软件工程第二次结对作业——校园失物招领

结对作业二——校园失物招领


项目 内容
这个作业属于哪个课程 H202601 软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 将"福大拾光"校园失物招领原型实现为可运行的 Web 应用,完成"发布—浏览/搜索—查看详情—联系发布者—更新状态"核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试
GitHub 仓库 102402112-102402110
学号与姓名 102402112 松格拉;102402110 姜思源
队友博客 松格拉的博客
队友博文 松格拉的博文

1. 具体分工

模块 / 任务 松格拉 姜思源
需求分析与原型对照 ✔ 主导 ✔ 参与
数据层 js/store.js(搜索 / 筛选 / 校验 / 统计 / 持久化) ✔ 主导 复审
种子数据 js/data.js ✔ 主导 —
页面结构 index.html 复审 ✔ 主导
样式 css/style.css(视觉设计) — ✔ 主导
视图渲染 js/views.js 参与 ✔ 主导
交互控制 js/app.js 主导 参与
单元测试用例设计(61 个) ✔ 主导 参与
测试报告页 test/test.html ✔ 主导 —
README 与目录说明撰写 参与 ✔ 主导
博客撰写 参与 ✔ 主导
GitHub 仓库创建 / 审核合并 PR ✔ 主导 Fork 并提交 PR

协作方式:松格拉创建仓库并负责数据层与测试,姜思源fork 仓库后负责界面与文档,
通过 Pull Request 合入,全程共 20+ 次有意义的 commit(按功能点提交,见第 11 节)。


2. PSP 表格

2.1 预估与实际对照表

PSP 2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 40 55
Estimate 估计这个任务需要多少时间 40 55
Development 开发 760 985
Analysis 需求分析(包括学习新技术) 90 80
Design Spec 生成设计文档 60 70
Design Review 设计复审 30 40
Coding Standard 代码规范 30 25
Design 具体设计 80 110
Coding 具体编码 350 480
Code Review 代码复审 60 80
Test 测试(自我测试,修改代码,提交修改) 60 100
Reporting 报告 400 565
Test Report 测试报告 90 120
Size Measurement 计算工作量 30 35
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 60 90
— 博客撰写 220 320
合计 1200 1605

2.2 时间分布可视化

阶段 预估 实际 偏差
计划 40 55 +15(+37.5%)
开发 760 985 +225(+29.6%)
报告 400 565 +165(+41.3%)
合计 1200 1605 +405(+33.8%)

2.3 偏差分析与总结

  1. 低估了「具体编码」的时间(预估 350 → 实际 480)。主要原因是:一开始想把
    「失物地图」和「时空匹配」也做进去,实现到一半发现工作量远超预期,于是调整为
    简化方案,中间浪费了一部分时间。教训是:原型设计可以天马行空,但要先做技术可行性评估。
  2. 测试环节实际耗时是预估的 1.67 倍。写完 61 个用例后,有 1 个用例首次运行是失败的
    (用例 2 的关键词设计有误,详见第 12 节),定位和修正花了额外时间。这说明
    测试数据本身也需要设计,不能随手写。
  3. 「事后总结」超时,是因为本次踩的坑(浏览器安全策略、剪贴板 API 降级)都值得记录,
    我们多花时间沉淀了经验。
  4. 下次改进:先做 15 分钟的技术预研(尤其是浏览器 API 兼容性),再开始估时;
    把「跑通一个最小闭环」作为第一个里程碑,而不是先追求功能全。

3. 解题思路描述与设计实现说明

3.1 总体思路

作业要求打通这条主流程:

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

我们二人围绕这条主线做设计,核心决策有三个:

决策一:为什么选择纯静态 Web(原生 HTML/CSS/JS),而不是框架?

作业明确要求「确保其他同学在下载你的所有文件后,用谷歌浏览器运行 HTML 文件就能展现预期结果」。
如果用 React/Vue,助教需要装 Node、跑 npm install、启动 dev server,任何一步出问题都会
导致「跑不起来」而丢便捷性分数。因此我们选择:

  • 原生 HTML + CSS + JavaScript,零框架、零构建、零依赖
  • 数据存储用 localStorage,无需后端服务
  • 单元测试框架 Mocha/Chai 也下载为本地文件,保证断网也能运行测试

这个决策的收益在测试环节非常明显:助教双击 index.html 就能看到完整的带数据界面。

决策二:三层架构分离

为了让 store.js 可被单元测试直接测试,我们把业务逻辑与 DOM 操作彻底解耦:

层级 文件 职责 是否碰 DOM
数据层 store.js 搜索、筛选、校验、统计、状态流转 ❌ 完全不碰
数据层 data.js 常量与示例数据 ❌
视图层 views.js 数据 → HTML 字符串 ❌(只拼字符串)
入口层 app.js 路由、事件绑定、编排 ✔ 唯一操作 DOM 的地方

这样做的直接好处:61 个单元测试可以直接 require('../js/store.js') 断言,
不需要 jsdom、不需要启动浏览器
。这正是分层带来的可测试性红利。

决策三:状态流转采用「不可变更新」

updateStatus(items, id, status) 不修改传入数组,而是返回新数组(内部用 map 生成新对象)。
这样做的原因:视图层可能在多处持有同一份数据引用,如果原地修改,容易产生「数据改了但界面没刷新」
或者「刷新了但引用了脏数据」的诡异问题。不可变更新的代价是内存开销略高,但对于校园级数据量完全可以接受。

3.2 关键实现的流程图

流程图 1:发布信息流程

                    ┌─────────────┐
                    │  点击发布信息 │
                    └──────┬──────┘
                           ▼
              ┌────────────────────────┐
              │ 选择类型(寻物 / 招领)  │
              └────────────┬───────────┘
                           ▼
              ┌────────────────────────┐
              │     填写表单字段        │
              │ 名称/类别/地点/日期/联系方式 │
              └────────────┬───────────┘
                           ▼
              ┌────────────────────────┐
              │    点击「确认发布」      │
              └────────────┬───────────┘
                           ▼
              ┌────────────────────────┐
              │  validateItem() 校验    │◄──── 数据层
              └────────────┬───────────┘
                     ┌─────┴─────┐
                  不通过        通过
                     │           │
                     ▼           ▼
          ┌──────────────┐  ┌──────────────────┐
          │ 逐字段标红    │  │  addItem() 写入   │
          │ 显示具体错误  │  │  localStorage     │
          └──────────────┘  └────────┬─────────┘
                                     ▼
                          ┌────────────────────┐
                          │ Toast 提示发布成功   │
                          │ 自动跳转「全部信息」  │
                          └────────────────────┘

