软件工程第二次结对作业 —— 校园失物招领程序实现
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026 秋软件工程(福州大学) |
| 这个作业要求在哪里 | 第二次结对作业 · 程序实现 |
| 这个作业的目标 | 基于第一次结对作业的原型设计,完成「校园失物招领」核心模块的代码实现,并练习 GitHub 版本管理、单元测试、PSP 记录与软件工程文档撰写 |
| 学号 / 姓名(同学 A) | 102401101 / 谢玲洁 |
| 结对搭档(同学 B) | AI Agent |
| GitHub 仓库 | https://github.com/xxxs111/102401101-AIagent |
| 第一次作业原型 | 墨刀原型链接 |
一、具体分工
本次结对为人机结对:我负责需求把关与全部验收工作,AI 搭档负责代码实现。
| 成员 | 主要负责 | 具体内容 |
|---|---|---|
| 102401101 谢玲洁(同学 A) | 需求与验收 | 需求梳理与范围界定、界面走查与功能测试、单元测试运行与截图、博客撰写、PSP 记录、本地运行调试与问题排查 |
| AI Agent(同学 B) | 程序实现 | 页面结构与样式、数据层与工具层、哈希路由与交互逻辑、表单校验、一键复制、单元测试代码、README 与开发文档、目录结构整理 |
开发过程中双方互换「驾驶员 / 领航员」角色:AI 先出一版可运行的实现,我当领航员逐条走查并提问题(必填项提示不够具体、已完成的信息还要不要显示联系方式、删除要不要二次确认、不是自己发的能不能改状态),AI 再改;有分歧时以「作业要求 + 真实使用场景」为准做取舍。
二、PSP 表格
表一:谢玲洁(102401101)—— 需求与验收
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 45 | 50 |
| Estimate 估计这个任务需要多少时间 | 45 | 50 | |
| Development | 开发 | 290 | 330 |
| Analysis 需求分析(包括学习新技术) | 40 | 45 | |
| Design Spec 生成设计文档 | 25 | 25 | |
| Design Review 设计复审(对照第一次作业原型与作业要求) | 20 | 25 | |
| Coding Standard 代码规范(统一命名、注释、分层约定) | 15 | 15 | |
| Design 具体设计(页面结构与交互方案确认) | 35 | 40 | |
| Coding 具体编码(走查中的小修改与文案调整) | 60 | 70 | |
| Code Review 代码复审(逐条读 AI 产出的代码) | 45 | 50 | |
| Test 测试(自我测试、修改代码、单元测试运行与截图) | 50 | 60 | |
| Reporting | 报告 | 100 | 115 |
| Test Report 测试报告 | 25 | 30 | |
| Size Measurement 计算工作量 | 10 | 10 | |
| Postmortem & Process Improvement Plan 事后总结 | 20 | 25 | |
| 博客撰写与配图 | 45 | 50 | |
| 合计 | 435 | 495 |
表二:AI Agent —— 程序实现
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 20 |
| Estimate 估计这个任务需要多少时间 | 20 | 20 | |
| Development | 开发 | 220 | 265 |
| Analysis 需求分析(读第一次作业原型、确认功能边界) | 20 | 25 | |
| Design Spec 生成设计文档(分层方案、数据模型) | 20 | 20 | |
| Design Review 设计复审 | 10 | 10 | |
| Coding Standard 代码规范 | 10 | 10 | |
| Design 具体设计(路由表、数据流、交互细节) | 30 | 35 | |
| Coding 具体编码(HTML/CSS/JS) | 70 | 85 | |
| Code Review 代码复审 | 20 | 25 | |
| Test 测试(写 61 个用例、修被测试抓出的 bug) | 40 | 55 | |
| Reporting | 报告 | 55 | 60 |
| Test Report 测试报告素材整理 | 15 | 15 | |
| Size Measurement 计算工作量 | 10 | 10 | |
| Postmortem & Process Improvement Plan 事后总结 | 15 | 20 | |
| README 与开发文档撰写 | 15 | 15 | |
| 合计 | 295 | 345 |
PSP 小结:两张表都是"预估 < 实际"。超时最明显的是 AI 的具体编码(+15)与测试(+15)——编码的时间花在移动端布局与样式细节打磨上;测试超时是因为它真的抓出了两个 bug,修完还要重跑全部用例并补回归用例。我这边的超时集中在代码复审和测试:许多"流程细节"(已完成的信息还要不要显示联系方式、删除要不要二次确认、不是自己发的能不能改状态)在预估时完全没意识到需要专门确认,走查时才发现必须逐条定规则。下次我会在动手前先把这些边界行为列成清单。
三、解题思路描述与设计实现说明
3.1 需求回顾与范围界定
第一次结对作业已经明确:本次不做后台管理、实名认证、即时聊天、地图定位、账号体系。所以我直接砍掉这些模块,把力气全部放在作业点名的核心闭环上:
发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态
设计时重点回答了两个问题(也是第一次作业点评提醒过的地方):
- 联系之后如何结束这条信息? —— 由发布者在详情页或「我的发布」里标记为「已找到 / 已归还」;标记后该条在列表中置灰并排到后面,详情页不再展示联系入口(显示"请勿再联系发布者"),并且支持「恢复为进行中」以防误标。
- 没有登录系统,怎么知道谁是发布者? —— 用本机发布者标识:首次发布时生成一个随机
publisherKey存在 localStorage,本人发布的信息都带上这个标识,「我的发布」只展示标识匹配的信息,详情页只有标识一致时才显示「标记完成 / 编辑 / 删除」。既回答了"谁改状态、从哪改",又没引入作业不要求的账号体系。

