2026秋软件工程结对作业(第二次之程序实现)
软工第二次结对作业:校园失物招领 Web 应用
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 202601 软件工程 |
| 这个作业要求在哪里 | 结对作业二:程序实现 |
| 这个作业的目标 | 把第一次结对作业的原型做成可运行的程序,完成「发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态」核心闭环 |
| 结对成员 | 102401135 何慧渊 | 102301230 林为峰 |
| 何慧渊(102401135)的博客 | https://www.cnblogs.com/VictoriaL |
| 林为峰(102301230)的博客 | https://www.cnblogs.com/Aidname |
| GitHub 仓库地址 | https://github.com/Aidname/102301230-102401135 |
| 第一次结对作业原型 | 墨刀原型 |
一、具体分工
| 成员 | 学号 | 主要负责 | 具体内容 | 对应提交 |
|---|---|---|---|---|
| 何慧渊 | 102401135 | 数据层与测试 | js/config.js 业务字典、js/utils.js 纯函数、js/store.js 全部业务规则(校验 / 增删改查 / 搜索筛选排序 / 状态流转 / 认领验证 / 申诉 / 老数据迁移)、js/seed.js 演示数据、53 个单元测试、docs/设计文档.md |
前 5 条(config → utils → store → seed → test) |
| 林为峰 | 102301230 | 页面与交互 | 9 个页面 HTML、9 个 page-*.js 控制器、js/ui.js 公共界面层、3 个 CSS(设计令牌 / 通用组件 / 响应式布局)、页面走查与截图、docs/流程图.md |
后 10 条(ui → css → 九个页面) |
| 共同 | — | 需求讨论与走查 | 功能范围界定、认领验证机制的两轮方案讨论、互相走查对方模块、README 与文档 | chore: 结构重整、docs: |
协作方式:按「职责」而不是「页面」切分——何慧渊先把数据层和它的测试做完(含一份接口约定:store 导出哪些方法、参数长什么样、返回什么),林为峰照着接口并行推进页面,两人几乎不互相阻塞。开发过程中互换「驾驶员 / 领航员」角色:一人写代码时另一人即时评审,功能完成后互相走查对方模块。
按功能划分提交,每完成一个可独立验证的功能提交一次,通过 fork + Pull Request 流程合并(见第七节)。
二、PSP 表格
何慧渊(102401135)—— 数据层与测试
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 450 | 540 |
| · Analysis | · 需求分析(包括学习新技术) | 50 | 65 |
| · Design Spec | · 生成设计文档 | 40 | 35 |
| · Design Review | · 设计复审 | 25 | 20 |
| · Coding Standard | · 代码规范 | 15 | 15 |
| · Design | · 具体设计 | 60 | 75 |
| · Coding | · 具体编码 | 180 | 235 |
| · Code Review | · 代码复审 | 30 | 35 |
| · Test | · 测试(自测、修 bug、提交) | 50 | 80 |
| Reporting | 报告 | 90 | 100 |
| · Test Report | · 测试报告 | 30 | 35 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结并提出过程改进计划 | 50 | 55 |
| 合计 | 570 | 685 |
林为峰(102301230)—— 页面与交互
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 450 | 530 |
| · Analysis | · 需求分析(包括学习新技术) | 50 | 55 |
| · Design Spec | · 生成设计文档 | 40 | 35 |
| · Design Review | · 设计复审 | 25 | 25 |
| · Coding Standard | · 代码规范 | 15 | 10 |
| · Design | · 具体设计 | 60 | 70 |
| · Coding | · 具体编码 | 180 | 245 |
| · Code Review | · 代码复审 | 30 | 40 |
| · Test | · 测试(自测、修 bug、提交) | 50 | 70 |
| Reporting | 报告 | 90 | 95 |
| · Test Report | · 测试报告 | 30 | 30 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结并提出过程改进计划 | 50 | 55 |
| 合计 | 570 | 670 |
偏差说明:
| 阶段 | 预估 | 实际 | 偏差原因 |
|---|---|---|---|
| Analysis | 100 | 120 | 中途调整了一次范围:最初把认领验证设计成"按分类填特征、认领人手打答案",讨论后发现它在判定准确性和防试探上都不成立,整块推翻重做 |
| Design | 120 | 145 | 推翻之后要重新设计数据结构(题目、认领记录、申诉)和"什么时候不该出题"的判断规则 |
| Coding | 360 | 480 | "看起来很简单"的交互很费时间:题目编辑器"改文字不重绘、改结构才重绘"这条规则调了几轮;搜索的边界情况(全角、零宽字符、正则元字符)反复补 |
| Test | 100 | 150 | 补边界用例:写完才发现"空选项行要不要重排答案下标""全错与只错一题返回结构是否一致"这类地方必须钉住,否则改一次就可能悄悄退化 |
| Postmortem | 100 | 110 | 设计文档里要写清"为什么不那样做",比只写"做了什么"费时间 |
整体实际耗时约为预估的 1.2 倍,超支集中在设计返工与边界用例补齐上。
下次改进:把"交互走查"单独列成一个任务并预留时间,而不是算在 Coding 里;先写一版能跑的最小骨架,再往上加附加特点,避免返工。
三、解题思路描述与设计实现说明
3.1 需求回顾与范围界定
第一次结对作业已经明确:本次不要求复杂后台管理、实名认证、即时聊天、地图定位。所以实现时直接砍掉这些方向,把精力集中在题目点名的核心闭环上:
发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态
设计时我们重点回答了两个问题——这两个问题不写代码是回答不清楚的:
问题一:联系之后,这条信息怎么结束?
由发布者在详情页或「我的发布」里标记为「已找到 / 已归还」。标记后信息在列表中置灰并沉到后面(不让已完结的信息淹没活跃信息),详情页不再展示联系入口,并提示"请勿再打扰发布者"——从机制上减少无效联系。支持「恢复为进行中」以防误标。标记是幂等的:重复点不会改动原有的完成时间,否则"已归还于昨天"会变成"已归还于刚才",记录就不准了。
问题二:没有登录系统,怎么知道谁是发布者?
采用本机发布者标识:首次打开时生成一个随机 ownerId 存进本地数据,「我的发布」只展示标识匹配的信息;状态维护、编辑、删除、查看申诉明细都要在这个标识上做归属校验。
关键的一条纪律:归属校验必须放在数据层,不能靠"界面上不显示按钮"。因为前端隐藏只是让人点不到,绕过界面直接调数据层一样能改。所以 markDone / updatePost / removePost / listAppeals / resolveAppeal 每一个都在数据层里判 post.ownerId !== actor 就直接拒绝。
问题三(原型里没想清楚、实现时才逼出来的):招领信息的联系方式,到底给谁看?
这是我们在实现过程中撞上的问题,也是本项目附加特点的起点:
- 全公开 → 任何人都能看到,冒领几乎没有成本。真失主还没看到消息,东西可能就被别人领走了;
- 完全不公开 → 失主联系不上拾得者,信息等于白挂。
这是个两难。我们的解法是引入一个只有物主能通过的门槛:拾到东西的人出几道只有物主答得对的客观题,认领人全部答对才解锁联系方式。详见 3.6。
3.2 技术选型
| 选项 | 结论 | 理由 |
|---|---|---|
| 形态 | Web(Chrome 直接打开 HTML) | 与原型一致;测试成本最低 |
| 框架 | 不用框架,原生 HTML / CSS / JS | 作业要求"下载所有文件后用谷歌浏览器运行 html 就能展现预期结果"。引入构建工具或框架反而增加环境要求,也让助教多一步 npm install |
| 数据持久化 | localStorage(无后端),不可用时逐级降级 |
单机演示足够;见 3.5.5 |
| 页面组织 | 多页应用(9 个 HTML),不是单页路由 | 页面数量与原型一一对应;file:// 下多页比 hash 路由更直观,也便于助教"点开某一页直接看" |
| 脚本 | 普通 <script>,无 import / export |
file:// 下 ES Module 会被 CORS 拦掉 |
| 单元测试 | Mocha 10.8.2 + Chai 4.5.0(浏览器构建随仓库提交) | 双击 test/test.html 即跑,不装 Node、不起服务器、不联网 |
3.3 分层架构
页面 HTML(只管结构)
↓
page-*.js(收集输入、调数据层、渲染结果)
↓
┌──────────────────────────────────────┐
│ store.js ★ 全部业务规则 │
│ 校验 / 增删改查 / 搜索筛选排序 / │
│ 状态流转 / 认领验证 / 申诉 / 迁移 │
└──────────────────────────────────────┘
↓
config.js(业务字典) utils.js(纯函数)
↓
存储适配器(localStorage → sessionStorage → 内存)
为什么要分这一层:有四件事必须做对,做错了后果严重——
- 验证题的正确答案不能泄露(这是防冒领的根基);
- 状态流转不能乱(东西还回去了、信息还挂着"待认领",别人就会白跑一趟);
- 只有发布者能改自己的信息;
- 搜索得搜得到(搜"校园卡"找不到"校园卡一张(学号 2023****)",用户就不会用第二次)。
这四件事全是"输入 → 输出"的逻辑,跟界面长什么样没关系。把它们全部收进 store.js 之后,有两个直接好处:
- 改一处规则就全局生效,不会出现"详情页改了、搜索结果还漏着";
- 这些规则能被单元测试一条条钉住,而且不需要浏览器环境。
实现上最关键的一步是存储注入:createStore(storage) 只要求传进来的对象有 getItem / setItem / removeItem 三个方法(和 localStorage 一模一样),于是:
| 运行环境 | 注入的东西 | 结果 |
|---|---|---|
| 浏览器 | LF.createBrowserAdapter() |
数据落盘,刷新还在 |
| 单元测试 | LF.createMemoryAdapter() |
每个用例一个干净仓库,互不干扰 |
同一份业务代码,一个字都不用改。测试因此不需要 jsdom、不需要起服务器、不需要装 Node。
数据流图:

图 1:分层架构与数据流。最关键的一条在右下角——数据层向上返回的每一个对象都经过 toPublic() 出口,验证题的正确答案、认领记录、申诉明细在这一步被真正删掉,而不是靠界面藏起来。
3.4 数据模型
一条信息的核心字段(内部结构,落盘时的形态):
{
id: 'p_xxx', // 内部主键
code: 'LF20261009001', // 展示用编号,线下核对时报这个
type: 'lost' | 'found', // 寻物 / 招领
title, category, location, happenedAt, description,
contactName, contactWay, // 联系方式
photo, // 压缩后的 dataURL
status: 'active' | 'done',
doneType: 'found' | 'returned' | null,
doneAt, createdAt, updatedAt, views,
ownerId, // 本机发布者标识
questions: [ // ★ answer 永远不出现在对外的对象里
{ id, type: 'judge' | 'choice', stem, options: [...], answer: 0 }
],
attemptsLeft: 2, // 剩余作答次数
claims: [ // 收到的认领记录(只有发布者能看)
{ at, passed, voucher }
],
appeals: [ // 人工审核申请(明细只有发布者能看)
{ id, claimantName, contact, detail,
decision: 'pending' | 'accepted' | 'rejected', note, voucher, createdAt, decidedAt }
]
}
整个库存在一个键 lf_db_v1 下({version, seq, ownerId, posts, profile, history, draft, unlocked, vouchers})。选单键而不是分键,是因为写一次就是一个原子操作,迁移和"清空 / 导出"都只需要处理一个对象。
3.5 关键实现与解释
3.5.1 toPublic() —— 防冒领的第一道闸门
对外返回的每一个对象都经过这一层处理:
out.questions = … // 只给题干和选项,answer 在这一步被剥掉
out.contactWay = locked ? '' : post.contactWay; // 未解锁时联系方式根本不进页面
out.contactName = isOwner ? post.contactName : LF.maskName(post.contactName); // 张**
out.claimCount = isOwner ? post.claims.length : 0; // 非发布者连"有多少人试过"都不透露
// claims / appeals 一个字段都不带出去
这里是"删掉"而不是"界面上不显示"。 区别很关键:如果只是前端 display:none,答案、认领记录、申诉人的联系方式仍然在内存对象里,打开控制台就能看到。把它们从数据里删掉,才是真的拿不到。
这几条规则有专门的测试直接验证:拿到公开视图后断言题目对象没有 answer 属性、整个 JSON 序列化结果里搜不到 answer 字样、claims / appeals 两个键压根不存在。断言的是"数据里没有",不是"界面没显示"——这样哪天有人为了"提升体验"把它加回来,用例会立刻变红。
3.5.2 统一判定:把"哪题错了"彻底留在数据层
// store.js — submitClaim() 的失败分支
if (!passed) {
return { ok: true, passed: false, remaining: left - 1,
locked: left - 1 <= 0, canAppeal: left - 1 <= 0,
message: '回答的细节与描述不符' }; // ★ 没有 failed、没有题号
}
记录里确实带了每题的对错,但它只从内部状态里出来;发布者看的是自己的信息,本来就知道答案,不需要这个字段。认领人拿到的失败结果里连题号都不出现。
测试怎么钉住这条设计决策:让同一个发布者的信息分别"只错一题"和"全错",然后断言两个返回对象序列化后完全一致。这样将来谁为了"提升体验"把错题信息加回去,用例会立刻变红。
3.5.3 搜索:一个纯函数 + 一条容易踩的边界
// store.js — matchKeyword()
var terms = LF.normalizeText(keyword).split(' ').filter(Boolean); // 多词按「与」
var haystack = LF.normalizeText([title, description, location, 分类名, 类型名,
LF.maskName(contactName)].join(' '));
return terms.every(t => haystack.indexOf(t) !== -1);
三个考虑:
- 用
indexOf而不是new RegExp:用户输入什么就搜什么,如果拼进正则解析,输入一个(就会让整个页面崩掉。 - 归一化里加了 NFKC 和零宽字符过滤:从微信、QQ 复制过来的文字经常夹着看不见的零宽字符,人眼看着一样,字符串比较却不相等。
- 归一化和清理是两个函数:
normalizeText()只用于比较,clean()才用于保存。早先把两者混在一个函数里,"存的时候顺手做了全角转半角",用户回看自己的描述发现字符被改了。所以刻意拆开,各自只服务一个目的。
3.5.4 分面统计:统计某一维时要忽略该维自身的筛选
筛选按钮右侧要显示"这个选项下有几条"。如果拿当前全部筛选条件去算,会出现一个尴尬的结果:选中"图书馆"之后,其他地点的条数全变成 0,用户就没法从"图书馆"直接切到"食堂"了。
所以 facets() 在统计某一维时,会从条件里剔除这一维:
for (var d = 0; d < FILTER_DIMS.length; d++) {
var ignore = {}; ignore[FILTER_DIMS[d]] = true; // 统计这一维时忽略这一维
// 用 (opts - 这一维) 去统计这一维的各选项条数
}
3.5.5 存储降级链
createBrowserAdapter() 按 localStorage → sessionStorage → 内存 的顺序探测并降级,每一步都真写一次再删掉来确认可用——因为无痕模式或企业策略下,连读 localStorage 属性都可能抛异常,"能拿到对象"不等于"能用"。
为什么中间要夹一层 sessionStorage:这是个多页应用,每跳一次页面,内存里的变量全部重置。如果 localStorage 不可用就直接退回内存,「发布 → 跳成功页」这一步数据就没了,主流程当场断掉。sessionStorage 至少能让同一标签页内把流程走完——降级的目标是"功能仍然可用",不是"程序不崩"这么低的标准。
页面顶部会同步出现一条提示条,明确告诉用户当前数据的保留范围,而不是让他在毫不知情的情况下丢数据。
3.5.6 老数据必须能就地升级
DATA_VERSION 与 migrate():改数据结构时版本号 +1,迁移里补一条分支。这不只是"补字段",还要清理已经不该生效的数据。
具体到本项目:曾经给「其他」类挂过验证题的历史记录,在改成"这一类不出题"之后必须把题目清掉——否则发布者会被锁在一个详情页还在要求答题、发布页却已经没有出题入口的旧流程里,他连"把这套题删掉"都做不到。测试里专门造了这样一条老数据来钉住这个行为。
另外还实现了对第一次结对作业那份脚手架(存储键 lost_found_list,字段是 name / place / desc / contact / createTime)的一次性迁移,这样如果本机之前玩过旧版本,数据不会白丢。
3.6 核心机制:认领验证(防冒领)
3.6.1 第一版想法为什么被推翻
我们最初的设计是"发布者按分类填 2~3 个只有物主才知道的特征,认领人手打答案"。自己走一遍就发现两个绕不开的问题:
| 问题 | 具体表现 | 后果 |
|---|---|---|
| 判不准 | 发布者写「深蓝色」,失主记得是「藏青」;发布者写「蓝色小熊贴纸」,失主写「小熊贴纸」 | 真失主被拦在门外。为了宽容就得做文本归一化,而"宽容到什么程度"本身没有正确答案 |
| 能试探 | 为了让失主知道错在哪,页面必须指出答错的题 | 每答错一次就排除一个选项,3 次机会足够把答案试出来,限制次数形同虚设 |
这两个毛病其实是同一个根源:它把"答案"交给了自然语言。
3.6.2 第二版(本仓库采用的方案)
把判定从"文本比对"换成"选项比对":
- 只允许判断题和选择题,题量 3~5 道(建议 4 道),选择题 2~4 个选项并指定正确答案;系统再按物品分类给出题模板,降低出题门槛;
- 认领人一次性答完全部题目再提交,每题只能点选项,没有自由文本;
- 系统统一判定,只回"通过 / 不通过":未通过时提示语统一为「回答的细节与描述不符」,不告诉他是哪一题错;
- 最多答 3 次,答对不消耗次数;发布者换了题目则重新给满 3 次;
- 3 次全败后解锁【申诉】,认领人写下只有物主才知道的细节和联系方式,转人工审核。
四个设计决定,每一个都是被一个具体问题逼出来的:
| 决定 | 被什么问题逼出来的 |
|---|---|
| 只允许判断题和选择题 | 手打答案有措辞歧义;换成下标比对后这一整类误判直接消失 |
| 一次性答完、不分页 | 分页会让人以为可以回头改,也让人误以为系统在逐题判定——而逐题反馈正是我们要避免的 |
| 只回「通过 / 不通过」 | 告诉错在哪一题,就等于给冒领者递了排除法的线索 |
| 未答完提交不消耗次数 | 否则用户会被自己的手滑消耗掉机会,这是我们能替他挡住的 |
3.6.3 什么时候不该出题
把"锁定特征 + 出题验证"推到底,会撞到两个边界:
① 寻物信息不出题。 寻物的人本来就希望别人联系他,给他设门槛是反的。所以验证只加在招领这一侧——门槛只设在需要门槛的那一侧。
② 「其他」类不出题。 这一类之所以是「其他」,正是因为物品没有客观、能锁死的细节(一本书、一串钥匙、一个说不清型号的充电器)。我们对自己提了一个问题:这一类该出什么题? 答案是出题人只能把描述里的细节再抄一遍("扉页上写着谁的名字"配上描述里的"扉页有名字"),同一份信息写两次,而冒领者照着公开描述就能选对——在一类根本没有可锁特征的信息上强制出题,只会逼人编题。
所以这一类改成对照组,走信任原则:不设验证题,描述、照片和联系方式直接公开,并把代价写在页面上:
这一类没有防冒领闸门,理论上谁先看到都能联系你。所以别把唯一凭据写进公开描述(比如"书里夹着一张写名字的借书凭条"——这句话本身就是答案)。
判断逻辑收在一个函数里,依据是"这个分类在字典里有没有公开特征定义",而不是手写 category === 'other':
// config.js
LF.allowVerifyFor = function (categoryKey) {
var c = LF.categoryOf(categoryKey);
if (!c) return null; // 三态:分类压根不在字典里,无从判断
return !!c.allowVerify; // true = 必须出题;false = 明确不出题
};
返回三态而不是布尔,是为了脏数据:分类不在字典里时,校验应当跳过出题这一条,免得在"分类错误"之上再叠一条用户改不掉、也无从理解的"请至少出 3 道验证题"。
3.6.4 出题结构由数据层兜底
题目结构不靠界面保证:
- 判断题的选项固定为「正确 / 错误」,发布者传什么都不算数;
- 选择题里的空选项行会被丢掉,正确答案下标跟着重排。
// 空选项行被丢掉后,原来指向第 3 项的答案要落到第 2 项上
LF.normalizeQuestions([{ type:'choice', stem:'伞骨有几根',
options:['6 根','','8 根'], answer:2 }]);
// → options: ['6 根','8 根'], answer: 1
为什么由数据层重排下标:界面上"删除一个选项""切换题型"都会改动选项数组,如果直接在页面上算正确答案的下标,很容易出现"看着选的是第 2 项,存进去却指到第 3 项"。把重排规则放在数据层并配单元测试,这类错位就只有一个地方可能出错。
另外,新增题目时不预选正确答案。默认勾上"正确"能让发布者少点一下,但也可能就这么发出去了——而一个错的答案是直接把真失主锁在门外,代价比多点一下大得多。
3.6.5 流程图

图 2:认领验证的完整流程。这张图里有四个地方是特意设计的,也是第二版与第一版想法的区别所在:题型只有判断题和选择题(判定退回到字符串比对的话,"深蓝 / 藏青"这类措辞歧义会把真失主拦在门外);一次性作答 + 统一判定(逐题反馈等于给冒领者递排除法的线索);次数上限写进数据层(绕过界面直接调数据层也躲不开);3 次用完才解锁申诉(三处按钮都以同一个条件为准,不会出现"提前出现申诉入口")。
四、附加特点设计与展示
4.1 认领验证(防冒领)+ 限制 3 次 + 申诉人工审核
意义:把"谁有资格看到联系方式"从口头约定变成程序判断。
- 对拾得者:敢把信息挂出来了,因为不怕被冒领;
- 对失主:能证明"这东西确实是我的",反而更容易拿回来;
- 对平台:信息可信度提高,别人也愿意用。
顺带解决了一个隐私问题:发布者的联系方式默认不公开,只有通过验证的人能看到。
实现思路与代码见 3.6。

图 3:一次性看到全部题目、一次答完,没有自由文本,也没有"上一题 / 下一题"分页。
一次性看到全部题目、一次答完,没有自由文本,也没有"上一题 / 下一题"分页。顶部显示剩余作答次数,底部实时提示还差几题。

图 4:只给一句统一提示与剩余次数,页面主动解释"为什么不告诉你是哪题错",并给出再答一次与申诉两个出口。

图 5:生成认领凭证码并解锁联系方式,可一键复制。

图 6:申诉人写下"只有物主才知道的细节"和联系方式——这段内容会直接送到发布者手上。

图 7:发布者在这里逐条处理:同意交还则生成线下交接编号,驳回则要求写一句理由。
发布者在「我的发布 → 人工审核」里看到对方写的细节与联系方式,可同意交还(生成线下交接编号)或驳回(附一句理由)。
4.2 「其他」类走信任模式(对照组)
意义:把"锁定特征 + 出题验证"推到底会撞到的那个分类,需要一个说得通的流程,而不是一个逼着人编题的流程。这一类改成不设门槛、直接公开,并把代价写在页面上。
页面上的说明(选中「其他」类时替换掉整个出题区,而不是留一块空白让发布者以为功能坏了):
📦「其他」不设认领验证题,改走信任模式
这一类物品没有客观、能锁死的特征(一本书、一串钥匙、一个说不清型号的充电器),硬要出题,出题人只能把描述里的细节再抄一遍,而冒领者照着公开描述就能选对。
- 描述和照片会直接公开;
- 联系方式对所有人直接可见;
- 想认领的人会直接联系你核对。
代价:这一类没有防冒领闸门,理论上谁先看到都能联系你。所以别把唯一凭据写进公开描述(比如"书里夹着一张写名字的借书凭条"——这句话本身就是答案)。

图 8:没有验证题,描述与联系方式直接可见,页面主动说明"为什么这一类不需要答题"。
4.3 一键复制联系方式
意义:校园场景里大家联系靠的是"复制号码 → 打开微信 / 电话",这一步省去手抄和误记。
实现思路:优先 navigator.clipboard.writeText,失败(非安全上下文,file:// 下很常见)时降级为隐藏 textarea + document.execCommand('copy')。
一个刻意的设计:复制失败时不是只弹一句错误就完了——会弹出原文和一个可手动复制的输入框,事情还办得成。
4.4 分类 / 地点 / 状态筛选 + 实时条数
意义:人是按场景找东西的("我今天在图书馆丢的"),不想先想出关键词。点一下就能筛,不用打字。
实现思路:筛选逻辑集中在 store.queryPosts(),每加一个条件就是一条 continue;选项不是写死在 HTML 里的,而是从 LF.CATEGORIES / LF.LOCATIONS 两个常量渲染出来的,改一处常量,首页和搜索页同步更新。每项右侧的条数由 facets() 提供,且统计某一维时忽略该维自身的筛选(见 3.5.4)。
地点用子串模糊匹配:点"图书馆"能命中"图书馆三楼自习区""图书馆门口",不需要和原文一字不差。

图 9:左侧吸顶筛选栏(分类 / 地点 / 状态,各选项带实时条数),右侧两列卡片网格。

图 10:同一套 HTML,窄屏下自动变成底部 Tab 栏 + 单列卡片。
4.5 搜索引导:告诉失主"该搜什么",也说清"什么搜不到"
意义:这是我们在实现过程中意识到的一个副作用。取消自由描述、只公开少数特征之后,失主习惯搜的那些词("蓝色充电宝""伞柄有划痕")已经不在公开面里了,搜出来是 0 条。而"0 条结果"和"没人捡到"在页面上长得一模一样,他不会怀疑自己搜错了,只会认为东西没人捡到,然后放弃。
实现思路:每个分类在字典里带一条 searchHint,选中分类时显示。写法上有两点是刻意的:
- 不是"请搜 X",而是"请搜 X,Y 搜不到"——只说搜得动的字段,用户照样会去搜"颜色"。所以每条引导语都带一句"哪些细节不公开"。
- 显示与否由字典决定,
LF.searchHintFor()只读LF.CATEGORIES[].searchHint这一份数据:写了就显示、没写就返回空串由页面隐藏,页面不用维护第二份分类名单。

图 11:选中「证件卡片」时,筛选项下面出现这条引导——不仅说该搜什么,还说清哪些细节根本不在公开信息里。
4.6 智能配对提醒
意义:不做聊天、不做地图,就用这一步把"丢的人"和"捡的人"第一次关联起来。从"我发完就只能干等"变成"刚发完就有人推荐了一条可疑信息"。
实现思路:配对条件刻意收得很窄,避免乱推荐——
| 条件 | 说明 |
|---|---|
| 类型相反 | 寻物只匹配招领,反之亦然——同一方向的两条信息不可能是同一件物品 |
| 分类相同 | 都选了"雨伞雨具"才可能是同一把伞 |
| 仍在进行中 | 已标记"已找到 / 已归还"的不再推荐 |
| 标题有两字重合 | 中文二元组粗匹配,"蓝色折叠雨伞" ↔ "藏蓝色折叠雨伞"会命中"折叠/叠雨/雨伞" |
满足条件的最多取 3 条,显示在发布成功页和详情页。
4.7 搜索历史 + 热门搜索
意义:找东西往往是反复搜的,不用每次重新打字。热门词让"不知道搜什么"的人有个起点。
实现思路:历史最多 10 条,去重、可单条删除、可一键清空。热门词由真实数据统计得出,不是写死的:候选来自"常用地点 + 常见物品名词 + 分类名",再按"有多少条信息能被这个词搜到"排序,搜不到的自然沉底。
4.8 发布草稿自动保存 + 照片本地压缩
草稿:填了一半误关页面是真会发生的。草稿 400ms 防抖自动保存(照片不入草稿,体积太大),再次打开时问一句"要恢复吗",而不是替用户默默填回去或默默丢掉。
照片:直接存手机原图会顶到 localStorage 配额。选择后用 FileReader + canvas 压缩到最长边 480px、JPEG 质量 0.72 再存,并显示压缩后的体积。
两个交互细节:
- 题目编辑器不整块重绘:题干和选项都是输入框,如果每敲一个字就把整个表单重建一遍,焦点会丢,表现为"打一个字就跳出输入框"。所以改文字只更新内存模型、改结构才重绘那一道题。
- 切分类时把题目收起来而不是丢掉:用户写三道题是花了心思的,只因为点错一下分类就被清空、切回来还得重写,那是我们在惩罚他的误操作。所以收起来,切回来再还给他并提示一句。

图 12:招领信息必须出 3~5 道客观题。判断题的选项固定为「正确 / 错误」,选择题可以增删选项并指定正确答案;右边的分类模板用来降低出题门槛。
4.9 响应式:一套 HTML 服务两种形态
- 宽屏(≥900px):桌面网页版——顶部导航 + 左侧吸顶筛选栏 + 两列卡片网格;
- 窄屏(<900px):手机版——底部 Tab 栏 + 单列卡片 + 横向滑动的筛选条。
只用一个断点,避免中间态出现"两边都不像"的布局。筛选区只有一份 DOM,靠 CSS 在两种形态间切换——写两份 HTML 就一定会不同步(改了一边忘了另一边)。

图 13:手机端的「我的」——统计、我的发布、人工审核、本地数据管理。
4.10 其他贴心小设计
| 小设计 | 解决什么问题 |
|---|---|
| 关键词高亮 | 一屏好几条信息,直接看到"命中的是哪几个字",确认快 |
| 状态驱动 UI | 已完成的信息自动置灰、沉到列表后面,详情页不再显示联系入口 |
| 分页加载更多 | "加载更多(还有 N 条)",不让一屏滚不到底 |
| 空状态引导 | 搜不到时给"清空筛选 / 去发布"按钮,而不是干巴巴一行"暂无数据" |
| 数据导出 / 导入 | 没有后端,所以把"数据存在哪、能存多久"写在明面上,并给出迁移手段 |
| 存储降级提示条 | 降级时明确告诉用户当前数据的保留范围,不让他在毫不知情的情况下丢数据 |
五、目录说明和使用说明
5.1 目录是怎么组织的
按「页面 → 样式 → 逻辑 → 测试 → 文档」五层分开,核心思路是页面文件只管结构,业务规则全部收在数据层:
campus-lost-found/
├─ ① 页面层:9 个 HTML,文件名即用途
│ index / search / publish / success / detail
│ verify / verify-result / appeal / mine
├─ ② 样式层:3 个 CSS
│ css/tokens.css 设计令牌(色板、圆角、阴影、字号)
│ css/components.css 通用组件(卡片、标签、按钮、表单、题目编辑器、弹层、空状态)
│ css/layout.css 响应式布局(900px 断点)
├─ ③ 逻辑层
│ js/config.js 业务字典(类型、分类与搜索引导、地点、出题模板、验证规则)
│ js/utils.js 纯函数工具(时间、文本归一化、图片压缩、剪贴板)
│ js/store.js ★ 数据层:全部业务规则(含申诉通道与老数据迁移)
│ js/seed.js 演示数据(招领信息自带 3~4 道客观题)
│ js/ui.js 界面公共层(导航外壳、卡片渲染、轻提示、弹窗、一键复制)
│ js/page-*.js 9 个页面的控制器,与 HTML 一一对应
├─ ④ 测试层
│ test/test.html 测试入口,双击即跑
│ test/specs/main.spec.js 用例(10 组,53 个用例)
│ test/lib/ Mocha 与 Chai 浏览器构建(随仓库提交,离线可用)
└─ ⑤ 文档层
docs/设计文档.md / 目录说明与使用说明.md / PSP表格.md / 流程图.md
README.md
命名约定:
- 页面:小写、多词用连字符(
verify-result.html); - 脚本:
page-xxx.js一定是某个 HTML 的控制器,与页面同名,一眼能对上;不带page-前缀的是被复用的公共模块; - 数据字段:一律小驼峰,语义直白(
happenedAt、contactWay、doneType);字典类字段存key而不是中文,中文只出现在config.js里。
5.2 测试人员如何运行
- 把整个
campus-lost-found文件夹下载到本地(git clone或Code → Download ZIP后解压); - 用谷歌浏览器 Chrome 打开根目录下的
index.html(双击即可); - 第一次打开会自动灌入 11 条演示数据,其中 3 条归属于使用者本人。
不需要安装任何东西,不需要起服务器,不需要联网。
务必用 Chrome。 本项目用
localStorage存数据。Chrome 在file://协议下把所有本地文件视为同一来源,页面之间能共享数据;Firefox 会把每个文件当成独立来源,导致「发布成功后跳到成功页,数据却读不到」。
运行单元测试:双击 test/test.html,Chrome 打开后自动跑完全部 53 个用例并给出报告。

图 14:命中的关键词高亮显示,一眼能看出匹配在哪里。

图 15:「我的」页——个人资料、发布统计、我的发布、人工审核与本地数据管理。

图 16:未通过验证时,联系方式显示为「🔒 联系方式已锁定」,并说明出了几道题、还剩几次机会。

图 17:通过验证后,联系方式与认领凭证码一起出现。
5.3 建议的验收路线(走一遍就覆盖了全部核心功能)
| 步骤 | 操作 | 应该看到 |
|---|---|---|
| 1 | 首页点「证件卡片」分类 | 列表只剩证件类;出现一条搜索引导,写明这一类只公开哪些特征、该按什么搜 |
| 2 | 首页点「其他」分类 | 引导语换成"这一类不设认领验证题,描述和联系方式直接公开" |
| 3 | 打开首页那条「校园卡一张(姓名已打码)」(带 🔒 标识) | 联系方式显示「🔒 联系方式已锁定」,并说明出了几道题、还剩几次 |
| 4 | 点「开始认领验证」 | 一次看到全部题目,只有题干和选项;正确答案看不到(F12 也搜不到) |
| 5 | 只选一半就提交 | 提示「还差 N 道题没有作答」,不消耗次数,缺的题被标黄 |
| 6 | 全部选完但故意答错一题 | 跳到「验证未通过」,只提示「回答的细节与描述不符」+ 剩余次数,不告诉哪题错 |
| 7 | 再答一次,这次全选对 | 跳到「验证通过」,给出 CL- 开头的凭证码并解锁联系方式 |
| 8 | 打开「藏蓝色三折雨伞」(演示数据里已答满 3 次) | 底部出现「📮 提交申诉(人工审核)」 |
| 9 | 填申诉表并提交 | 详情页变为「申诉已提交,等待发布者核对」 |
| 10 | 进「我的」→ 人工审核 | 看到待处理申诉(含对方写的细节与联系方式);点「同意交还」生成线下交接编号 |
| 11 | 「我的」→ 我的发布 → 点「标记已归还」 | 条目移到「已完成」,首页上它变灰并沉到后面 |
| 12 | 点「+ 发布信息」,类型选「招领」、分类选「雨伞雨具」 | 出现出题区,可从下拉框插入分类模板,能加判断题 / 选择题、增删选项、指定正确答案 |
| 13 | 同一页把分类改成「其他」 | 出题区换成一段信任模式说明;再切回「雨伞雨具」,之前写的题会被放回来 |
| 14 | 发布成功页 | 显示信息编号,并给出「可能相关的信息」 |
想知道某道题的正确答案:在「我的」里点那条信息的「编辑」,题目编辑器里被圆点选中的那一项就是答案。
想恢复初始状态:「我的 → 本地数据 → 恢复演示数据」。
六、单元测试
6.1 选用的测试工具与学习过程
工具:Mocha 10.8.2 + Chai 4.5.0(浏览器构建,随仓库提交)。
学习路径:先看了作业附录里推荐的几篇(廖雪峰的 JavaScript 教程、阮一峰的 Mocha 实例教程),照着把官方文档的 Getting Started 跑了一遍,理解了 describe / it / beforeEach 这套 BDD 风格的组织方式,然后决定用它。
遇到的第一个问题不是怎么写测试,而是"在哪跑"。 按常规做法是 npm install mocha,在 Node 里跑——但这个项目本身不需要 Node。如果为了跑测试让助教先装 Node,就违背了"下载后双击就能用"的底线。
于是改成把 Mocha 的浏览器版构建随仓库一起提交,test/test.html 用 <script> 标签按顺序加载 lib/mocha.js、lib/chai.js、被测代码、用例文件,最后 mocha.run();跑完把结果写到 <html data-selftest="PASS|passing=53|failing=0"> 上,命令行也能检查。这样双击 test/test.html 就能跑,离线可用、不需要任何环境。
一个版本上的坑:Chai 从 5.x 起只提供 ES Module,普通 <script> 加载会报 SyntaxError: Unexpected token 'export',而 file:// 下又不能用 type="module"(会被 CORS 拦)。所以 Chai 固定在 4.5.0——最后一个带 UMD 浏览器构建的版本。
关于"测试自动化"这条要求:作业要求"每个人都能很容易地运行它,并且可以使单元测试每天都运行"。我们的做法是把它变成一个零依赖的单文件入口——不需要装 Node、不需要起服务器、不需要联网,双击就跑;同时把结果写进 data-selftest 属性,需要的话用一行命令就能在提交前检查:
# 想接进提交前的检查,一条命令就够(把结果打到控制台)
(chrome --headless --dump-dom test/test.html) -match 'data-selftest="PASS'
6.2 一份简易教程:从零开始给这个项目加一条测试
- 想清楚要测什么。 不是"测这个函数",而是"什么情况会出错"。比如发布表单,要测的不是
validatePost能返回结果,而是"漏填物品名称时会不会拦住"。 - 造一份必然合法的输入作为基准。
test/specs/fixtures.js里放了validLost()/validFound()两份基准数据(含一套标准验证题和一份标准作答),每条用例只在这份基准上改一个字段——这样一旦失败,出错原因就是唯一的那个。 - 按下界和上界各取两个点。 例如把
title改成 1 个字断言报错、2 个字断言通过。 - 把当前时间固定下来。 不固定
now,"日期不能晚于今天"这类断言会随运行时刻改变,测试就会间歇性地红。 - 用内存存储隔离每条用例。 每条
it里都调T.makeStore()新建一个 store(注入LF.createMemoryAdapter()),用例之间互不污染,也不用担心测试数据残留。
6.3 用例设计方法:白盒为主
作业要求"至少应采用白盒测试用例设计方法"。我们主要用了四种,前面两种属于白盒:
| 方法 | 怎么用 | 例子 |
|---|---|---|
| ① 分支覆盖(白盒) | 每个 if 的"真 / 假"两侧各取一条用例,不留单边分支 |
日期校验里"晚于今天"这条:一条填明天的(拒绝)、一条填今天的(通过) |
| ② 边界值(白盒) | 凡是有上下界的字段,取「下界−1 / 下界 / 上界 / 上界+1」四个点 | 物品名称限 2–40 字 → 测 1 字(拒)、2 字(过)、40 字(过)、41 字(拒) |
| ③ 等价类划分 | 同一类输入只取代表,避免用例重复 | 联系方式分「合法手机号 / 位数不足 / 格式不符」三类各取一个 |
| ④ 正例反例成对写 | 只测"应该通过"会写出过宽的实现,只测"应该拒绝"会写出过严的实现 | 搜索的"命中"与"不命中"、权限的"本人能改"与"别人不能改" |
另外两条工程上的处理:
- 把不确定性剔除:时间注入固定成
2026-10-09T10:00:00;需要验证排序时用一个可推进的时钟函数——因为时间不动的话所有createdAt相等,"最新在前 / 最早在前"这两条规则根本没被验证到(比较器全部返回 0,只剩稳定排序的原始顺序)。 - 每条用例一个干净仓库:注入
LF.createMemoryAdapter(),用例之间互不污染。
6.4 用例清单:10 组,53 个用例
按被测模块分组,用例名写"应该发生什么",而不是"测哪个函数"。
| 组 | 主题 | 用例数 | 重点 |
|---|---|---|---|
| 一 | 发布校验 | 10 | 名称 / 描述 / 日期 / 联系方式的上下边界成对写;校验不通过时不落库 |
| 二 | 出题规则 | 6 | 题量 3~5;空选项行被丢弃后正确答案下标跟着重排;判断题选项由数据层固定;寻物与「其他」类不出题 |
| 三 | 搜索与筛选 | 6 | 命中与不命中成对写;多词按「与」;正则元字符不崩;地点子串匹配;已完成沉底 |
| 四 | 隐私出口 | 6 | 下发的题目里没有 answer;公开视图没有 claims / appeals 键;未解锁时 contactWay 是空串;受保护字段防篡改 |
| 五 | 认领验证 | 10 | 未答完不扣次数;全错与只错一题返回的对象结构完全一致;答对不扣次数;3 次用完才开申诉;换题重置次数 |
| 六 | 申诉与人工审核 | 4 | 解锁条件;明细只有发布者能看;同意交还与驳回的两条分支 |
| 七 | 状态流转 | 3 | 状态文案映射;幂等(重复标记不改动完成时间);越权拦截 |
| 八 | 存储与容错 | 3 | 半截 JSON / 非数组 / 混入 null 不崩;配额写满给中文提示;旧脚手架数据能迁移 |
| 九 | 端到端串联 | 2 | 主线一次走通;「3 次全败 → 申诉 → 同意交还」整条链路 |
| 十 | 基础工具与演示数据 | 3 | 防 XSS 的转义与高亮;相对时间的几种说法;演示数据本身必须能通过当前校验 |
| 合计 | 53 |
这里挑两条最能说明问题的:
① 统一判定:全错和只错一题,返回给前端的东西必须一模一样
it('★ 全错和只错一题,返回给前端的东西必须一模一样', function () {
var c1 = locked();
var allWrong = c1.store.submitClaim(c1.id, T.wrongAnswers(), T.OTHER);
var c2 = locked();
var onlyOneWrong = T.correctAnswers();
onlyOneWrong[0] = 1 - onlyOneWrong[0]; // 只把第一题改错
var oneWrong = c2.store.submitClaim(c2.id, onlyOneWrong, T.OTHER);
assert.deepEqual(Object.keys(allWrong).sort(), Object.keys(oneWrong).sort(),
'两种失败情况返回的键集合必须完全一致');
assert.strictEqual(JSON.stringify(allWrong), JSON.stringify(oneWrong),
'否则前端就能从差异里推断出答对了几题,3 次机会变成"每次排除一题"');
});
为什么费劲去断言"结构一样":因为这是本次改版的核心承诺。如果哪天有人为了"提升体验"把"哪题错了"加回来,这条用例会立刻变红——把设计决策写成测试,它才不会被后续改动悄悄推翻。
② 端到端串联:模块拼起来也是通的
main.spec.js 的第九组用两条用例把作业要求的主线走一遍:
发布招领并出 3 道题 → 关键词搜到 → 打开详情(看得到描述,看不到联系方式与 answer)→ 先答错一题(扣一次次数、只回统一提示)→ 再全对(拿到 CL- 开头的凭证码)→ 再看详情不再锁定 → 标记已归还 → 统计对得上
以及核心附加特点那条链路:
连答错 3 次 → 申诉入口打开 → 提交申诉 → 发布者看得到明细而非发布者看不到 → 同意交还 → 认领人拿到联系方式与线下交接编号
单模块测试都过、拼起来却出问题,是结对编程里很常见的情况——所以专门留了一组把它们串起来。
6.5 测试评估:这些用例够用吗?
作业要求"结合测试评估的要求,对自己的测试设计进行评价"。我们的自评分三层:
够用的部分(这 53 条能保证什么)
作业要求的那条主线的每一个环节,都有用例钉住:发布(校验边界 + 不落库)、浏览搜索(命中与不命中)、详情(联系方式按状态呈现)、联系(隐私出口)、状态更新(幂等 + 越权拦截)。核心机制的判定逻辑是逐分支覆盖的——认领验证的「未答完 / 答错 / 答对 / 次数用尽」四条分支、申诉的「次数没完 / 已提交 / 未找到」三条分支,都有对应用例。只要这 53 条全绿,核心业务规则在各种输入下都是对的。
不够用的部分(它证明不了什么)
- 页面层没有自动化回归。 全部用例都落在
store.js/config.js/utils.js上,页面控制器的渲染与事件绑定靠人工走查(原因和补充办法见 6.6)。 - 浏览器专属函数测不到。
compressImage(依赖 canvas 与 FileReader)和copyText(依赖 navigator.clipboard)在 Node 里根本执行不了,只能靠真机走查。 - 环境分支天然不可覆盖。 每个模块开头都有"Node 里 require / 浏览器里挂全局"的分支,同一个进程里只能走一边。
覆盖率反查(用数据找盲点,而不是刷数字)
为了确认"没测到的地方到底是测不到、还是漏了",我们用 Node 内置的 V8 覆盖率跑了一遍全部用例,把"一次都没执行到的代码块"逐个列出来看:
| 文件 | 字节覆盖(≈语句) | 说明 |
|---|---|---|
js/store.js |
72% | 未覆盖的主要是防御性默认值(缺参数时的兜底)与环境分支 |
js/config.js |
93% | |
js/utils.js |
72% | 未覆盖的主要是 compressImage / copyText 两个浏览器专属函数 |
js/seed.js |
98% |
这一步真的抓到了一个盲点:第一版用例里所有 store 都是 seed: false 建的,导致 seed.js(随应用一起发出去的演示数据)几乎从没被执行到——如果演示数据坏了,应用会带着坏掉的数据发出去,而测试全绿。 补上第十组之后,这一块从 8% 提到了 98%。
结论:这 53 条用例能满足课程作业对"核心功能正确性"的测试要求,也能挡住"规则被后续改动悄悄改坏"这类回归;但它不构成完整的质量保证——页面可用性、浏览器兼容性、真实多人并发场景都在这套测试之外。
6.6 测试的盲区:三层不同层面的检查
自动化测试跑绿了,不等于用户能用。我们后来补了两层检查(脚本依赖本机 Chrome,没有随仓库提交,但确实跑过):
| 层次 | 做什么 | 补的是哪个盲区 |
|---|---|---|
| 单元测试(53 用例) | 数据层全部业务规则 | —— |
| 静态一致性 | 页面 JS 用到的 id 在 HTML 里是否存在、脚本与样式路径是否有效、页面互链是否 404 |
逻辑单测证明数据层对,证明不了页面把它接对了 |
| 浏览器渲染 | 把 9 个页面真的在 Chrome 里跑一遍,检查关键内容是否渲染出来 | 证明页面能画出来,证明不了按钮点下去真的有用 |
| 交互流程 | 模拟点击 / 输入 / 提交,走通主流程与认领验证、申诉、状态维护 | 证明 id 对得上,证明不了元素真的可见可点 |
一个真实的教训:桌面端布局里我们写过一条 .searchbox { display: none } 想藏掉首页那个"搜索框样式"的入口链接,而搜索页真正的输入框用的是同一个类名。结果桌面宽度下整个搜索输入框被一起藏掉了——用户点进搜索页,看不到任何可以打字的框。
页面没报错、单元测试也全绿(测试脚本是直接按引用操作元素的,input.value = 'x' 对 display:none 的元素同样有效),是后来换成"这个元素能不能被点到"(getBoundingClientRect() 看尺寸 + document.elementFromPoint() 看该位置上最顶层的是谁)才发现的。
修复很简单(把入口的类名改成 .home-search-entry),但教训比修复本身重要:"页面没报错"和"用户能用"是两回事。
七、GitHub 代码签入记录
仓库地址:https://github.com/Aidname/102301230-102401135
我们的提交粒度是按功能划分,每完成一个功能提交一次,提交信息用 类型(范围): 做了什么 的格式(feat / fix / test / docs / style / chore),范围直接写模块名,让人翻记录时能看出"这一批改的是哪一块"。
本次实现的提交记录:
| # | Commit | 类型 | 说明 | 负责人 |
|---|---|---|---|---|
| — | aa1b4cf |
merge | 上一次合并(脚手架阶段的收尾) | 林为峰 |
| 1 | 6a4e100 |
chore | 重整为分层结构,移除上一版单文件脚手架 | 共同 |
| 2 | d667b09 |
feat(config) | 业务字典层 | 何慧渊 |
| 3 | 013b90f |
feat(utils) | 纯函数工具层 | 何慧渊 |
| 4 | 4794236 |
feat(store) | 数据层 —— 全部业务规则收在一处 | 何慧渊 |
| 5 | 8cea48b |
feat(seed) | 演示数据 | 何慧渊 |
| 6 | c091719 |
feat(ui) | 界面公共层 | 林为峰 |
| 7 | 89979d7 |
style(css) | 设计令牌与通用组件 | 林为峰 |
| 8 | df27655 |
style(css) | 响应式布局 | 林为峰 |
| 9 | 45573d6 |
feat(home) | 首页 —— 浏览、四维筛选与搜索引导 | 林为峰 |
| 10 | 11f4a11 |
feat(search) | 搜索页 —— 关键词、热门词、历史与命中高亮 | 林为峰 |
| 11 | 34a7411 |
feat(publish) | 发布与编辑页 —— 表单校验、出题编辑器、草稿与照片 | 林为峰 |
| 12 | f1d443e |
feat(success) | 发布成功页与智能配对 | 林为峰 |
| 13 | 8e69d79 |
feat(detail) | 详情页 —— 联系方式按需解锁与状态维护 | 林为峰 |
| 14 | 077f513 |
feat(verify) | 认领验证答题页与结果页 | 林为峰 |
| 15 | cb12377 |
feat(appeal) | 申诉页 —— 人工审核通道 | 林为峰 |
| 16 | 9607e03 |
feat(mine) | 我的 —— 资料、统计、发布管理与人工审核 | 林为峰 |
| 17 | 3a544fa |
test | 单元测试用例与浏览器测试入口 | 何慧渊 |
| 18 | 3464f7c |
docs | README、设计文档、目录说明、流程图与 PSP 表格 | 共同 |
| 19 | da50fc7 |
merge | 同步搭档上游的详情页提交 | 何慧渊 |
| 20 | c4f0404 |
chore | 移除被 js/page-detail.js 取代的旧详情页脚本 |
何慧渊 |
| — | f3d1c8b |
merge | Merge pull request #4 from Victoria029/main | 林为峰 |
几条值得注意的:
- 数据层放在一个提交里,是有意的:所有业务规则必须同时生效才成立("要不要出验证题"这一个判断同时决定校验、锁定、认领入口和申诉入口),拆开反而会出现"详情页按新规则、列表页还按旧规则"。这一点写进了 commit 正文。
- 重构与删除分开提交:
chore: 重整为分层结构只做"删旧的",chore: 移除被 js/page-detail.js 取代的旧详情页脚本单独说明为什么这个文件已经没人引用(它依赖的旧数据层 API 已经不存在了)。 - 测试和文档单独提交,不混在功能提交里。
- 同步上游单独成一次 merge 提交,而不是把上游的改动揉进功能提交里——这样回看时能分清"哪些是搭档的、哪些是我的"。
协作用的是 fork + Pull Request 流程:
- 林为峰创建主仓库
Aidname/102301230-102401135; - 何慧渊 fork 出
Victoria029/102301230-102401135,在本地按功能拆分提交,每完成一个功能就 push 一次; - 阶段性完成后向主仓库发起 Pull Request(PR #4),由林为峰 review 差异后合并到主仓库
main。
本次 PR:#4,20 个提交 / 49 个文件 / +43106 −475 行,由林为峰合并(合并提交 f3d1c8b)。

图 18:主仓库的提交记录——20 条分功能提交,加上原有骨架共 35 条。

图 19:PR #4 由搭档 review 后合并。
八、遇到的代码模块异常或结对困难及解决方法
| # | 问题 | 做过的尝试 | 是否解决 | 收获 |
|---|---|---|---|---|
| 1 | 第一版验证机制"判不准"和"能试探"同时卡住:手打答案导致真失主因为"深蓝/藏青"被判错;为了让失主知道错在哪必须回传错题,结果 3 次机会足够把答案试出来 | 先想加大文本归一化的宽容度,发现宽容度本身没有正确答案;再想减少提示信息量,发现不给提示失主无从下手 | ✅ 整块推翻重做:从"文本比对"换成"选项下标比对",只回通过/不通过 | 当一个判定"怎么调都不对"时,先怀疑判定方式本身,而不是继续调参数。 换成客观题之后,两个毛病是一起消失的 |
| 2 | "不告诉哪题错"看起来是体验倒退,我们担心真失主会觉得系统在刁难他 | 讨论后决定:不靠"多给信息"来补偿,而是在结果页主动解释为什么不告诉他,并给申诉出口 | ✅ 结果页明确写出"否则反复试几次就能把答案试出来",并给出"再答一次"和"提交申诉"两个出口 | 安全性设计如果让用户困惑,代价要用解释和出口来补,而不是把信息还回去 |
| 3 | 题目编辑器打字丢焦点:题干和选项都是输入框,一开始每次改动都重绘整块,结果"打一个字就跳出输入框" | 排查发现是重绘把输入框整个替换掉了,焦点自然丢失 | ✅ 分成两条路:改文字只更新内存模型、不重绘;改结构(加题/删题/换题型/加删选项)才重绘那一道题 | 有输入框的地方,"重绘"和"改状态"要分清——DOM 一重建,焦点和光标位置就都没了 |
| 4 | 切一下分类,用户写的三道题就没了 | 一开始认为"换了分类本来就不该出题",清空是合理的;走查时发现点错一下分类就得重写三道题,很不友好 | ✅ 改成 stashedQuestions:不需要出题时收起来,切回需要出题的分类再还给他并提示一句 |
规则正确不等于体验正确。用户的操作失误不该被我们惩罚 |
| 5 | 桌面端一条 CSS 把搜索页真正的输入框藏掉了:首页那个"搜索框样式的入口链接"和搜索输入框用了同一个类名 .searchbox |
页面没报错、单元测试全绿,一度以为搜索页没问题;后来换成"这个元素能不能被点到"才测出来 | ✅ 给入口换类名(.home-search-entry),并在走查里加了一层"元素尺寸是否为 0 / 该位置上最顶层的是谁"的检查 |
"页面没报错"和"用户能用"是两回事。 测试脚本按引用操作元素,会绕开真实用户遇到的障碍 |
| 6 | 改成「其他」类不出题之后,老数据还挂着题,发布者被锁在旧流程里 | 只改了新流程,历史记录里的 questions 还在,认领人仍被要求答题,而发布页已经没有出题入口了——发布者连"把这套题删掉"都做不到 |
✅ 迁移时把不需要验证的分类下残留的题目一并清掉、作答次数复位;补了三条用例钉住 | 改规则的时候必须同时问一句"老数据怎么办"。 这种"用户看得见现象、却没有任何入口能修"的状态,比报错更难被发现,因为它一切"正常" |
| 7 | 选项本身还是会判不准:发布者把选项写成「深蓝色 / 藏青色」,东西其实是藏青色的同学看见「深蓝色」觉得说的就是自己,随手选了 | 想一律拦下"容易混淆的选项",但发现「深蓝色 / 浅蓝色」是能分清的,一律拦会大量误报 | ✅ 做了一份易混词组表,组内互斥、组间放行;判定取命中的最长词("浅蓝色"同时含"蓝色"和"浅蓝色",不比长度会归错组);但这条规则只提示、不拦截 | 防误报和防漏报同样重要。 这条规则如果误报了,发布者的第一反应不是改选项,而是关掉这条规则——比不拦还糟 |
附:一个我们没法彻底解决、只能如实写下来的问题
认领的作答次数记在信息上,而不是按认领人区分。也就是说,同一条信息被答满 3 次之后,后面来的人也只能走申诉通道;而申诉内容(称呼、联系方式、物品细节)在发布者那一端是可见的,本机数据又是共享的。真实环境里应该把尝试次数和申诉记录按人区分、并存到服务端,本项目作为课程作业做不到,所以我们没有把它藏在代码注释里,而是写进了 README 的「已知限制」,让每一个使用者都看得见。
九、评价你的队友
对林为峰(102301230)的评价:
-
值得学习的地方 —— 界面走查的较真程度。 有三个我完全没意识到的问题,都是他一条一条点出来的:
第一个是题目编辑器打字丢焦点。我写的重绘逻辑每次改动都把整块表单重建一遍,结果"打一个字就跳出输入框"。我在数据层测了半天,压根没想到问题在重绘上。
第二个更典型:桌面端一条 CSS 把搜索页真正的输入框一起藏掉了。首页那个"长得像搜索框"的入口链接和搜索页的输入框用了同一个类名
.searchbox,我给入口加display:none的时候把真输入框也藏了。页面不报错、我的单元测试全绿——因为测试脚本是直接按引用操作元素的,input.value = 'x'对display:none的元素照样有效。是他换了一种测法(看元素的实际尺寸、看那个位置上最顶层的是谁)才发现的。这件事直接改变了我们对"测试通过"的理解。第三个是切一下分类,用户写的三道题就没了。我当时觉得"换了分类本来就不该出题,清空很合理",他追问了一句"那用户点错了分类,是不是就得重写三道题?"——这句话让我把清空改成了"收起来,切回来再还给他"。
-
值得学习的地方 —— 会从用户视角提要求。 我写功能时习惯先问"规则对不对",他会追问"用户会不会因此白干一次"。上面第三条就是被这个习惯逼出来的。
-
需要改进的地方 —— 对数据层的边界可以更敏感一点。 "哪些字段不能让前端改"(id、编号、浏览量、归属、认领记录)这一层兜底最初是我补上去的,后来才变成约定。如果一开始就把"谁负责守住什么"聊清楚,能少一轮返工。另外前期 commit 粒度不太统一,有的提交把页面和样式混在一起,后来统一成"每完成一个可独立验证的功能提交一次"才好起来。
十、总结
这次我主要负责数据层和单元测试,收获最大的地方是学会了"把规则放在能被测试的地方"。
一开始我把业务规则很自然地写在页面里——校验写在发布页、状态判断写在详情页。写到一半发现不对:同一条规则在列表页、详情页、搜索结果里各出现了一次,改一处就得记着去改另外两处,而且漏了也看不出来。于是下决心把全部规则搬进 store.js。这个决定带来了一连串的连锁好处:规则能单独测了、不用浏览器也能跑了、改一处就全局生效了。"让规则变得可测试"本身就是一个设计动作,而不只是"为了写测试"。
第二个收获是把设计决策写成测试。"全错和只错一题返回的对象必须一模一样"这条,表面上看是在测一个很怪的东西,实际上它是在保护我们在方案讨论里做过的决定。后来我意识到,代码注释会和代码一起被改掉,而一条会变红的测试不会。
第三个收获是看到了自己的短板。我对"逻辑正确"很敏感,对"用户能不能用"却钝得多:搜索框被 CSS 藏掉那次,我的第一反应是"不可能,我测过了",而不是"那我去看看"。这是这次结对里搭档帮我校准得最多的一点。
如果重做一次,我会把"攻击自测"提前到写第一版实现之前:先想清楚"什么样的人会来钻空子",再动手写规则,而不是写完再扮演坏人。
浙公网安备 33010602011771号