流程图 2:浏览 / 搜索 / 详情 / 状态更新流程

   ┌───────────┐
   │  首页     │──输入关键词──►┌─────────────────┐
   └───────────┘               │ 跳转「全部信息」  │
                               │ 并预填关键词      │
   ┌───────────┐               └────────┬────────┘
   │ 全部信息页 │◄───────────────────────┘
   └─────┬─────┘
         │ 类型标签 / 类别 / 状态 / 关键词
         ▼
   ┌──────────────────────────┐
   │  searchItems() 组合筛选   │◄──── 数据层(纯函数)
   └────────────┬─────────────┘
                ▼
   ┌──────────────────────────┐
   │  渲染卡片列表(views.js)  │
   └────────────┬─────────────┘
                │ 点击卡片
                ▼
   ┌──────────────────────────┐
   │   打开详情抽屉(右滑入)   │
   │  · 物品信息              │
   │  · 联系方式(手机号脱敏)  │
   │  · 一键复制按钮           │
   └────────────┬─────────────┘
                │
     ┌──────────┴──────────┐
     ▼                     ▼
┌──────────┐        ┌────────────────┐
│ 复制联系  │        │ 标记已找到/已归还 │
│ 方式      │        └───────┬────────┘
└──────────┘                ▼
                  ┌──────────────────────┐
                  │ updateStatus() 更新   │◄──── 数据层
                  │ 返回新数组,持久化     │
                  └──────────┬───────────┘
                             ▼
                  ┌──────────────────────┐
                  │ 列表与详情同步刷新     │
                  │ 状态徽章变为「已找到」 │
                  └──────────────────────┘

3.3 数据流图(分层视角)

┌──────────────────────────────────────────────────────────────┐
│                          用户交互                              │
│   发布 · 搜索 · 筛选 · 查看详情 · 复制联系方式 · 标记状态        │
└───────────────────────────┬──────────────────────────────────┘
                            │ DOM 事件(事件委托)
                            ▼
┌──────────────────────────────────────────────────────────────┐
│                     入口层 app.js                              │
│   go() 路由切换  ·  handleSubmit()  ·  markClosed()            │
│   只做编排:读表单 → 调 store → 调 views → 写 DOM               │
└──────────┬────────────────────────────────┬──────────────────┘
           │                                │
           ▼                                ▼
┌────────────────────────┐      ┌──────────────────────────────┐
│    数据层 store.js      │      │      视图层 views.js          │
│  ┌──────────────────┐  │      │  ┌────────────────────────┐  │
│  │ searchItems()    │  │      │  │ renderCard()           │  │
│  │ validateItem()   │  │      │  │ renderDetail()         │  │
│  │ updateStatus()   │  │      │  │ renderStatCards()      │  │
│  │ calcStats()      │  │      │  │ renderDayChart()       │  │
│  │ load() / save()  │  │      │  │ renderMineList()       │  │
│  └──────────────────┘  │      │  └────────────────────────┘  │
│        纯函数 · 可单测   │      │      只输出 HTML 字符串        │
└───────────┬────────────┘      └──────────────────────────────┘
            │
            ▼
┌────────────────────────┐      ┌──────────────────────────────┐
│    localStorage        │      │   data.js 类别常量 + 示例数据  │
│  (浏览器本地持久化)    │      │   (首次访问自动注入)          │
└────────────────────────┘      └──────────────────────────────┘

3.4 重要的代码片段与解释

片段 1:关键词搜索 + 多条件组合筛选(store.js)

function searchItems(items, query) {
  var list = Array.isArray(items) ? items.slice() : [];
  if (!query) { return list; }

  var keyword = String(query.keyword || '').trim().toLowerCase();

  return list.filter(function (item) {
    if (keyword) {
      var haystack = [
        item.title || '',
        item.description || '',
        item.place || ''
      ].join(' ').toLowerCase();
      if (haystack.indexOf(keyword) === -1) { return false; }
    }
    if (query.type && item.type !== query.type) { return false; }
    if (query.category && item.category !== query.category) { return false; }
    if (query.status && item.status !== query.status) { return false; }
    return true;
  });
}

为什么这么写?

  • items.slice() 先拷贝再操作,保证函数是纯函数,不污染调用方数据。
    这是本项目的统一约定,单元测试「用例 15」专门验证了这一点。
  • 三层字段拼成 haystack 再统一匹配:用户搜「图书馆」可能想找「地点在图书馆的物品」,
    搜「校园卡」可能是物品名,搜「恐龙」可能是描述里的特征词。把标题、描述、地点合成一个
    搜索域,一次 indexOf 就能覆盖全部场景,比写三个 || 判断更简洁,也更容易扩展
    (将来要加「搜索联系人姓名」只需往数组里加一项)。
  • toLowerCase() 统一转小写实现大小写不敏感搜索。「用例 4」用 airpods / AIRPODS /
    AiRpOdS 三种写法验证了这个特性。
  • 筛选条件用「有值才判断」的短路写法:if (query.status && item.status !== query.status)。
    这样调用方只需传关心的条件,其他留空即可,无需构造完整的 query 对象。

片段 2:不可变的状态更新(store.js)

function updateStatus(items, id, status) {
  var list = Array.isArray(items) ? items : [];
  return list.map(function (item) {
    if (item.id !== id) { return item; }
    var next = Object.assign({}, item);      // 浅拷贝,生成新对象
    next.status = status;
    next.statusUpdatedAt = status === STATUS.CLOSED ? new Date().toISOString() : null;
    return next;
  });
}

为什么这么写?

  • 用 map 而非 forEach + 原地赋值:map 天然返回新数组,从函数签名就能看出
    「这是一个不可变更新」,调用方不会担心原数据被改。
  • Object.assign({}, item) 生成新对象而非改原对象。项目里同一个 id 的记录可能同时被
    列表渲染函数和详情抽屉引用,原地修改会导致「改了列表,详情看着新了,但点开别的又串了」
    这类难排查的 bug。
  • 回滚时把 statusUpdatedAt 置为 null:因为「重新打开」意味着这条记录不再是
    已结束状态,统计「平均找回时长」时不应把它算进去。「用例 21」验证了这个回滚行为。

片段 3:表单校验(store.js)—— 一次返回全部错误

function validateItem(data) {
  var errors = {};
  if (!data || typeof data !== 'object') {
    return { valid: false, errors: { _global: '数据格式不正确' } };
  }

  if (!data.title || String(data.title).trim().length < 2) {
    errors.title = '物品名称至少需要 2 个字符';
  } else if (String(data.title).trim().length > 30) {
    errors.title = '物品名称不能超过 30 个字符';
  }
  // ... 其余字段同理 ...

  // 联系方式采用「二选一」策略:手机号或 IM 至少填一个
  var phoneOk = /^1[3-9]\d{9}$/.test(String(contact.phone || '').trim());
  var imOk = String(contact.im || '').trim().length >= 4;
  if (!phoneOk && !imOk) {
    errors.contact = '请至少填写一个有效的联系方式(11 位手机号,或 QQ / 微信号)';
  }

  var keys = Object.keys(errors);
  return { valid: keys.length === 0, errors: errors };
}

