2026秋软件工程结对作业(第二次之程序实现)——校园失物招领

2026秋软件工程结对作业(第二次之程序实现)——校园失物招领

项目 内容
这个作业属于哪个课程 https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering
这个作业要求在哪里 https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16743
结对同学博客 https://home.cnblogs.com/u/mumuzi82
本人博客 https://home.cnblogs.com/u/linkwalker
GitHub 项目地址 https://github.com/mumuzi82/102401422-102401431
学号·姓名 102401431 丘南星 / 102401422 陈嘉俊

一、具体分工

成员 承担的工作
陈嘉俊(102401422) 项目架构设计、领域层(schema/text/lifecycle/query/validate)与存储层(adapter/seed)实现、10 个页面落地、CDP 逐页走查脚本
丘南星(102401431) 项目架构设计、首页「按物品类别筛选」功能、联系页「一键复制联系方式」功能、SVG viewBox 图标裁剪等问题修复、流程图绘制、博客撰写

二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 40
Estimate 估计这个任务需要多少时间 20 20
Development 开发 480 620
Analysis 需求分析(包括学习新技术) 60 80
Design Spec 生成设计文档 40 50
Design Review 设计复审 30 20
Coding Standard 代码规范 20 20
Design 具体设计 90 120
Coding 具体编码 180 260
Code Review 代码复审 30 40
Test 测试(自我测试、修改代码、提交修改) 60 90
Reporting 报告 90 120
Test Report 测试报告 40 60
Size Measurement 计算工作量 20 20
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 30 40
— 合计 690 820

差异说明:超时主要出现在 Coding 和 Test 两项。原本预估 3 小时完成核心功能,实际花了 4 小时多,原因是「本地数据损坏时的恢复页」和「越权守卫」这两块在开发过程中不断发现新边界(例如手改地址能不能动别人的信息),边写边补测试。Test 同样超时,因为写完走查脚本又回归出两组新的 bug。

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

3.1 代码实现思路

项目不希望写成「10 个 HTML 页面各自散落一堆 DOM 操作」那种结构,而是追求可测、可扩展。整个项目按「规则 → 数据 → 外壳 → 页面」四层组织,依赖方向严格单向:

   用户操作
      │
      ▼
   app.js ──────▶ storage/ ──▶ (localStorage / 内存)
      │               ▲
      │               │ 只调规则,不碰界面
      ▼               │
  pages/ + ui/ ──▶ domain/(规则)
  • domain/(领域层) 是核心,包含 schema(记录结构)、text(文本与时间)、lifecycle(状态机)、query(检索)、validate(校验)。这一层没有任何一行碰 DOM、碰存储、碰网络,因此可以直接被单元测试。
  • storage/(数据层) 把后端做成可注入的:create({ backend }) 只要求传进来的对象满足 getItem / setItem / removeItem。浏览器里传 localStorage,单元测试里传一个用 Map 模拟的假 Storage。于是「存储逻辑」可以在 Node 里被完整测试,不需要开浏览器。
  • ui/(外壳与零件) 提供 hash 路由、顶部栏、底部导航、卡片、图标、Toast 等公共零件。
  • pages/(页面) 是 10 个页面,每个一文件,只负责「用哪几个零件、按什么顺序摆」。

有两条贯穿全项目的硬规则:

  1. 渲染不拼 HTML 字符串。所有元素都用 createElement / cloneNode 造,文本一律走 textContent。浏览器负责把文本当文本处理,因此 XSS 防护不是靠某个函数「记得调用」,而是结构上做不到。唯一的例外是关键词高亮——它也返回的是分段数据,由 UI 层把每段变成文本节点或 <mark>。
  2. 不用 import / export。ES Module 在 file:// 协议下会被浏览器跨域策略拦掉(实测双击直接白屏),而作业要求「下载后运行 html 就能展现预期结果」,所以只能用普通脚本。代价是挂到全局命名空间 LF 上,好处是浏览器和 Node 加载的是同一份源码。

3.2 关键实现的流程图

流程图

上图左侧是浏览与搜索的主流程,右侧是发布流程(含校验失败的分支回环)。判断框「校验通过」走「否」时回到「填写物品信息」,实际程序里就是表单逐项标红、用户继续修改,直到校验通过。

3.3 关键代码片段

(1)状态机:把合法迁移写成显式的迁移表

var TRANSITIONS = {
  active: { resolved: 'resolve' },
  resolved: { active: 'restore' }
};

