2026秋软件工程结对作业(第二次之程序实现)
2026秋软件工程结对作业(第二次之程序实现)——校园失物招领
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 结对同学博客 | 丘南星的博客 |
| 本人博客 | 陈嘉俊的博客 |
| GitHub 项目地址 | 102401422-102401431 |
| 学号·姓名 | 102401422 陈嘉俊 / 102401431 丘南星 |
一、具体分工
| 成员 | 承担的工作 |
|---|---|
| 陈嘉俊(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)越权守卫:把「谁有权改这条信息」做成可测试的规则
/**
* 这条记录是不是"我的"。
* 本项目没有登录,身份就是本机档案里的学工号,
* 所以"谁有权改"这条规则只有一处定义,放在领域层,被单元测试盯着。
*/
function owns(item, profile) {
if (!item || !profile) return false; // 缺参数不抛异常,一律不当成自己的
var sid = T.text(profile.sid);
if (!sid) return false;
return !!(item.publisher && T.text(item.publisher.sid) === sid);
}
页面里用它把住入口:
// 发布者视角只给信息的主人:手改地址打开别人的 id 时,换成浏览者视角
if (item && !LF.app.isMine(item)) {
ui.router.replace('/detail/' + item.id); // replace:不写历史,后退键不会在两个地址间弹
return;
}
这段是本次走查中补上的(详见第九节问题 4)。一开始只是「界面上不显示铅笔和那两个按钮」,但地址是可以手写的——界面上看不见,不等于不能用。所以把判断下沉到领域层做成
owns(),两个入口(发布者视角的详情页、编辑表单)都调它,编辑表单在渲染时和提交前各校验一次。
(3)一键复制联系方式(丘南星补充,附加特点)
var payload = '电话:' + phone + ',邮箱:' + email;
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,下面一行是按物品类别筛选条(附加特点,见 5.1),再往下是信息卡片列表。
4.2 搜索页面

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

关键词命中处用黄色背景高亮显示;顶部显示「共找到 N 条相关结果」,无结果时给出提示。
4.4 发布页(选择类型)

两张深红色入口卡,分别对应「我丢失了物品」与「我捡到了物品」。
4.5 寻物发布页(含表单校验)

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

红色对勾 + 「发布成功!」+ 「查看发布详情」按钮。
4.7 信息详情页(浏览者视角)

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

发布者信息卡片(姓名 / 学工号 / 学院 / 联系电话 / 邮箱),底部有「一键复制联系方式」按钮(附加特点,见 5.2)。
4.9 寻物详情页(发布者视角)