为什么这么写?

  • 不提前 return,把所有错误一次性收集完再返回。如果写成「发现第一个错误就返回」,
    用户要反复提交 5 次才能把 5 个字段的错误都填对,体验很差。现在一次提交就能把
    title、category、date、contact 等全部标红。「用例 31」专门验证了这个行为。
  • 用 errors 对象的键对应表单字段名,渲染层只需 $('[data-error="' + key + '"]')
    就能定位到错误提示容器,无需写一堆 if-else 映射。
  • 手机号正则 /^1[3-9]\d{9}$/:这是中国大陆手机号的常用校验规则(1 开头,
    第二位 3-9,共 11 位)。比只判断长度更严格,能拦住 12345678901 这类无效输入。
  • 联系方式「二选一」:考虑到学生可能只愿意留微信不留手机号,所以设计成
    「手机号有效 或 IM 长度 ≥ 4」满足其一即可。「用例 29」「用例 30」分别验证了
    两种路径。

片段 4:事件委托(app.js)—— 一个监听器管全站

document.addEventListener('click', function (e) {
  var navEl = e.target.closest('[data-nav]');
  var actEl = e.target.closest('[data-action]');
  var card  = e.target.closest('.item-card');
  var copyEl = e.target.closest('[data-copy]');

  // 优先级很重要:先判断更具体的目标
  if (navEl) { e.preventDefault(); go(navEl.getAttribute('data-nav')); return; }
  if (copyEl) { e.stopPropagation(); /* 复制逻辑 */ return; }
  if (actEl)  { e.stopPropagation(); /* 操作逻辑 */ return; }
  if (card)   { openDetail(card.getAttribute('data-id')); }
});

为什么这么写?

  • 列表会频繁重渲染(搜索、筛选、标记状态后都要重新生成卡片 HTML)。如果给每张卡片
    单独 addEventListener,每次重渲染都要重新绑定,既冗余又容易漏绑、重复绑。
    事件委托把监听器挂在整个 document 上,不管列表怎么重渲染,事件永远有效。
  • 判断顺序体现优先级:一张卡片里面可能同时包含「卡片本身」(data-id)和
    「卡片内的复制按钮」(data-copy)。必须先判断更具体的目标,否则点复制按钮会同时
    触发「打开详情」。return 语句保证了这一点。
  • closest() 会向上查找祖先节点,所以用户点到卡片里的文字、图标、标签都能正确识别
    为「点了这张卡片」,不需要给每层子元素绑定事件。

片段 5:剪贴板复制的降级方案(app.js)

function copyText(text) {
  function fallback() {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    try {
      document.execCommand('copy');
      toast('已复制:' + text, 'success');
    } catch (e) {
      toast('复制失败,请手动选择复制', 'error');
    }
    ta.remove();
  }

  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text)
      .then(function () { toast('已复制:' + text, 'success'); })
      .catch(fallback);          // 现代 API 失败时回退
  } else {
    fallback();                  // 不支持现代 API 时直接回退
  }
}

为什么这么写?

  • navigator.clipboard 是较新的 API,在 file:// 协议下很多浏览器会拒绝授权
    (因为不是安全上下文)。而本作业要求「双击 HTML 直接运行」,恰好就是 file:// 场景。
    如果只用新 API,助教本地测试时会遇到「点了复制没反应」的问题。
  • document.execCommand('copy') 虽然已标记为废弃,但在 file:// 下仍广泛可用,
    作为兜底方案效果很好。
  • 双层保险:先试新 API,.catch() 到降级方案;如果浏览器连 navigator.clipboard
    都没有(老版本),直接走降级。这保证了在任何环境下复制功能都可用。

4. 附加特点设计与展示

4.1 附加特点一:一键复制联系方式 + 手机号脱敏保护

设计的创意独到之处与意义

作业的评分规则里明确举例提到「一键复制联系方式」这个方向。我们在此基础上做了
一个额外思考:直接明文展示手机号,等于把发布者的隐私暴露给所有访问页面的爬虫。

因此我们的设计是:

状态 显示 用户操作
初始 138****5678(脱敏) 点击手机号
第一次点击 13812345678(明文展开) 再次点击
第二次点击 不变 自动复制到剪贴板 + Toast 提示

这个设计的双重价值:

  1. 对发布者:号码不会被自动化脚本批量抓取(很多失物招领页面会被人肉爬虫扫号码用于推销);
  2. 对使用者:真正的联系动作只需两次点击,且复制后有明确的成功反馈——「已复制:13812345678」,
    避免用户不确定有没有复制成功而反复点击。

同时详情面板底部还有两个显式的大按钮「📋 一键复制手机号」「📋 一键复制微信号」,
给追求效率的用户一条更短的路径。

实现思路

  • 脱敏:store.js 的 maskPhone() 把 11 位手机号的第 4-7 位替换为 ****,
    非手机号格式的输入原样返回(不会误截断短的 IM 号)。
  • 展开状态用一个模块级变量 phoneRevealed 管理,点击手机号时先判断是否已展开,
    未展开则展开(并刷新抽屉),已展开则执行复制。
  • 复制走 copyText(),内部有 navigator.clipboard → execCommand 的降级链(见 3.4 片段 5)。

关键代码片段

// views.js —— 渲染联系方式时,根据是否已展开决定显示脱敏还是明文
var showPhone = opts.maskedPhone && phone ? global.LFStore.maskPhone(phone) : phone;
html.push(
  '<div class="contact-row">',
  '  <span class="contact-row__label">手机号</span>',
  '  <span class="contact-row__value" data-copy="' + escapeHtml(phone) + '">',
       escapeHtml(showPhone),
       (opts.maskedPhone ? ' <span>点击展开</span>' : ''),
  '  </span>',
  '</div>'
);
// store.js —— 脱敏实现,非手机号输入原样返回
function maskPhone(phone) {
  var p = String(phone || '');
  if (!/^1\d{10}$/.test(p)) { return p; }   // 不符合手机号格式则不动
  return p.slice(0, 3) + '****' + p.slice(7);
}
// app.js —— 第一次点击展开,第二次点击才复制
if (isPhone && !phoneRevealed) {
  phoneRevealed = true;
  refreshDetail();                    // 重渲染为明文
  toast('已显示完整手机号');
} else {
  copyText(raw);                      // 复制 + Toast 反馈
}

4.2 附加特点二:数据统计看板

设计的创意独到之处与意义

作业不要求后台管理,但我们认为:一个失物招领平台光有「列表」是没有生命力的,
它应该能回答一个更有价值的问题——「这个校园的失物找回情况到底怎么样?」

因此我们增加了一个数据看板页面,它承担三个作用:

  1. 对用户:展示「找回率 40%」这类数字,传递「发了真的有用」的信任感,
    激励更多人来发布和归还;
  2. 对校园管理者:想优化失物招领工作(比如在哪个楼多放招领点),
    需要知道「哪类物品最容易丢」「哪个时间段发布量高」,看板直接给出答案;
  3. 对本项目:让项目从「信息展示工具」升级为「有数据洞察的平台」,
    这是纯粹加分的设计,也和我们原型里「福大拾光」这个品牌调性一致。

看板包含四张指标卡和两张图表:

组件 内容 为什么有用
指标卡 ×4 累计发布 / 成功找回归还 / 正在寻找 / 找回率 一眼看清全局
近 7 天发布趋势(柱状图) 每日新增数量 看活跃度变化,发现「周末丢东西多」之类的规律
物品类别分布(条形图) 各类别数量排序 提示高发类别(本例中电子产品居多)

实现思路

关键决策:不引入图表库(如 ECharts / Chart.js),全部用纯 CSS 绘制。

原因有两点:

  1. 体积与依赖:ECharts 压缩后还有几百 KB,而我们整个项目才几十 KB。
    为了两张小图表引入一个大库,性价比极低,也违背了「零依赖」的架构决策;
  2. 学习成本与可控性:CSS 画的柱状图高度可控、样式和全站统一、还能直接加 CSS 过渡动画,
    视觉效果并不比图表库差。

技术实现是:用数据算出每个柱子的高度百分比,直接写入内联 style 的 height,
配合 CSS 的 transition: height .6s 就有生长动画。

所有统计计算都在 store.js 的 calcStats() 里完成(纯函数,可单测),
views.js 只负责把算好的数字画成 HTML。

关键代码片段

// store.js —— 统计计算(纯函数,被单元测试覆盖)
function calcStats(items) {
  var list = Array.isArray(items) ? items : [];
  var stats = { total: list.length, lost: 0, found: 0, closed: 0, open: 0,
                closedRate: 0, byCategory: {}, last7Days: [] };

  list.forEach(function (item) {
    if (item.type === TYPE.LOST) { stats.lost++; }
    if (item.type === TYPE.FOUND) { stats.found++; }
    if (item.status === STATUS.CLOSED) { stats.closed++; } else { stats.open++; }

    var cat = item.category || '其他';
    stats.byCategory[cat] = (stats.byCategory[cat] || 0) + 1;   // 按类别累加
  });

  // 找回率:四舍五入为整数百分比;总数为 0 时直接给 0,避免除零得到 NaN
  stats.closedRate = stats.total === 0
    ? 0
    : Math.round((stats.closed / stats.total) * 100);

  // 近 7 天每日发布量:从 6 天前循环到今天,保证返回的永远是 7 个点
  var today = new Date();
  today.setHours(0, 0, 0, 0);
  for (var i = 6; i >= 0; i--) {
    var day = new Date(today.getTime() - i * 86400000);
    var key = day.getFullYear() + '-' +
      String(day.getMonth() + 1).padStart(2, '0') + '-' +
      String(day.getDate()).padStart(2, '0');
    var count = list.filter(function (item) {
      return String(item.createdAt).slice(0, 10) === key;
    }).length;
    stats.last7Days.push({ date: key, count: count });
  }

  return stats;
}

💡 两个细节值得说明:

  1. stats.total === 0 ? 0 : ... 这个三元判断是必要的。如果没有它,
    空数据时 0/0 会得到 NaN,界面上就会显示「找回率 NaN%」。
    单元测试「用例 39」专门断言了 Number.isNaN(s.closedRate) === false。
  2. 日期循环写成 for (var i = 6; i >= 0; i--) 而不是正序,是为了让数组里
    的日期从早到晚递增,这样画出来的柱状图天然就是时间从左到右的顺序。
// views.js —— 纯 CSS 柱状图:用数据算出高度
function renderDayChart(days) {
  var max = Math.max.apply(null, days.map(function (d) { return d.count; }).concat([1]));
  return days.map(function (d) {
    // 数量为 0 时给 4px 的最小高度,让用户能看到「这天确实有柱子,只是值为 0」
    var height = d.count === 0 ? 4 : Math.round((d.count / max) * 140) + 8;
    var label = d.date.slice(5).replace('-', '/');
    return [
      '<div class="chart__col">',
      '  <div class="chart__num">' + d.count + '</div>',
      '  <div class="chart__bar" style="height:' + height + 'px"></div>',
      '  <div class="chart__label">' + label + '</div>',
      '</div>'
    ].join('');
  }).join('');
}

💡 .concat([1]) 的作用:当所有日期的 count 都是 0 时,max 会是 0,
后面的除法就会变成 0/0 = NaN。加一个 [1] 保证分母至少是 1。
这是写图表代码时非常容易忽略的边界,我们把它记录下来。


4.3 其他值得说明的人性化设计

  1. 首次访问自动注入 10 条示例数据:助教老师打开即可看到完整界面效果,
    不用先手动录入 10 条才能评估「美观性」。同时提供控制台命令 localStorage.clear()
    方便查看空状态。
  2. 表单错误实时清除:用户修改某个字段时,该字段的红色提示立即消失,
    而不是等到下次提交才更新,减少「我已经改了但还红着」的困惑。
  3. 状态可回滚:误把「进行中」点成「已找到」后,可以在详情页点「重新标记为进行中」,
    不用删了重发。
  4. 全局 Toast 反馈:每一次「发布成功」「已复制」「已删除」「已标记」都有明确反馈,
    用户永远知道自己的操作有没有生效。
  5. ESC 键关闭详情抽屉:符合桌面端用户的直觉习惯。
  6. 详情页显示「N 天前」:2026-10-05(3 天前) 比单纯的日期更直观,
    因为失物招领的时效性很强。

5. 目录说明与使用说明

5.1 目录是如何组织的

fzu-lost-and-found/
├── index.html              # 单页应用入口,包含 5 个视图的完整结构
├── package.json            # 仅用于命令行跑单元测试,网页运行不需要它
├── README.md               # 目录说明 + 使用说明
│
├── css/
│   └── style.css           # 全站样式(主题变量 / 布局 / 组件 / 响应式)
│
├── js/
│   ├── store.js            # ★ 数据层:业务逻辑核心(可单测)
│   ├── data.js             # 常量与示例数据
│   ├── views.js            # 视图层:数据 → HTML
│   └── app.js              # 入口层:路由 + 事件 + 编排
│
├── test/
│   ├── test.html           # 浏览器端测试报告页
│   ├── store.test.js       # 61 个单元测试用例
│   └── lib/                # Mocha + Chai 本地文件(保证断网可跑)
│
└── docs/
    └── 博客园博文.md        # 本博客原文

组织思路:按「职责」而不是「文件类型」划分文件夹。

  • 所有业务逻辑集中在 js/store.js,它是项目的核心资产,也是单元测试的主要对象;
  • 视图层与入口层分离,保证「改样式」和「改逻辑」互不干扰;
  • 测试相关文件全部放在 test/,第三方测试框架放在 test/lib/ 下,
    与业务代码物理隔离,一眼就能看出「哪些是别人的代码,哪些是我们写的」;
  • 文档放在 docs/,与代码分离。

5.2 老师和助教如何运行我们的网页

运行主程序

  1. 从 GitHub 下载(Code → Download ZIP)并解压,注意保持目录结构完整;
  2. 用 谷歌浏览器(Chrome) 双击打开根目录的 index.html;
  3. 页面会自动加载 10 条示例数据,可直接体验全部功能。

运行单元测试

方式一:浏览器