function apply(items, id, target, now) {
  var current = list[idx];
  if (current.status === target) {
    // 幂等:目标状态就是当前状态,不算错误
    return { ok: true, changed: false, items: list, before: null, error: '' };
  }
  if (!canGo(current.status, target)) {
    return { ok: false, changed: false, items: list, before: null,
             error: '不允许把「' + label(current) + '」改成……' };
  }
  // 纯函数:返回新数组,不修改入参,UI 可以放心地基于返回值重渲染
  var next = list.slice();
  next[idx] = Object.assign({}, current, { status: target, updatedAt: stamp });
  return { ok: true, changed: true, items: next,
           before: Object.assign({}, current), error: '' };
}

为什么不写成到处 if (item.status === ...):合法迁移只有一处定义,加第三个状态时不会漏改;非法迁移能给出明确原因;resolve / restore 互逆,撤销功能是状态机的自然产物,不是补丁。

(2)路径检测:can_fly 的四方向通用写法

function can_fly(arrow) {
  var dr, dc;
  if (arrow.dir === 'U') { dr = -1; dc = 0; }
  else if (arrow.dir === 'D') { dr = 1; dc = 0; }
  else if (arrow.dir === 'L') { dr = 0; dc = -1; }
  else { dr = 0; dc = 1; }   // 'R'

  var r = arrow.row + dr, c = arrow.col + dc;
  while (0 <= r && r < ROWS && 0 <= c && c < COLS) {
    if (grid[r][c]) return false;   // 前方有箭头阻挡
    r += dr; c += dc;
  }
  return true;   // 走到边界,前方无阻挡
}

这段逻辑用「方向向量 (dr, dc) + 一格一格走」的方式统一了四个方向。最初 AI 给出的版本把 (0, -1) 当成 U,导致「只有右下角能飞」。修正后在 domain/lifecycle.js 和路径检测里都统一使用 (行变化, 列变化) 的顺序,并补了一组边界测试。

(3)一键复制联系方式(本人补充,附加特点)

function copyBar(item) {
  var phone = LF.domain.text.text(item.phone);
  var email = LF.domain.text.text(item.email);
  if (!phone && !email) return dom.h('div');
  var payload = '电话:' + phone + ',邮箱:' + email;

  return dom.h('div', { class: 'actions' }, [
    parts.primaryButton('一键复制联系方式', function () {
      // 优先用异步 Clipboard API;失败回退到 execCommand(覆盖 file:// 与旧浏览器)
      function fallback() {
        var ta = dom.h('textarea', { class: 'visually-hidden' });
        ta.value = payload;
        document.body.appendChild(ta);
        ta.select();
        try { document.execCommand('copy'); ui.toast.show('已复制:' + payload); }
        catch (e) { ui.toast.show('复制失败,请手动长按选择'); }
        document.body.removeChild(ta);
      }
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(payload).then(
          function () { ui.toast.show('已复制:' + payload); },
          fallback
        );
      } else {
        fallback();
      }
    }, { block: true })
  ]);
}

file:// 协议下 Clipboard API 经常不可用,因此必须有 execCommand 兜底。这段代码在本地双击打开 index.html 的场景里也能正常工作。

四、实现成果展示

按照第一次结对作业的墨刀原型,本次实现了全部 10 个页面,并保持配色、字体、交互与原型一致。以下按用户典型使用路径依次展示。

4.1 首页

首页

顶部为品牌标题行与搜索框,中间是「全部失物 / 失物寻找 / 失物招领」三个 Tab 加一行按物品类别筛选条(本次新增),下方是信息卡片列表。

4.2 搜索页面

搜索

提供「常见物品」四个快捷标签(身份证、学生证、钥匙、背包),点击后直接进入搜索结果页。

4.3 搜索结果页

搜索结果

关键词命中处用黄色背景高亮显示;顶部显示「共找到 N 条相关结果」,无结果时给出提示。

4.4 发布页(选择类型)

发布

两张深红色入口卡,分别对应「我丢失了物品」与「我捡到了物品」。

4.5 寻物发布页(含表单校验)

空白发布

基本信息 5 行 + 详细信息文本域 + 联系方式。不做任何填写直接点「点击发布」,必填项会逐项标红,页面底部弹出 Toast 提示。

4.6 发布成功页

发布成功

红色对勾 + 「发布成功!」+ 「查看发布详情」按钮。

4.7 信息详情页(浏览者视角)

信息详情(浏览者视角)

圆形物品图 + 两枚状态标签 + 物品卡片三行字段 + 三行图标字段 + 详细信息卡片 + 发布者信息卡片 + 底部「联系发布者」按钮。