3.2 技术选型
| 决策点 | 结论 | 理由 |
|---|---|---|
| 形态 | 单页 Web 应用(Chrome 打开 index.html) |
与原型一致,助教双击即用 |
| 框架 | 原生 HTML/CSS/JS,不引入任何框架 | 作业要求"下载全部文件后用 Chrome 打开就能看到预期结果",引入构建工具反而抬高验收门槛 |
| 视图组织 | location.hash 简易路由 + 字符串模板渲染 |
支持 #/detail/3 可直达链接,方便截图与复现问题;逻辑层可与 DOM 解耦,便于单元测试 |
| 数据持久化 | localStorage |
无后端前提下单机演示足够,刷新不丢数据;不可用时降级为内存存储 |
| 单元测试 | 原生 JS 手写断言 | 零依赖、离线可跑、控制台粘贴即可执行 |


3.3 架构与数据模型
代码按「界面层 → 数据层 / 模板层 → 工具层」组织,数据层、模板层、工具层都不访问 DOM,这是能写出单元测试的关键:
app.js(界面层:哈希路由 / 渲染调度 / 事件委托)
├── 调用 ──► store.js(数据层:校验 / 增删改查 / 组合搜索 / 演示数据)
├── 取模板 ► view.js (模板层:数据 → HTML 字符串,所有插值经 escapeHtml)
└── 依赖 ──► utils.js(工具层:HTML 转义 / 日期 / 状态文案 / 防抖)
│
▼
localStorage(lf_items_v1 信息列表 / lf_owner_v1 本机标识)
信息数据模型(item):
| 字段 | 含义 | 取值示例 |
|---|---|---|
| id | 自增主键 | 1, 2, 3… |
| type | 信息类型 | lost 寻物 / found 招领 |
| name / category | 物品名称 / 分类 | 校园卡 / 校园卡、电子产品、水杯… |
| time / place | 丢失或拾取的时间 / 地点 | 2026-10-06 / 三区田径场看台 |
| desc / image | 补充描述 / 图片链接 | 蓝色卡套,卡号末尾 3721 |
| contactName / contact | 称呼 / 联系方式 | 李同学 / 13800001234 |
| status | 状态 | active 进行中 / done 已完成 |
| publisherKey | 本机发布者标识 | 随机字符串 |
| views / createdAt / updatedAt | 浏览数 / 创建与更新时间戳 | 26 / 1727… |
状态文案映射:status × type 两两组合出四种文案 —— 寻物+进行中=「寻找中」、寻物+已完成=「已找到」、招领+进行中=「待认领」、招领+已完成=「已归还」,统一由 Utils.statusOf() 输出,界面层只认这个函数,避免文案散落在十几个模板字符串里。
状态流转:
┌────────── updateStatus(id,'done') ──────────┐
▼ │
┌─────────┐ ┌──────────┐
│ active │◄──── updateStatus(id,'active') ───│ done │
│ 进行中 │ │ 已完成 │
└─────────┘ └──────────┘
列表正常展示、显示联系入口 列表置灰后置、隐藏联系入口
3.4 关键代码片段与解释
① 组合搜索(store.js)—— 搜索不是简单的 includes
function searchItems(opts) {
opts = opts || {};
var kw = String(opts.keyword || '').trim().toLowerCase(); // 处理首尾空格与大小写
var list = loadItems();
if (opts.type && TYPES.indexOf(opts.type) >= 0) list = list.filter(function (i) { return i.type === opts.type; });
if (opts.category) list = list.filter(function (i) { return i.category === opts.category; });
if (opts.onlyActive) list = list.filter(function (i) { return i.status === 'active'; });
if (kw) {
list = list.filter(function (i) {
var hay = [i.name, i.desc, i.place, i.category, Utils.typeLabel(i.type)]
.join(' ').toLowerCase(); // 关键词同时覆盖名称 / 描述 / 地点 / 分类 / 类型
return hay.indexOf(kw) >= 0;
});
}
return sortItems(list); // 进行中在前,同状态按发布时间倒序(内部先 slice 再排序)
}
解释:把「关键词 + 类型 + 分类 + 只看进行中」做成自由组合的过滤器,最后统一排序。trim().toLowerCase() 保证 校园卡、airpods 这类输入也能命中,避免测试人员因为多打一个空格就以为搜索坏了。另外 sortItems 内部先 slice() 再排序,返回的是副本,不会就地改动原数组——这条副作用有专门的用例锁住。
② 一键复制联系方式(app.js)—— 主方案 + 降级方案
function copyText(text) {
function fallback() { // 非 HTTPS / file:// 下的降级方案
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-1000px';
document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, ta.value.length);
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
toast(ok ? '联系方式已复制:' + text : '复制失败,请手动长按选中复制');
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(String(text)).then(function () { toast('联系方式已复制:' + text); }, fallback);
} else { fallback(); }
}
③ 防 XSS(utils.js + view.js)—— 用户输入全部转义
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
模板层所有插值都经过它,例如 '<span class="item-name">' + esc(item.name) + '</span>'。测试人员如果故意发布一条名字叫 <img src=x onerror=alert(1)> 的信息,它只会被当作普通文字显示。注意 & 必须最先替换,否则会把后面生成的实体再转义一遍。
④ 搜索输入的防抖与"局部重渲染"(app.js)
关键词输入用 300ms 防抖,而且只重渲染列表容器、不重渲染整页——否则每敲一个字输入框都会失焦、光标跳回开头。地址栏用 history.replaceState 同步筛选条件(不产生历史记录,也方便把搜索结果链接分享到班群)。
3.5 更多界面截图(与上面的设计决策一一对应)
下面几张图对应第三节的设计决策与第四节列出的附加功能:
「只看进行中」:6 条筛到 4 条,已完结的信息自动被过滤掉