用谷歌浏览器打开 test/test.html,立即看到测试报告,全通过显示绿色的 61 passing。

方式二:命令行

npm install    # 首次需要,安装 mocha 与 chai
npm test       # 输出:61 passing

6. 单元测试

6.1 测试工具的选择与学习过程

选用的工具

  • Mocha 10.7.3:测试运行器,负责组织用例(describe / it)和输出报告;
  • Chai 4.5.0:断言库,提供 expect(xxx).to.equal(yyy) 这类自然语言风格的断言。

选择它们的原因是:作业附录里明确推荐了「测试框架 Mocha 实例教程」和
「10 个可靠的 JavaScript 测试工具」,而 Mocha + Chai 是 JavaScript 社区最经典的组合,
资料最丰富,遇到问题容易搜到答案。

我们是怎么学单元测试的

第一步:先搞懂「什么是单元测试」

我们读了邹欣老师的《关于单元测试和回归测试》,得到的核心认知是:

单元测试是对代码中最小可测试单元(通常是一个函数)的验证,
它的价值不在于「证明代码是对的」,而在于当你改动代码时,能立刻知道有没有破坏原有功能。

这个认知直接影响了我们的架构决策:为了让 store.js 可被单测,我们刻意把它写成了
不依赖 DOM 的纯函数集合
。如果一开始就把逻辑写在事件处理函数里,就根本没法测。

第二步:跟着教程跑通第一个用例

我们参考了廖雪峰的 JavaScript 教程和 Mocha 官方文档,先写了一个最简单的用例跑通:

describe('冒烟测试', function () {
  it('1 应该等于 1', function () {
    expect(1).to.equal(1);
  });
});

跑通后再逐步替换成真实的业务断言。

第三步:给项目写一份自己的简易教程

我们的教程如下(也希望能帮到其他同学):

① 安装

npm install --save-dev mocha chai

② 在 package.json 里配置测试命令

{
  "scripts": {
    "test": "mocha test/store.test.js --reporter spec"
  }
}

③ 写测试文件(test/store.test.js)

// 1) 引入被测代码和断言库
var Store = require('../js/store.js');
var expect = require('chai').expect;

// 2) describe 描述一个「测试套件」,通常对应一个功能模块
describe('【搜索】searchItems', function () {

  // 3) it 描述一个「测试用例」,写明「应该发生什么」
  it('用例1:应能按物品名称精确命中', function () {
    var data = [{ id: 'T1', title: '校园卡', description: '', place: '' }];
    var result = Store.searchItems(data, { keyword: '校园卡' });

    // 4) 用 expect 断言
    expect(result).to.have.lengthOf(1);
    expect(result[0].id).to.equal('T1');
  });
});

④ 运行

npm test

⑤ 看懂报告

  【搜索】searchItems - 关键词匹配
    √ 用例1:应能按物品名称精确命中(关键词「校园卡」)
    √ 用例2:应能匹配详细描述中的文字
  61 passing (58ms)
  • √ 绿色对勾 = 通过;× 红色叉号 = 失败,下方会打印期望值和实际值;
  • 我们在断言里都写了中文说明,失败时能直接看出是哪条业务规则被破坏了。

⑥ 让测试能在浏览器里跑(本项目采用的方式)

因为作业要求「双击 HTML 就能运行」,我们还额外做了一个 test/test.html,
把 Mocha/Chai 换成浏览器版并下载到本地:

<div id="mocha"></div>
<script src="lib/mocha.js"></script>
<script src="lib/chai.js"></script>
<script>mocha.setup({ ui: 'bdd', reporter: 'spec' });</script>

<!-- 被测代码 -->
<script src="../js/store.js"></script>
<script src="../js/data.js"></script>
<!-- 测试用例 -->
<script src="store.test.js"></script>

<script>mocha.run();</script>

打开这个 HTML 就能看到漂亮的测试报告,助教无需任何环境配置。

一点心得

我们最大的体会是:单元测试的价值在「重构」时才能真正体现出来。
开发过程中我们改过 calcStats 的实现(一开始用 for 循环,后来改成 forEach),
改完之后直接跑 npm test,61 个用例 58 毫秒出结果,立刻确认没有破坏任何原有功能。
如果没有这套测试,就得手工翻遍每个页面去点,效率差了几十倍。

6.2 单元测试代码展示

完整用例文件:test/store.test.js,共 61 个用例,全部通过。

可以看到,测试代码被分成了 8 个套件,每个套件对应一个功能模块。

测试的函数清单

套件 测试的函数 用例数 重点覆盖
【搜索】 searchItems(关键词部分) 9 标题/描述/地点匹配、大小写、trim、空值
【筛选】 searchItems(条件部分) 7 类型、类别、状态、组合、纯函数性
【状态更新】 updateStatus 7 状态转换、不可变性、回滚、非法 id
【表单校验】 validateItem 10 各字段边界、多错并返、非法入参
【统计】 calcStats 9 计数、找回率、7 天统计、除零、空数据
【工具函数】 sortByTimeDesc findById maskPhone daysBetween generateId todayString 9 排序正确性、格式校验、唯一性
【持久化】 save load addItem removeItem 5 读写一致、跨会话、数据损坏降级
【示例数据】 data.js 的数据质量 5 类别合法性、内置数据自身可过校验

部分关键测试代码

(1)边界值测试:物品名称长度

it('用例24:物品名称为空或过短(1 个字)应校验失败', function () {
  var d = validData(); d.title = '';
  expect(Store.validateItem(d).valid).to.equal(false);
  expect(Store.validateItem(d).errors).to.have.property('title');

  d.title = '卡';      // 边界:恰好 1 个字符,应该失败
  expect(Store.validateItem(d).valid).to.equal(false);
});

it('用例25:物品名称超过 30 字应校验失败', function () {
  var d = validData();
  d.title = new Array(32).join('测');   // 31 个字符,越过上限
  var r = Store.validateItem(d);
  expect(r.valid).to.equal(false);
  expect(r.errors.title).to.contain('30');
});

设计说明:new Array(32).join('测') 是一个常用技巧,生成 31 个重复字符。
验证的是「上限 +1」这个经典的边界值。

(2)状态更新的不可变性测试

it('用例18:不应修改原数组(避免视图层数据脏读)', function () {
  var originalStatus = Store.findById(testItems, 'T1').status;
  Store.updateStatus(testItems, 'T1', 'closed');
  expect(Store.findById(testItems, 'T1').status).to.equal(originalStatus);
});

设计说明:这条用例保护的是架构约定。如果将来有人把 updateStatus 改成原地修改,
这条测试会立刻失败,从而拦住可能引发界面 bug 的改动。这是「用测试固化架构决策」。

(3)除零边界测试

it('用例39:边界 —— 空数据时所有统计应为 0,且不出现除零 NaN', function () {
  var s = Store.calcStats([]);
  expect(s.total).to.equal(0);
  expect(s.closedRate).to.equal(0);
  expect(Number.isNaN(s.closedRate)).to.equal(false);   // 关键断言
  expect(s.last7Days).to.have.lengthOf(7);
});