与浏览者视角相比,多出「标记已结束」和「取消发布」两个按钮,编辑铅笔在顶部栏右上角。顶部栏用的是与原型的「信息详情页」同款样式(返回箭头 + 红字标题)——原型这一页原本没有顶部栏,从「查看发布详情」或手输地址进来之后一个返回入口都没有,本次补上了(详见第九节问题 4 之后的说明)。
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 目录结构
102401422-102401431/
├── 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 个页面,一页一文件
│ │ (home.js 含类别筛选条,contact.js 含一键复制按钮)
│ └── app.js 装配与启动:数据入口 + 路由分发
├── tests/
│ ├── cases.js 140 个用例,表驱动的一整份数据
│ ├── runner.js 运行器(用例是数据,运行器是逻辑)
│ ├── run-node.mjs 命令行跑法
│ └── run-browser.html 浏览器跑法(与上面共用同一份 cases.js)
├── scripts/
│ ├── verify-pages.mjs CDP 逐页走查 10 个页面(96 项断言 + 自动截图)
│ └── md-to-html.mjs 把 md 转成可双击阅读的 html
└── docs/
├── README.html README 的阅读版
└── shots/ 走查截图
6.2 测试人员如何运行
- 从 GitHub 下载整个文件夹到本地,保持目录结构不变;
- 双击
index.html,用 Google Chrome 打开(作业明确要求统一使用 Chrome,防止不同浏览器效果不一致); - 完成。
数据存在浏览器本机的
localStorage里,关掉再打开记录还在。想回到初始示例数据,浏览器控制台执行localStorage.clear()后刷新即可。
6.3 十页走一遍的推荐路线
| 想看什么 | 怎么走 |
|---|---|
| 首页信息流、三个 Tab、类别筛选 | 打开就是;点 Tab 与类别条切换 |
| 搜索与关键词高亮 | 底部「搜索」→ 点「学生证」标签 → 看结果与高亮 |
| 发布一条信息 | 底部「发布」→ 点「我丢失了物品」→ 填表 → 点「点击发布」 |
| 表单校验 | 在发布页什么都不填直接点「点击发布」,会逐项标红 |
| 发布成功页 | 正常提交后自动进入 |
| 发布者视角的详情页 | 提交后点「查看发布详情」(可「标记已结束」或「取消发布」,左上角可返回) |
| 浏览者视角的详情页 | 回首页,点「电动车钥匙」那张卡 |
| 联系发布者 + 一键复制 | 在上面那页点底部「联系发布者」 |
| 个人页 | 底部「我的」 |
七、单元测试
7.1 选用的测试工具与简易教程
项目使用 极小的自制表驱动测试运行器(tests/runner.js),命令行与浏览器两种跑法共用同一份用例。之所以不引入 Mocha / Jest 之类,是因为作业要求「下载后运行 html 就能展现预期结果」,引入任何需要构建的工具都会破坏「双击就能跑」这一前提。自制运行器一共 60 行,核心逻辑是:用例是数据(一个 { name, run, expect } 数组),运行器是逻辑。
自己写一个几十行的运行器,从零到跑起来只要这几步:
- 写一个
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 }] },
// 越权守卫:学工号不一致就不是我的(界面上藏按钮 ≠ 拦住,规则要能单独测)
{ name: '学工号不一致 → 不是我的', run: function () {
return L.owns({ publisher: { sid: '102401431' } }, { sid: '102401422' });
}, expect: false },
// 异常:缺参数一律 false,不抛异常
{ name: '★ 缺参数的异常输入一律 false,不抛异常', run: function () {
return [L.owns(null, { sid: 'x' }),
L.owns({ publisher: { sid: 'x' } }, null),
L.owns({ publisher: { sid: 'x' } }, {}),
L.owns({ publisher: { sid: '' } }, { sid: '' })];
}, expect: [false, false, false, false] },
7.3 构造测试数据的思路
用例按四类情况铺开:正常 / 边界 / 异常 / 恶意。
- 正常:主流程走通(发布、搜索、标记状态、撤销)。
- 边界:空值、纯空格、长度刚好等于上限 / 刚好越界一格(如标题 30 字、电话 7 位)。
- 异常:缺字段、
null/undefined、非法枚举、损坏的 JSON、存储写满。 - 恶意:XSS 载荷、正则元字符(
.*())、重复 id、HTML 实体。
将来测试人员的刁难,我们已经提前处理了一类:手改 URL 越权。例如手输 #/mine/detail/<别人的id> 想标记别人的信息为已结束,程序会把他换成浏览者视角;手输 #/publish/lost?edit=<别人的id> 想改别人的标题,表单不会回填。这些都有对应的单元测试用例覆盖。
7.4 测试结果

140 个用例、11 个分组,命令行与浏览器两种跑法,全部通过。 另有 scripts/verify-pages.mjs 用 CDP 驱动真实浏览器逐页走查,96 项断言覆盖十页的结构、字段、文案,一条完整的发布链路(发布 → 成功页 → 详情页 → 标记已结束),以及三组回归:手改地址越权、本地数据损坏时的表现、返回入口是否可用;走查的同时自动产出 docs/shots/ 下的截图。
八、Github 的代码签入记录