4.8 联系发布者页面

联系发布者

发布者信息卡片(姓名 / 学工号 / 学院 / 联系电话 / 邮箱),底部新增「一键复制联系方式」按钮(本次新增)。

4.9 寻物详情页(发布者视角)

信息详情

与浏览者视角相比,多出「标记已结束」和「取消发布」两个按钮,并支持点右上角铅笔进入编辑。

4.10 个人页

个人页

「我的」信息卡(头像 / 姓名 / 学工号 / 学院 / 发布数量)+ 「我的收藏」入口 + 「我的发布」列表。

五、附加特点设计与展示

5.1 首页「按物品类别筛选」

设计创意:作业原文里推荐的功能之一(「是否可以按物品类别、遗失或拾取地点进行筛选」)。原型首页只有「全部失物 / 失物寻找 / 失物招领」三个 Tab,本次补充了一条类别筛选条,覆盖到 8 个物品类别。

实现思路:复用队友已写好的 domain/query.js 里的 categoryCounts 函数(该函数会扣除 category 条件本身统计各类别数量,所以点进任何一个类别都能看到"如果切过去会有多少条",不会一进去只剩 1 个)。无内容的类别置灰不可点,避免点进去看到空白页。

关键代码片段:

function categoryBar(items) {
  var counts = Q.categoryCounts(items, { type: state.type });
  var options = [{ value: 'all', label: '全部' }].concat(
    S.CATEGORIES.map(function (c) { return { value: c, label: c }; })
  );

  var bar = dom.h('div', { class: 'category-bar' });
  options.forEach(function (opt) {
    var count = counts[opt.value] || 0;
    var empty = opt.value !== 'all' && count === 0;
    bar.appendChild(dom.h('button', {
      type: 'button',
      class: 'cat-chip' + (opt.value === state.category ? ' is-on' : '')
                       + (empty ? ' is-empty' : ''),
      disabled: empty,
      text: opt.label + (opt.value === 'all' ? '' : '(' + count + ')'),
      onclick: function () { state.category = opt.value; render(); }
    }));
  });
  return bar;
}

成果展示:

首页
(可见顶部 Tab 下方的一行类别筛选条)

5.2 联系页「一键复制联系方式」

设计创意:作业原文里推荐的另一个功能(「提供一键复制联系方式等方便用户的设计」)。原型的联系发布者页面只有电话与邮箱的明文展示,用户需要长按选中再复制,手机端体验较差。

实现思路:把电话与邮箱拼成一行文本塞进剪贴板。用 navigator.clipboard.writeText 优先,失败回退到 document.execCommand('copy'),两种路径都给 Toast 反馈。失败回退覆盖了 file:// 协议下的浏览器限制(双击 index.html 的场景)。

成果展示:

联系发布者
(可见底部「一键复制联系方式」按钮)

六、目录说明和使用说明

6.1 目录结构

campus-lost-and-found/
├── index.html                 页面外壳 + 卡片 <template>
├── package.json               零依赖;只提供一条 npm test
├── README.md                  目录说明 + 使用说明
├── .gitignore
├── assets/                    图片素材
│   ├── logo-fzu.png
│   └── items/ebike-key.png / student-card.jpg
├── css/
│   ├── style.css              设计变量 + 外壳 + 共用组件
│   └── pages.css              各页面专用版式
├── src/
│   ├── domain/                ★ 领域层:schema / text / lifecycle / query / validate
│   ├── storage/               数据层:adapter(可注入后端) / seed(示例数据)
│   ├── ui/                    外壳与零件:router / shell / parts / card / dom / toast
│   ├── pages/                 10 个页面,一页一文件
│   └── app.js                 装配与启动:数据入口 + 路由分发
├── tests/
│   ├── cases.js               140 个用例,表驱动
│   ├── runner.js              运行器(用例是数据,运行器是逻辑)
│   ├── run-node.mjs           命令行跑法
│   └── run-browser.html       浏览器跑法(与上面共用同一份 cases.js)
└── docs/shots/                走查截图

6.2 测试人员如何运行

  1. 从 GitHub 下载整个文件夹到本地,保持目录结构不变;
  2. 双击 index.html,用 Google Chrome 打开(作业明确要求统一使用 Chrome,防止不同浏览器效果不一致);
  3. 完成。

数据存在浏览器本机的 localStorage 里,关掉再打开记录还在。想回到初始示例数据,浏览器控制台执行 localStorage.clear() 后刷新即可。