分类筛选:点「🎧 电子产品」只看这一类;筛选条件同步进地址栏,链接可直接分享到班群

空状态引导:搜索无结果时给出「清空筛选条件」,而不是干巴巴一行“暂无数据”

「我的发布」空状态:没有发布过时给出「去发布一条」入口

已完成的信息(招领 → 已归还):详情页不再展示联系方式,改为「请勿再联系发布者」提示

四、附加特点设计与展示
题目允许"尽情丰富内容",我围绕真实使用痛点做了几个不炫技但实用的设计:
- 一键复制联系方式:校园场景里联系靠的是"复制号码 → 打开微信/电话",详情页点一下就完成,省去手抄和误记。非 HTTPS 环境下自动降级为
execCommand,无论成功失败都弹 Toast 反馈。 - 状态驱动 UI:已完成的信息自动置灰、排到列表后方,详情页不再展示联系入口——从机制上减少无效联系,而不是靠大家自觉。
- "我的发布"+ 本机发布者标识:没有账号也能只管理自己发的内容,并且明确"只有发布者能改状态",让"更新状态"这条流程第一次在代码里有了明确答案。
- 可分享的搜索结果链接:
index.html#/search?q=保温杯发到班群里,别人点开就是同一批搜索结果。 - 空状态引导:搜索无结果时给出「清空筛选条件」按钮,发布列表为空时给出「去发布」入口,而不是干巴巴一行"暂无数据"。
- 健壮性细节:所有用户输入渲染前转义(防 XSS);localStorage 读写全部 try/catch,隐私模式下自动降级内存存储;存储内容被写坏时降级为空数组而不是白屏。
五、目录说明和使用说明
5.1 目录结构
102401101-AIagent/
├── README.md # 项目说明与使用指南
├── index.html # 单页应用入口(Chrome 直接打开)
├── css/
│ └── style.css # 全部样式(移动端优先,桌面居中手机形态)
├── js/
│ ├── utils.js # 工具层:转义 / 日期 / 状态文案 / 防抖(不依赖 DOM)
│ ├── store.js # 数据层:localStorage / 校验 / 增删改查 / 搜索(不依赖 DOM)
│ ├── view.js # 模板层:数据 → HTML 字符串(不碰 DOM、不读存储)
│ └── app.js # 界面层:哈希路由 / 渲染 / 事件委托
├── test/
│ ├── test.html # 单元测试运行页(打开即跑,结果显示在页面上)
│ └── unit-test.js # 61 个用例,原生 JS 手写断言
└── docs/
├── 开发文档.md # 架构、数据模型、关键实现、技术决策与踩坑清单
├── 验收与提交指南.md # 验收走查清单、测试报告、博客骨架
├── PSP记录.md # PSP 表格
├── 博客正文(可直接粘贴).html
└── screenshots/ # 效果截图
分层的目的:utils.js / store.js / view.js 都不碰 DOM,业务规则(校验、搜索、状态流转、转义、模板输出)全是纯函数,只有 app.js 和浏览器打交道。这是本项目能写出有意义单元测试的前提——如果一开始把逻辑都写进 onclick 回调,后面就只能靠人工点测了。
5.2 测试人员如何运行
- 从 GitHub 下载全部文件(或
git clone),用 Chrome 双击打开index.html,无需服务器、无需 Node、无需任何构建步骤; - 首次打开自动载入 6 条演示数据,可直接浏览、搜索、查看详情;
- 想体验完整闭环:首页「发布寻物」→ 先什么都不填点「立即发布」看校验提示 → 填完发布 → 底部「我的发布」→ 点「标记为已找到」→ 回首页看该条置灰并排到最后;
- 运行单元测试:打开
test/test.html,页面顶部直接显示"用例总数 / 通过 / 失败";也可按 F12 把test/unit-test.js粘贴进 Console 后输入runTest(); - 重置数据:底部「关于」→「清空本机数据」或「重新载入演示数据」。
六、单元测试
6.1 选用的测试工具与学习过程
工具:原生 JavaScript 手写断言(assert.ok / equal / notEqual / deepEqual / throws),零框架、零依赖、离线可运行。
为什么不用现成框架:作业要求"Chrome 直接打开 html 就能看到预期结果",引入 QUnit 需要额外下载框架文件,引入 Jest / Mocha 需要 Node 环境,都会抬高助教的验收门槛;而本次要测的都是纯函数,5 个断言方法已经够用。
学习过程:先读了单元测试相关的博客(邹欣《单元测试之道》)理解"自动化、每天可运行"的思想,再对照本项目的逻辑层设计用例。
一份简易教程(三步上手):
- 用 Chrome 打开
index.html,按 F12 进入 Console; - 把
test/unit-test.js的全部内容粘贴进 Console 回车(浏览器若提示禁止粘贴,先输入allow pasting解锁粘贴权限); - 输入
runTest()回车:绿色 ✔ 为通过,红色 ✘ 会带上失败原因与出错位置。
更省事的方式:直接打开 test/test.html,加载即自动运行,并把结果按模块分组显示在页面上,刷新就重跑。
6.2 测试思路与用例设计
一共 61 个用例,覆盖 6 个模块:
| 模块 | 用例数 | 覆盖的函数 |
|---|---|---|
| 工具函数 | 10 | escapeHtml / toDateStr / parseDate / isFutureDate / friendlyDate / timeAgo / statusOf / typeLabel / categoryIcon / uid |
| 表单校验 | 11 | isValidPublishData(名称、分类、日期、地点、描述、类型、联系方式逐字段)、isValidContact |
| 数据读写 | 20 | loadItems / saveItems / generateId / getItem / createItem / updateItem / deleteItem / incrementViews |
| 状态与搜索 | 11 | updateStatus(含非法状态、不存在 id)、searchItems、sortItems、countByType、getMyItems、isMine |
| 演示数据 | 5 | seedIfEmpty(含"不覆盖用户数据"回归用例)、clearAll |
| 其它 | 4 | 中文与 emoji 往返、存储损坏降级等 |
构造测试数据的思路:
- 确定性优先:每个用例前先
resetStorage()清库保证隔离,再用fixtures()固定写入 4 条数据(含寻物/招领、进行中/已完成、不同地点与联系方式格式),保证用例可重复、可预期、可乱序执行; - 日期不写死:用"相对今天偏移 N 天"生成,避免用例过一段时间就失效;
- 白盒边界穷举:名称刚好 30 字 / 地点刚好 50 字 / 描述刚好 200 字 / 日期刚好是今天 / 关键词带首尾空格 / QQ 与微信号位数边界 / 存储内容被写坏;
- 异常与对抗:空表单、纯空格、超长、未来日期、非法联系方式、不存在的 id、非法状态值——"测试人员会怎么刁难"是我们设计用例时最花心思的地方。
测试代码摘录一:校验函数边界
test('[校验] 日期:空 / 格式错误 / 未来日期', function () {
assert.ok(Store.isValidPublishData(data({ time: '' })).errors.time, '空日期应报错');
assert.ok(Store.isValidPublishData(data({ time: '2026/09/20' })).errors.time, '斜杠格式应报错');
assert.ok(Store.isValidPublishData(data({ time: dayStr(1) })).errors.time, '明天应报错');
assert.notOk(Store.isValidPublishData(data({ time: dayStr(0) })).errors.time, '今天应通过');
});
test('[校验] 联系方式:非法格式应被拦截(防止放行垃圾数据)', function () {
['', ' ', '12', '12!@#', 'abc', '1234', '1380000123', '138000012345678', '@example.com', 'a@b']
.forEach(function (c) {
assert.notOk(Store.isValidContact(c), '"' + c + '" 应被判为非法');
});
});
测试代码摘录二:状态更新与文案映射联动
test('[数据] updateStatus:招领标记 done 后文案变「已归还」,可再恢复', function () {
resetStorage(); seed(fixtures());
var done = Store.updateStatus(2, 'done');
assert.equal(Utils.statusOf(done), '已归还', '招领 + done = 已归还');
var back = Store.updateStatus(2, 'active');
assert.equal(Utils.statusOf(back), '待认领', '恢复后应回到待认领');
});
test('[数据] updateStatus:不存在 id 返回 null 且数据不变', function () {
resetStorage(); seed(fixtures());
var before = JSON.stringify(Store.loadItems());
assert.equal(Store.updateStatus(999, 'done'), null, '应返回 null');
assert.equal(JSON.stringify(Store.loadItems()), before, '数据不应被改动');
});
一个容易被忽略的工程细节:用例会反复清库,如果直接跑就会把用户真实数据抹掉。所以 runTest() 在运行前会备份 lf_items_v1 / lf_owner_v1,在 finally 里原样还原——测试可以随时运行而不会弄丢已发布的信息。
6.3 测试运行结果
- 用例总数 61,通过 61,失败 0,耗时约 8–15 ms。
- 运行截图(
test/test.html全览,页面上直接显示"用例总数 61 / 通过 61 / 失败 0"):见下方图 20。 - 应用内一键运行(关于页)结果:见下方图 20
![10-unit-test-all-pass]()
6.4 测试抓出来的三个真实 bug
问题一:少一位的手机号被当成合法 QQ 放行
- 现象:断言
1380000123(10 位)应判为非法,结果失败——它被判定为合法。 - 排查:原规则是"手机号 或 邮箱 或 QQ(5-12 位数字) 或 微信号",这串数字满足"8 位纯数字",于是被当成合法 QQ 放行了。
- 修复:所有纯数字串先按"手机号 / QQ"这一组规则判定,顺序与失败条件都要明确——
1[3-9]开头但只有 10 位,说明用户想写手机号却少打一位,直接判非法;QQ 收窄到 5–11 位。 - 收获:边界规则要"先写测试再写实现",比写完再补测试更容易暴露漏洞。
问题二:非法日期被 new Date() 静默接受
- 现象:断言
parseDate('2026-13-01')应返回null,实际返回了2027-01-01。 - 原因:
new Date()对越界月份不报错,而是静默溢出到下一年的 1 月。 - 修复:
parseDate自己校验月份 / 日期范围,并检查构造结果是否与输入一致(2026-02-30这类溢出日期一并拦掉),非法直接返回null。 - 收获:不要盲信标准库的"容错",日期解析必须自己校验。
问题三(数据安全隐患):演示数据播种会覆盖用户已有数据
- 现象:回归用例
seedIfEmpty:已有数据时不覆盖用户数据断言失败。 - 原因:播种逻辑最初只检查"是否播种过",用户清掉标记但保留自己发布的信息时,刷新后 6 条演示数据会整体覆盖用户数据。
- 修复:加"已有数据一律不播种"的前置守卫,并保留该回归用例永久守护。
- 收获:存储层"不覆盖用户数据"是底线,必须用测试锁死。
6.5 测试评估
- 覆盖程度:逻辑层(校验、存储、搜索、状态流转、转义、模板输出)的全部核心函数与主要异常分支都有用例,能够满足本程序的测试要求。
- 不足之处:
app.js的视图渲染、事件绑定、剪贴板交互尚未被自动化覆盖,这部分用浏览器人工走查与截图补足(本文第八节的 9 张效果图就是实测记录)。 - 改进方向:若后续要做端到端自动化,可引入 Playwright 覆盖界面层。
七、GitHub 代码签入记录
仓库地址:https://github.com/xxxs111/102401101-AIagent
我们按功能划分提交,每完成一个可运行的功能点就 commit 一次,提交信息遵循 <type>: <描述> 规范(参考阮一峰《Commit message 写法》)。提交序列:
chore: 初始化项目结构与 README 骨架
feat: 工具层 utils.js(HTML转义/日期/状态文案/防抖)
feat: 数据层 store.js(localStorage封装、校验、增删改查、模拟数据)
feat: 首页信息流与类型/状态筛选
feat: 搜索页——关键词+类型+分类组合筛选与空状态
feat: 发布表单与逐字段校验回显
feat: 详情页——浏览数、一键复制(含降级)、状态更新
feat: 我的发布——统计卡与信息管理(编辑/标记/删除)
feat: 附加设计(可分享搜索链接、空状态引导、防XSS)
test: 单元测试61用例(原生JS断言)+ 测试运行页
docs: README / 开发文档 / 验收指南 / PSP 记录 / 效果截图
fix: 联系方式校验放行少一位手机号的问题
fix: 非法日期溢出未拦截的问题
fix: 演示数据播种覆盖用户数据的问题
图 10:GitHub 仓库提交记录