合计 +130 −13,涉及 5 个文件(src/pages/home.js、src/pages/contact.js、src/ui/parts.js、css/style.css、css/pages.css)。
九、遇到的代码模块异常或结对困难及解决方法
问题 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 布局里「预留空间」要加在真正滚动的那一层,加在外层只是把外层撑高,内层内容并不会因此上移。
问题 4:手改地址就能动别人的信息(陈嘉俊)
- 问题描述:走查时发现「发布者视角」的两个入口都没有身份校验。手输
#/mine/detail/<别人的id>,页面照常渲染出「标记已结束」和「取消发布」——能把别人的信息标记成已结束,甚至直接删掉;手输#/publish/lost?edit=<别人的id>,表单会被回填、按钮变成「保存修改」,一提交就改掉了别人的标题。 - 做过哪些尝试:第一反应是在界面上"藏起来"——只给自己的信息显示铅笔和那两个按钮。但地址是可以手写的,隐藏按钮拦不住任何人。于是把判断下沉到领域层,做成
lifecycle.owns(item, profile)(本项目没有登录,身份就是本机档案里的学工号),两个入口都调用它;编辑表单在渲染时和提交前各校验一次,防止渲染之后数据被换掉。发布者视角的详情页在发现不是自己的信息时,用router.replace换成浏览者视角——用 replace 而不是 go,是为了不让后退键在两个地址之间来回弹。 - 是否解决:已解决。补了 5 条
owns()的单元用例(学工号一致 / 不一致 / 没有发布者 / 缺参数 / 两侧空格),走查脚本里补了 4 条"手改地址"的断言。 - 有何收获:「界面上看不见」和「不能用」是两件事。只要一个地址能被手写,任何只靠隐藏按钮实现的限制都等于没有。判断应该放在数据和规则那一层,界面只是它的表现。
问题 5:本地数据损坏被静默吞掉,下一次写入还会覆盖原始内容(陈嘉俊)
- 问题描述:
localStorage里的 JSON 解析失败时,程序本来会渲染一个错误提示,但它紧接着被路由的首次渲染盖掉了,用户看到的只是普通的「这里还没有信息」。更糟的是用户以为只是没数据、随手发布一条,写入就把损坏的原始内容永久覆盖了——storage/adapter.js里写着「为避免覆盖原始记录,本次不做任何写入」,但这个承诺在应用层的写入入口上被打破了。 - 做过哪些尝试:第一版只把错误"画一次",被路由盖掉;第二版把"读取失败"做成持续状态(
readError):任何路由进来都优先显示它,并且在该状态下拒绝一切写入(persist()直接返回 false,连直接调LF.app.add()也写不进去);恢复页上把浏览器里保存的原始内容摊进一个只读文本框供复制留底,用户明确点「恢复示例数据」才清空。 - 是否解决:已解决。走查脚本补了 8 条断言:首页要显示错误、要摊出原始内容、底部导航要隐藏、原始字符串不能被覆盖、发布页也进不去、连直接调
add()也写不进去、点恢复后回到正常首页、恢复后底部导航回来。 - 有何收获:「报了错」和「报错能被看见」是两件事。异常处理不是写完那一行
render(error)就结束了,要确认它不会被后面的正常流程冲掉。另外「承诺」要落在代码路径上——存储层写了"不写入",但应用层的写入入口没有做检查,承诺就是空的。
问题 6:寻物详情页没有任何返回入口(陈嘉俊)
- 问题描述:原型第 9 页(寻物详情页)本身没有顶部栏,标题连编辑铅笔一起放在正文里。实际用下来,从「发布成功页 → 查看发布详情」或直接输地址进来之后,一个返回入口都没有,退不出去。
- 做过哪些尝试:考虑过在正文标题左边加一个小箭头(会显得和整个应用的顶部栏体系不一致),也考虑过给这一页加底部导航(但原型明确这一页没有底部导航,且页面底部已经被两个操作按钮占满)。最后选择复用一个原型自己就在用的样式:原型的「信息详情页」(第 5 页)就是「返回箭头 + 红字标题」的顶部栏,直接把同款搬过来,编辑铅笔挪到右上角。
- 是否解决:已解决。走查脚本补了 3 条断言:顶部栏要有返回箭头、从发布成功页进来点返回能退出这一页、从个人页进来点返回能回到个人页。
- 有何收获:照抄原型不等于照抄得对。原型是静态图,画的时候不会暴露"这一页进去出不来"这种问题;而实现出来一用就露馅了。遇到原型有坑时,最好的补法往往不是自己发明一套,而是在这个原型自己的设计语言里找现成的答案——这样补完仍然像是同一个人设计的。
十、评价你的队友
值得学习的地方:
队友在本次作业中承担了所有"锦上添花"的部分,而且都落在了作业原文点名的加分点上——首页的按物品类别筛选、联系页的一键复制联系方式,都是作业要求里明确提到"可以尽情思考,添加你认为好的特点"的方向。他在写这两个功能时没有凭空造新函数,而是先读了一遍 domain/query.js,复用了已经写好的 categoryCounts,这既省了代码量,也保证了计数逻辑在全项目只有一份。
排查问题时的思路也值得学习。列表底部被悬浮导航遮住那个问题,他第一轮试了给外层 .phone 加 padding-bottom: 140px,没生效;第二轮他没有继续加大数值,而是先打开 DevTools 确认"真正滚动的是哪一层",发现是 .list 而不是 .phone,然后精准地在 .list 上加了 130px。先定位再改,比盲目试数值效率高得多。
他按规定走完了 fork → PR 的流程(PR #1,5 条 commit,+130 −13),提交信息也按 fix(icons): feat(home): 这样的规范写,主仓库的历史因此很清楚。
需要改进的地方:
如果时间允许,可以再深入到 domain/lifecycle.js 的状态机内部去看一眼 TRANSITIONS 迁移表的设计思路——那是整个项目最值得琢磨的一段,理解了它之后,未来做任何"状态变更"的功能(比如订单、审批流)都会很顺。现在他对这块还停留在"知道有这么个东西"的层面,可以再往深挖一层。
十一、个人总结与心得
这次作业最大的体会是:「界面看起来对了」和「程序真的对」之间隔着很远的距离。
第一次结对作业我们交出的是原型,这次要把它变成能跑的东西。写页面本身比我预想的快——10 个页面加起来不到一天。真正花时间的是那些"原型上根本画不出来"的部分:谁能改这条信息、数据坏了怎么办、这一页进去出不来怎么办。这三个问题在原型阶段一个都不会暴露,因为静态图里没有"手输一个地址"这种操作。
关于 AIGC 的使用,我的感受比上一次具体了很多。它在生成骨架、写样板零件(图标、表单行、卡片)上确实快,但它给的东西必须自己验。这次最有价值的两条修复——越权守卫和本地数据损坏——都不是"写的时候想出来的",而是事后专门用对抗性脚本找出来的:把 localStorage 手动改成坏 JSON、把 URL 里的 id 换成别人的、往搜索框里塞 <img src=x onerror=...>。我的结论是:AIGC 能把"写"的成本降下来,但会把"验"的重要性顶上去,因为代码不再是自己一行行敲的,对它的直觉判断也变弱了。
测试的投入是这次最值的一笔。用例写成一张表({ name, run, expect }),命令行和浏览器共用同一份,改完代码立刻知道有没有踩坏别的地方;后来的走查脚本又用真实鼠标事件把十页点了一遍,两次回归出的 bug 都是这么抓到的。以前我觉得测试是"交作业前的仪式",这次是真的靠它发现了两个会丢数据的缺陷。
分工上我们按"领域层 / 页面层"切,但真正的磨合都发生在边界上:队友加类别筛选时要复用 query.js 的计数,我补越权判断时要在 lifecycle.js 里加规则——分层不是为了分类,是为了让别人能安全地在上面加东西。
不足的地方也明确:附加特点几乎全靠队友,我在"锦上添花"上投入不够;PSP 里 Coding 和 Test 的预估明显偏乐观(实际多花了近一半时间)。如果重做一次,我会先把"谁能改什么、改完别人在哪看到"这类规则想清楚再动手写界面,而不是等走查时才发现。

浙公网安备 33010602011771号