设计说明:Number.isNaN 在 Chai 的 expect 里不能直接调用(它会尝试把 NaN 当
断言目标),所以用原生方法包裹。这条用例的价值在于:如果不写,
空数据时界面会显示「找回率 NaN%」,而这个 bug 在数据充足时永远不会暴露。

(4)异常入参的健壮性测试

it('用例8:数据源为空数组或非法入参时,应安全返回空数组', function () {
  expect(Store.searchItems([], { keyword: '任意' })).to.have.lengthOf(0);
  expect(Store.searchItems(null, { keyword: '任意' })).to.have.lengthOf(0);
  expect(Store.searchItems(undefined, {})).to.have.lengthOf(0);
});

it('用例32:边界 —— 传入 null / undefined / 非对象时应安全返回失败', function () {
  expect(Store.validateItem(null).valid).to.equal(false);
  expect(Store.validateItem(undefined).valid).to.equal(false);
  expect(Store.validateItem('字符串').valid).to.equal(false);
});

设计说明:传入 null 和传入 '字符串' 是两种不同性质的错误(空值 vs 类型错误),
我们特意都测了。因为 typeof '字符串' === 'string',如果代码里只写
if (!data) return ...,字符串会绕过检查然后 .title 得到 undefined,
后续逻辑可能抛出 TypeError。

(5)unique 性压力测试

it('用例49:generateId 应返回非空且唯一的字符串 ID', function () {
  var ids = {};
  for (var i = 0; i < 500; i++) {
    var id = Store.generateId();
    expect(id).to.be.a('string');
    expect(id.length).to.be.greaterThan(5);
    expect(ids[id]).to.equal(undefined);   // 无重复
    ids[id] = true;
  }
});

设计说明:generateId 用「时间戳 + 随机串」实现,理论上存在碰撞可能。
循环 500 次检测重复,是验证随机部分足够分散的实用做法。
500 次在毫秒级完成,不会拖慢测试。

6.3 构造测试数据的思路

数据构造的整体策略

我们没有随手拿几条数据测,而是刻意设计了一份能覆盖各种组合的测试数据集 buildTestItems():

function buildTestItems() {
  var today = new Date();
  function iso(daysAgo) {
    var d = new Date(today.getTime() - daysAgo * 86400000);
    return d.toISOString();
  }
  function dateStr(daysAgo) { return iso(daysAgo).slice(0, 10); }

  return [
    { id: 'T1', type: 'lost',  title: '校园卡',        category: '证件卡类',
      place: '图书馆三楼',  date: dateStr(1), description: '蓝色卡套,学号 2023 开头',
      contact: { phone: '13800000001', im: 'user_t1' }, status: 'open',   createdAt: iso(1) },

    { id: 'T2', type: 'found', title: 'AirPods 耳机',  category: '耳机音响',
      place: '紫金楼 201', date: dateStr(2), description: '白色,盒盖写有字母 Z',
      contact: { phone: '13800000002', im: 'user_t2' }, status: 'closed', createdAt: iso(2) },

    { id: 'T3', type: 'lost',  title: '黑色雨伞',      category: '服饰穿戴',
      place: '第三食堂门口', date: dateStr(3), description: '伞骨变形,挂云朵挂件',
      contact: { phone: '13800000003', im: '' },        status: 'open',   createdAt: iso(3) },

    { id: 'T4', type: 'found', title: '宿舍钥匙',      category: '钥匙门禁',
      place: '12 号楼门口',  date: dateStr(5), description: '挂蓝色恐龙挂饰,两把钥匙',
      contact: { phone: '',            im: 'wechat_t4' }, status: 'closed', createdAt: iso(5) },

    { id: 'T5', type: 'lost',  title: '考研英语真题',  category: '书籍资料',
      place: '图书馆二楼',   date: dateStr(8), description: '黄皮书,有大量铅笔笔记',
      contact: { phone: '13800000005', im: 'user_t5' }, status: 'open',   createdAt: iso(8) }
  ];
}

每一维度的设计意图

维度 设计 意图
类型 3 条 lost + 2 条 found 验证类型筛选能正确分类,且数量不等可发现「硬编码数量」的错误
状态 3 条 open + 2 条 closed 验证状态筛选与找回率计算(2/5 = 40%)
地点 T1、T5 都在「图书馆」,T2 在「紫金楼」 验证关键词搜「图书馆」能命中 2 条(多条命中场景,而非只有 1 条)
描述词 T3 有「挂件」,T4 有「挂饰」,都用「挂」字能命中 验证搜索在描述字段生效,且子串匹配正确
大小写 T2 标题是 AirPods(大写 P) 用小写 airpods 搜索验证大小写不敏感
联系方式 T3 只有手机号、T4 只有 IM、T1/T2/T5 两者都有 覆盖「二选一」校验的两条分支,以及渲染层「只显示一种」的情况
时间 1/2/3/5/8 天前 精心设计:前 4 条在 7 天内、T5 在 8 天外,用于验证「近 7 天统计只含 4 条」
分类 5 条各属不同类别 验证类别聚合正确,且各类别数量之和等于总数

我们是如何考虑各种情况的

  1. 正常路径(Happy Path):用例 1、16、23、33 —— 确认基础功能可用;
  2. 边界值(Boundary):用例 24/25(标题长度 1 和 31)、用例 28(未来日期)、
    用例 39(空数据除零)—— 专门测「恰好越界」的位置,这是 bug 最集中的地方;
  3. 异常路径(Error Path):用例 8、20、32、48、54 —— 传 null、不存在的 id、
    非法日期、损坏的 JSON,验证程序不崩溃且行为可预期;
  4. 组合与交互:用例 13、14 —— 多条件组合,包括「组合后必然为空」的互斥场景;
  5. 不变量(Invariant):用例 34(closed + open === total)、用例 38
    (各分类之和 === total)—— 这类断言不依赖具体数值,而是验证业务逻辑的内在一致性,
    即使将来测试数据变了也不会失效;
  6. 纯函数性:用例 15、18、43 —— 验证函数不修改入参,保护架构约定。

如何考虑将来测试人员的刁难

我们自己扮演了一次「刁难的测试人员」,问了自己几个问题:

可能的刁难 我们的防御
「搜索空字符串会怎样?」 用例 6 断言返回全部数据
「搜索带前后空格的关键词呢?」 用例 5 验证自动 trim
「搜一个绝对搜不到的词,返回 null 还是空数组?」 用例 7 明确要求返回空数组(不是 null),因为渲染层用 .length 判断
「传个不存在的 id 去更新状态?」 用例 20 验证不报错、其他记录不受影响
「会不会把原数组改了?」 用例 15、18、43 三处验证
「空数据时统计会不会崩?」 用例 39、40 验证
「localStorage 里是坏数据怎么办?」 用例 54 验证降级为空数组
「手机号传个 5 位数会怎样?」 用例 46 验证原样返回而非错误截断
「示例数据本身合法吗?」 用例 57、58、59 反过来验证内置数据的质量
「ID 会不会重复?」 用例 49 循环 500 次检测