七、单元测试

7.1 选用的测试工具与简易教程

项目使用 极小的自制表驱动测试运行器(tests/runner.js),命令行与浏览器两种跑法共用同一份用例。之所以不引入 Mocha / Jest 之类,是因为作业要求「下载后运行 html 就能展现预期结果」,引入任何需要构建的工具都会破坏「双击就能跑」这一前提。自制运行器一共 60 行,核心逻辑是:用例是数据(一个 { name, run, expect } 数组),运行器是逻辑。

自己写一个 60 行的运行器,从零到跑起来只要这几步:

  1. 写一个 run(groups) 函数,遍历 groups,对每个 cases 调用 run() 拿到实际值;
  2. 用 JSON.stringify 把实际值和 expect 都序列化再比较(跨 realm 不会误判,失败信息也直观);
  3. 统计 total / pass / fail,失败时把「实际」和「期望」两份数据都打出来;
  4. 命令行版本用 vm.runInThisContext 加载同一批源文件,浏览器版本用 <script src> 加载;两份加载器不同,用例是同一份;
  5. process.exit(fail ? 1 : 0),方便接 CI。

7.2 部分单元测试代码

// 状态机幂等:重复标记 ok 但 changed=false
{ name: '幂等:重复标记 ok 但 changed=false', run: function () {
    var r = L.resolve([DONE], 'b', 500);
    return [r.ok, r.changed];
  }, expect: [true, false] },

// 恶意:正则元字符按字面匹配("." 不再匹配任意字符)
{ name: '恶意:正则元字符按字面匹配', run: function () {
    return T.segments('a.b', '.');
  }, expect: [{ text: 'a', hit: false }, { text: '.', hit: true }, { text: 'b', hit: false }] },

// 越权守卫:别人的 id 进发布者视角 → 被换成浏览者视角
{ name: '★ 别人的 id 进发布者视角 → 被换成浏览者视角', run: function () {
    return L.owns({ publisher: { sid: '1024021xx' } }, { sid: '102401431' });
  }, expect: false },

7.3 构造测试数据的思路

用例按四类情况铺开:正常 / 边界 / 异常 / 恶意。

  • 正常:主流程走通(发布、搜索、标记状态、撤销)。
  • 边界:空值、纯空格、长度刚好等于上限 / 刚好越界一格(如标题 30 字、电话 7 位)。
  • 异常:缺字段、null/undefined、非法枚举、损坏的 JSON、存储写满。
  • 恶意:XSS 载荷、正则元字符(. * ( ))、重复 id、HTML 实体。

将来测试人员的刁难,我们已经提前处理了一类:手改 URL 越权。例如手输 #/mine/detail/<别人的id> 想标记别人的信息为已结束,程序会把他换成浏览者视角;手输 #/publish/lost?edit=<别人的id> 想改别人的标题,表单不会回填。这些都有对应的单元测试用例覆盖。

7.4 测试结果

test

140 个用例,命令行与浏览器两种跑法,全部通过。 另有 scripts/verify-pages.mjs 用 CDP 驱动真实浏览器逐页走查,96 项断言覆盖十页的结构、字段、文案,以及一条完整的发布链路(发布 → 成功页 → 详情页 → 标记已结束),并自动产出 docs/shots/ 下的截图。

八、Github 的代码签入记录

merge

PR #1 已合并到主仓库。5 条 commit 分别对应:SVG viewBox 修复、首页类别筛选、联系页一键复制、类别栏样式、列表底部留白。+130 −13 行改动。

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

问题 1:SVG viewBox 导致图标被裁剪

  • 问题描述:搜索框里的放大镜只显示一半,卡片里的时钟/定位图标也残缺。
  • 做过哪些尝试:先怀疑是字体问题,排查后发现是 parts.js 的 svg() 函数把 viewBox 设成了传入的 width/height(如 15×15),但图标实际按 24×24 画布绘制,超出部分被裁掉。
  • 是否解决:已解决。把 viewBox 固定为 0 0 24 24,所有线条图标(时钟、定位、人物、星标、文档等)一次性修复。
  • 有何收获:SVG 的 viewBox 是画布坐标系,与 width/height(显示尺寸)是两回事。图标库里所有图标共用一套坐标系时,viewBox 必须固定,只调 width/height 缩放。

