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 个页面,每个一文件,只负责「用哪几个零件、按什么顺序摆」。
有两条贯穿全项目的硬规则:
- 渲染不拼 HTML 字符串。所有元素都用
createElement/cloneNode造,文本一律走textContent。浏览器负责把文本当文本处理,因此 XSS 防护不是靠某个函数「记得调用」,而是结构上做不到。唯一的例外是关键词高亮——它也返回的是分段数据,由 UI 层把每段变成文本节点或<mark>。 - 不用
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 测试人员如何运行
- 从 GitHub 下载整个文件夹到本地,保持目录结构不变;
- 双击
index.html,用 Google Chrome 打开(作业明确要求统一使用 Chrome,防止不同浏览器效果不一致); - 完成。
数据存在浏览器本机的
localStorage里,关掉再打开记录还在。想回到初始示例数据,浏览器控制台执行localStorage.clear()后刷新即可。
七、单元测试
7.1 选用的测试工具与简易教程
项目使用 极小的自制表驱动测试运行器(tests/runner.js),命令行与浏览器两种跑法共用同一份用例。之所以不引入 Mocha / Jest 之类,是因为作业要求「下载后运行 html 就能展现预期结果」,引入任何需要构建的工具都会破坏「双击就能跑」这一前提。自制运行器一共 60 行,核心逻辑是:用例是数据(一个 { name, run, expect } 数组),运行器是逻辑。
自己写一个 60 行的运行器,从零到跑起来只要这几步:
- 写一个
run(groups)函数,遍历groups,对每个cases调用run()拿到实际值; - 用
JSON.stringify把实际值和expect都序列化再比较(跨 realm 不会误判,失败信息也直观); - 统计
total / pass / fail,失败时把「实际」和「期望」两份数据都打出来; - 命令行版本用
vm.runInThisContext加载同一批源文件,浏览器版本用<script src>加载;两份加载器不同,用例是同一份; 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 测试结果

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

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 渲染四条线,任何一条没对齐都会出错。
几个具体收获:
-
先想清楚数据长什么样,再想界面长什么样。 项目里最省时间的一步是陈嘉俊一开始就把
schema.js里的字段、枚举、长度上限全部定死。后面写页面时不用反复问"这个字段叫什么",直接照 schema 写。 -
测试不是"写完代码补一遍",而是"设计的一部分"。 例如
lifecycle.js的TRANSITIONS迁移表,写测试时才发现"重复标记应该是幂等而不是报错"这种边界,倒逼我们回去改实现。 -
AI 是加速器,不是替代品。 这次大量使用了 DeepSeek 辅助编码,但真正卡住我们的三处 Bug(SVG viewBox 裁剪、flex 布局压缩、滚动容器定位)都只能在真实浏览器里靠肉眼排查,AI 给的代码第一版往往有环境假设。AI 生成的代码必须经过实际运行和人工调试才能进入项目。
-
PR 的粒度决定 review 的效率。 5 个独立 commit 比 1 个"改了很多"的 commit 更容易被别人看懂,也更容易在出问题时定位是哪一处引入的。
本次作业从需求分析到原型设计再到代码实现,完整走了一遍软件工程的流程。虽然项目本身不复杂,但"分层 + 可测 + 小步提交"这三个原则,对我的帮助会是普适的。
浙公网安备 33010602011771号