对测试设计本身的评价(作业要求「结合测试评估的要求,对自己的测试设计进行评价」):

我们认为这 61 个用例基本能满足本项目的测试要求,理由如下:

✅ 覆盖完整:store.js 的 15 个导出函数全部被覆盖,没有测试盲区;
✅ 深度足够:每个函数都覆盖了正常路径、边界值、异常路径三类;
✅ 可自动化:npm test 一条命令跑完,58 毫秒出结果,可以放进每日构建;
✅ 可回归:断言带中文说明,失败的用例能直接定位到被破坏的业务规则。

⚠️ 但我们也清楚它的局限:

  1. 未覆盖视图层(views.js 的 HTML 拼接)和入口层(app.js 的事件绑定)。
    这两层依赖 DOM,需要 jsdom 或 Puppeteer 这类工具才能测,超出了本次作业范围。
    我们通过人工走查 + 手工点击测试来覆盖这部分;
  2. 未做覆盖率统计。可以引入 nyc(Istanbul)生成覆盖率报告,
    但会增加依赖,与「零依赖」的架构决策冲突;
  3. 未测浏览器兼容性。作业统一要求用 Chrome,所以没有做跨浏览器测试。

如果后续要继续完善,我们会优先补 Puppeteer 端到端测试,
用脚本自动完成「打开页面 → 发布一条 → 搜索它 → 打开详情 → 标记已找到」
这条主流程,把目前只能手工验证的部分也自动化起来。


7. GitHub 代码签入记录

7.1 Commit 记录

📌 ** 下图是主仓库的提交记录(截图自 GitHub 的 Commits 页面):
**

image

7.2 Commit 信息规范

我们参照《Commit message 和 Change log 编写指南》采用了 type: 描述 的格式:

类型 含义 示例
feat 新功能 feat: 实现关键词搜索与多条件筛选
fix 修 bug fix: 修复空数据时找回率显示 NaN 的问题
docs 文档 docs: 补充 README 目录说明与运行指南
test 测试 test: 新增 61 个单元测试用例
style 样式 style: 优化卡片悬停动效与移动端适配
refactor 重构 refactor: 将状态更新改为不可变实现

7.3 按功能点拆分的提交顺序

按照「每做完一个功能,编译成功后至少 commit 一次」的要求,我们的提交大致是:

feat: 初始化项目结构与基础样式
feat: 实现数据层 store.js(本地存储读写)
feat: 实现物品卡片列表渲染
feat: 实现发布表单与表单校验
feat: 实现关键词搜索功能
feat: 实现类型/类别/状态多条件筛选
feat: 实现详情抽屉与联系方式展示
feat: 实现一键复制联系方式(含降级方案)
feat: 实现手机号脱敏保护
feat: 实现状态标记为已找到/已归还
feat: 实现状态回滚功能
feat: 实现数据统计看板与 CSS 图表
feat: 实现「我的发布」管理页
feat: 添加 10 条校园示例数据
style: 优化响应式布局,适配移动端
fix: 修复复制在 file:// 协议下失效的问题
fix: 修复近 7 天统计在零数据时出现 NaN
test: 搭建 Mocha + Chai 测试环境
test: 新增 61 个单元测试用例
docs: 撰写 README 目录说明与使用说明
docs: 撰写结对编程博客

7.4 Pull Request 协作记录

📌 **下图是仓库的 Pull Request 记录(Pull requests → Closed),可见已合并的 PR #1:

6461fac0f6eecf01f1cc43ca6142a9fc

姜思源 fork 仓库后在自己的分支上开发,每完成一个模块就发起一次 Pull Request,
松格拉 在合并前做 code review。这样可以保证主仓库的每一次提交都是可运行的。

3a450d73a187e35d5bc842ed3820f02c


8. 遇到的代码模块异常或结对困难及解决方法

问题一:一键复制功能在 file:// 协议下失效

问题描述

「一键复制联系方式」在本地开发生成的页面上点击没有任何反应,控制台报错:

NotAllowedError: Failed to execute 'writeText' on 'Clipboard':
Document is not focused. / Write permission denied.

做过哪些尝试

  1. 一开始怀疑是按钮事件没绑上,在事件处理里加 console.log 确认——事件是触发了的;
  2. 检查发现 navigator.clipboard.writeText() 返回的 Promise 被 reject,但我们的代码
    当时只写了 .then() 没写 .catch(),导致错误被静默吞掉,表现成「点击无反应」;
  3. 搜索资料后确认根因:navigator.clipboard 属于安全上下文 API,在 file:// 协议下
    浏览器会拒绝授权
    。而本作业恰恰要求「双击 HTML 直接运行」,正好踩中这个坑;
  4. 尝试用 document.execCommand('copy') 替代——在 file:// 下可用;
  5. 但 execCommand 已经标记废弃,未来可能被移除,不能只用它。

是否解决:✅ 已解决

解决方案:采用双层降级策略——优先用现代 navigator.clipboard,
.catch() 中回退到 document.execCommand;如果浏览器连 clipboard 都没有,直接走降级。
两种路径都给用户 Toast 反馈。

有何收获

  • 学到了「API 可用性 ≠ API 能用」。navigator.clipboard 在现代浏览器里都存在,
    但在 file:// 下不可用。这提醒我们:部署形态会影响 API 可用性,写代码时要考虑
    最终运行环境,而不是只在开发环境测试;
  • 学到了 Promise 一定要 .catch()。这次的「点击无反应」就是因为 unhandled rejection
    被静默忽略,排查时多花了不少时间;
  • 加深了对「渐进增强 / 优雅降级」的理解——不是所有用户都在最新浏览器和 HTTPS 环境下。

问题二:单元测试首次运行有 1 个用例失败——「测试数据设计错误」

问题描述

写完 61 个用例后第一次运行,显示:

59 passing
1 failing
1) 【搜索】searchItems - 关键词匹配
   用例2:应能匹配详细描述中的文字(关键词「挂饰」):
   AssertionError: expected [ { id: 'T4', ... } ] to have a length of 2 but got 1

做过哪些尝试

  1. 第一反应是「搜索函数有 bug」,但仔细看数据后发现是我自己写错了测试数据:
    T3 的描述是「挂云朵挂件」,T4 的描述是「挂蓝色恐龙挂饰」——
    「挂件」和「挂饰」虽然都有「挂」字,但没有「挂饰」这个共同的连续子串,
    所以搜「挂饰」只能命中 T4;
  2. 确认 searchItems 本身逻辑正确(子串匹配、多字段合并都对);
  3. 思考如何修改:是改测试数据,还是改断言?

是否解决:✅ 已解决

解决方案:选择同时修改数据断言并补充用例,而不是简单地把 lengthOf(2) 改成 1:

  • 把用例 2 的关键词改为「挂」(能同时命中两条),保留「多条命中」的验证意图;
  • 新增用例 2b,用「恐龙」这个只出现在 T4 描述中的词,专门验证
    「关键词只出现在描述字段、不出现在标题和地点时也能命中」。