八、实现成果展示
8.1 界面截图
图 11:首页(搜索触发框、发布寻物 / 发布招领入口、全部/寻物/招领计数标签、倒序信息流;进行中的信息在前并带绿色状态,已完成的置灰后置)

图 12:搜索结果(热门标签 + 类型与分类筛选,关键词实时命中,输入过程中焦点不跳)

图 13:详情页(本人发布)(分类渐变大图、类型与状态徽章、时间地点行、描述、一键复制、标记已找到 / 编辑 / 删除)

图 14:详情页(非本人发布)(只读:没有管理按钮,并提示"只有发布者本人可以修改状态或删除")

图 15:发布表单校验(什么都不填直接提交:名称、分类、地点、联系方式同时标红并给出具体提示,焦点跳到第一个出错字段)

图 16:发布表单填写完成(分类下拉、日期控件限制不能选未来日期、字符计数)

图 17:发布成功页(可直接查看这条信息或去我的发布)

图 18:我的发布(共发布 / 进行中 / 已完成统计卡 + 每条信息的查看详情、标记完成、编辑、删除)

8.2 测试结果截图
图 19:单元测试全部通过(test/test.html:用例总数 61 / 通过 61 / 失败 0)

九、遇到的代码模块异常或结对困难及解决方法
除了第六节的三个 bug,开发过程中还踩了这些坑:
问题 1:一个用例抛异常导致后面全部不执行
- 最初用一个
try/catch包住整个 for 循环,第一个断言失败后面的用例就全被跳过,一次只能看到一个错误。 - 解决:改成每个用例独立
try/catch,失败互不影响,一次运行就能看到全部红色用例及其原因。
问题 2:搜索框输入每个字都失焦
- 原因:输入时整页重渲染,新节点顶掉了输入框的焦点。
- 解决:只重渲染列表容器,并加 300ms 防抖;同时用
history.replaceState同步地址栏,不产生多余的历史记录。
问题 3:用 hidden 属性藏返回按钮却不生效
- 现象:首页明明设了
hidden,返回按钮还在。 - 排查:用
getComputedStyle一看是display: grid——.icon-btn里的display覆盖了浏览器对[hidden]的默认样式。 - 解决:补
.icon-btn[hidden] { display: none !important; }。 - 收获:不要凭感觉说"刷新一下就好了",先用计算样式确认,否则会浪费大量时间在错误的方向上。
问题 4:页面内跑测试时输出里混着 %c 和 color:...
- 原因:
console.log的样式参数被一起收集进结果框。 - 解决:收集日志时过滤掉样式参数,并去掉残留的
%c。
结对困难 5:对"他人信息是否显示管理按钮"产生分歧
- 分歧:为了演示方便,是否所有详情页都显示「标记完成」?
- 讨论结果:只有
publisherKey与本机一致才显示管理按钮,同时演示数据默认归到本机标识名下,这样助教打开就能体验状态流转,又不会出现"任何人都能改别人信息"的问题。这个约束也让"更新状态"到底谁改、从哪改第一次有了明确答案。 - 收获:结对的价值就在这种分歧里——一个人做容易顺着"演示方便"走,两个人互相质疑才能守住业务边界。
十、评价你的队友
AI Agent 值得学习的地方:
- 分层做得干净,
utils / store / view三个文件都不碰 DOM,所以单元测试能直接引用、写得下去; - 写代码时主动考虑异常路径(存储不可用降级、剪贴板不可用降级、存储内容损坏降级、XSS 转义),这些不是作业硬性要求,但直接影响"能不能被测试人员刁难住";
- 被测试抓出的 bug 会顺手补一条回归用例,而不是改完就算。
AI Agent 需要改进的地方:
- 前期对交互细节的把握不够,一些"流程规则"(已完成的信息还要不要显示联系方式、删除要不要二次确认、非发布者能不能改状态)需要我走查时逐条指出才补齐,下次应该在设计阶段就把这些边界行为列出来确认;
- 样式上反复微调(对齐、间距、圆角),耗时容易超预估,建议先锁死"功能可用"再做美化。
十一、总结
这次作业让我从头到尾走完了「原型 → 可运行程序 → 测试 → 文档」的完整流程,最大的三点收获:
- "流程细节"只有真正写代码时才被逼着回答清楚。 上次画原型时觉得"标记完成"一个按钮就够了,这次才发现背后有一串必须定的规则:谁来改(发布者)、从哪改(详情页和我的发布)、改完别人看到什么(置灰后置、隐藏联系入口)、改错了能不能恢复。这些不是画图能想明白的。
- 单元测试不是交作业的装饰,它真的抓出了 bug。 少一位的手机号被当成合法 QQ 放行、非法日期被
new Date()静默接受、演示数据会覆盖用户数据——三条都是测试先跑出红色断言才被发现的,修复后都变成了永久回归用例。 - 人机结对时"逐条走查"比"一次性生成"更靠得住。 AI 出实现很快,但边界行为、文案口径、异常路径这些地方必须由人拿着需求一条条对。我把它当成"代码复审 + 验收测试"来做,才把"能用"推到了"经得起刁难"。

浙公网安备 33010602011771号