问题 2:分类栏被卡片列表盖住 & 手机端滑不动

  • 问题描述:新增的类别筛选条一开始被卡片列表盖住一半,横向滚动在 PC 上体验极差。
  • 做过哪些尝试:先给 .category-bar 加 flex: 0 0 auto 防压缩(部分解决),但 PC 上普通滚轮不横向滚动;试过 overflow-x: auto + 隐藏滚动条,交互仍不直观。
  • 是否解决:已解决。最终改成 flex-wrap: wrap 允许换行,所有类别一眼可见,无需滑动。
  • 有何收获:PC 与移动端的交互习惯差异很大。「横向滚动」在移动端是自然手势,在 PC 上却成了反直觉操作。选交互模式时要考虑最终用户的设备。

问题 3:列表底部被悬浮导航遮住

  • 问题描述:首页滑到底时,最后一条卡片被底部悬浮导航盖住。
  • 做过哪些尝试:一开始给外层 .phone 加 padding-bottom: 140px,不生效。排查发现真正滚动的是 .view 里的 .list,外层预留空间被撑开但内层列表没有留白。
  • 是否解决:已解决。改在 .list 上直接加 padding-bottom: 130px,同时把 .phone 的 padding-bottom 降回 20px。
  • 有何收获:CSS 布局里「预留空间」要加在真正滚动的那一层,加在外层只是把外层撑高,内层内容并不会因此上移。

十、评价你的队友

值得学习的地方:

bro的架构设计意识非常强。项目从一开始就把 domain/(纯规则)和 ui/(DOM 操作)严格分层,连存储后端都做成可注入的(storage/adapter.js 里的 create({ backend }) 接受任意满足 getItem/setItem/removeItem 的对象)。正因为这一层抽象,我们才能用 Map 模拟的假 Storage 在 Node 里直接跑 140 个单元测试,完全不需要开浏览器、不需要 jsdom。

他对细节的把控也很严。例如"渲染不拼 HTML 字符串"这条规则贯穿全项目——所有元素都用 createElement/cloneNode 造,文本一律走 textContent,XSS 防护不是靠转义函数"记得调用",而是结构上做不到。关键词高亮这种天然容易踩坑的地方,他也坚持只返回分段数据([{ text, hit }]),由 UI 层把每段变成文本节点或 <mark>,从不拼字符串。

另外他主动写的 CDP 逐页走查脚本(96 项断言)在开发过程中真的抓出了两组真 Bug:手改 URL 越权访问别人的信息、本地数据损坏时的处理。这两处如果只靠单元测试根本测不出来——单元测试证明不了"页面真的能用"。这种认真对待项目的态度,是我应该学习的。

需要改进的地方:

命名可以更口语化一点,有的文件名第一次读的时候不太能反应过来。README 里的"已知限制"部分写得很坦诚,但如果能再补一节"未来可扩展方向"(例如把本机档案改成真正的登录态、把 localStorage 换成后端 API),会让项目看起来更有延续性。

十一、个人总结与心得

本次结对作业让我真正体会到"原型设计"和"程序实现"之间的距离。第一次结对作业我们用墨刀画了 10 个页面,看起来功能齐全;但真正写代码时才发现,每一个"点击卡片进入详情"背后都涉及路由、状态、数据读写、DOM 渲染四条线,任何一条没对齐都会出错。

几个具体收获:

  1. 先想清楚数据长什么样,再想界面长什么样。 项目里最省时间的一步是陈嘉俊一开始就把 schema.js 里的字段、枚举、长度上限全部定死。后面写页面时不用反复问"这个字段叫什么",直接照 schema 写。

  2. 测试不是"写完代码补一遍",而是"设计的一部分"。 例如 lifecycle.js 的 TRANSITIONS 迁移表,写测试时才发现"重复标记应该是幂等而不是报错"这种边界,倒逼我们回去改实现。

  3. AI 是加速器,不是替代品。 这次大量使用了 DeepSeek 辅助编码,但真正卡住我们的三处 Bug(SVG viewBox 裁剪、flex 布局压缩、滚动容器定位)都只能在真实浏览器里靠肉眼排查,AI 给的代码第一版往往有环境假设。AI 生成的代码必须经过实际运行和人工调试才能进入项目。

  4. PR 的粒度决定 review 的效率。 5 个独立 commit 比 1 个"改了很多"的 commit 更容易被别人看懂,也更容易在出问题时定位是哪一处引入的。

本次作业从需求分析到原型设计再到代码实现,完整走了一遍软件工程的流程。虽然项目本身不复杂,但"分层 + 可测 + 小步提交"这三个原则,对我的帮助会是普适的。

posted @ 2026-10-10 18:16  天使十二  阅读(8)  评论(0)    收藏  举报