这样既修正了错误,又让测试覆盖更全面。

有何收获

  • 测试代码也是代码,同样需要设计。这次失败不是产品 bug,而是测试设计缺陷。
    如果当初随手编数据,很可能写成「T3 描述含挂饰、T4 描述含挂件」这种想当然的数据,
    然后为了让测试通过而把断言改成 lengthOf(1),那就把测试的意义彻底丢掉了;
  • 学会了区分两种情况:「产品代码错了」vs「测试写错了」。前者要改源码,
    后者要改测试。绝不能为了让测试通过而弱化断言,那等于自欺欺人;
  • 体会到「失败信息足够详细」的重要性。因为断言里写了中文描述、.with.lengthOf(2)
    也给出了期望值,一眼就能定位问题,省去了大量排查时间。

结对困难:对「界面美观」标准的理解不一致

问题描述

我个人认为功能性最重要,界面「能用就行」;但是姜思源希望多做视觉效果(渐变、阴影、动效)。
两人在样式代码上产生了分歧:我 觉得 姜花了太多时间在「不重要的地方」,
但她 觉得 我 的界面「太朴素,作业评分里美观性占 10 分」。

做过哪些尝试

  1. 一起重新读了一遍评分规则,发现美观性确实占 10 分,而且规则里明确写了
    「朴素不加修饰取最低分 5 分」——说明作业是明确鼓励做视觉打磨的;
  2. 也发现 我 的担忧有道理:如果时间都花在美化上,测试和文档就会赶不上 deadline;
  3. 于是我们做了时间分配的约定。

是否解决:✅ 已解决

解决方案

  • 明确优先级:先把「发布—浏览/搜索—详情—联系—更新状态」这条主流程跑通(对应功能性 15 分),
    确认功能完备后,再投入时间做视觉;
  • 把美观工作集中在 style.css:因为 CSS 与逻辑完全解耦(这正是我们分层架构的收益),
    所以美化过程不会引入功能 bug,风险可控;
  • 我 负责跑一遍 npm test 确认 61 个用例仍然全绿,作为「美化没有破坏功能」的守门人。

有何收获

  • 分歧的解法是回到评分规则,用「共同目标」而非「个人偏好」来做决策。
    与其争论「要不要美化」,不如问「评分规则怎么说的,剩下的时间够不够」;
  • 体会到了好的架构能让协作更顺畅。如果 CSS 和 JS 混在一起,
    姜思源 改样式的过程很可能改坏 我 的逻辑,分歧会升级成技术冲突。
    正因为我们把样式隔离在 style.css、逻辑集中在 store.js,
    两人的工作才能并行推进、互不干扰;
  • 学会了给非功能性需求设定时间和范围的边界,而不是简单地争论「要不要做」。

9. 评价队友

9.1 对松格拉的评价

值得学习的地方

  • 架构意识强。是松格拉坚持要把业务逻辑抽到独立的 store.js 里、不碰 DOM。
    当时我觉得「多写一个文件有点麻烦」,但后来写单元测试时才发现,
    正因为这个决定,61 个用例才能直接 require 断言,不用 jsdom、不用启浏览器。
    这个技术判断力值得学习;
  • 严谨。松格拉写代码会主动考虑边界情况(空数组、null、除零),
    写测试时也是先想「哪些地方可能出错」再动手。
    看板里「空数据找回率显示 NaN」这个 bug 就是松格拉在写用例 39 时提前发现的;
  • 守时守约。每次约定好的模块交付节点都能按时完成,且交付前会自测一遍。

需要改进的地方

  • 过度追求「完备」。做数据层时想一次性把所有能想到的函数都写出来
    (比如还想加一个「按距离排序」),导致初期进度偏慢。
    建议采用「够用就好,先跑通主流程」的迭代思路;
  • 不擅长表达。技术方案讲得比较简略,有时松格拉听不懂也不太好意思追问,
    导致理解偏差后返工。建议多用流程图或简单例子沟通。

9.3 我们两个人的共同收获

最大的收获是理解了「结对编程」不是「分工干活」。

作业初期我们其实是「各做各的」——松格拉写逻辑,我写界面,最后合并时才发现接口对不上:
松格拉的 searchItems 返回的是数组,但我的渲染函数期望的是一个带 total 字段的对象。
这次返工让我们意识到,结对的核心价值在于「实时的设计对齐」,
而不是把工作量对半分。

后来我们调整了方式:每个模块开工前先用 10 分钟口头对齐接口
(函数名、参数、返回值结构),对接成本大幅降低。
这 10 分钟花得非常值——它避免了后面几个小时的返工。


10. 程序实现成果展示

📌 **下面是程序的实际运行截图+GIF(全部在 Chrome 中双击 index.html 打开后截取)。
**

ezgif-89b3d47e982393b2

  1. 首页:Hero 搜索区 + 统计数字 + 最新发布卡片列表

截图-1-首页

  1. 全部信息页:类型标签 + 类别/状态筛选器 + 筛选结果

截图-2-搜索结果

  1. 详情抽屉:物品信息 + 脱敏手机号 + 一键复制按钮

截图-3-详情脱敏

  1. 数据看板:四张指标卡 + 近 7 天柱状图 + 类别分布条形图

截图-4-数据看板

  1. 单元测试报告页:绿色的 61 passing

截图-5-单元测试

功能演示路径

① 打开 index.html
      ↓  首页显示统计数字与最新 8 条信息
② 在搜索框输入「图书馆」→ 回车
      ↓  跳转全部信息页,筛选出 2 条结果
③ 点击任意卡片
      ↓  右侧滑出详情,手机号显示为 138****5678
④ 点击手机号
      ↓  展开为完整号码,Toast 提示「已显示完整手机号」
⑤ 再点一次手机号
      ↓  Toast 提示「已复制:13812345678」
⑥ 点击「✅ 我已找到,标记为已找到」
      ↓  确认弹窗 → 状态徽章变为灰色「已找到」,列表同步刷新
⑦ 点击导航栏「数据看板」
      ↓  找回率数字上升,柱状图与类别分布正常渲染
⑧ 打开 test/test.html
      ↓  显示 61 passing

11. 最后的话

这次作业我们最大的感受是:「做一个能跑的程序」和「做一个别人能跑起来的程序」
之间隔着很多细节
。

比如「一键复制」这个看起来 10 行代码就能搞定的功能,因为要适配 file:// 协议
而不得不写降级方案;再比如「零依赖」这个决定,意味着所有第三方库都要下载到本地、
所有图表都要手写 CSS。但正是这些选择,让助教双击 index.html 就能看到完整效果,
让测试环节没有任何环境障碍。

如果重来一次,我们会在项目开始时就用 10 分钟做技术预研(尤其是
「浏览器在要求的运行环境下,哪些 API 不可用」),而不是等到踩坑再补救。

posted @ 2026-10-09 09:23  Cactus061  阅读(12)  评论(0)    收藏  举报