AIGC标识 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 → 内存)

为什么要分这一层:有四件事必须做对,做错了后果严重——

  1. 验证题的正确答案不能泄露(这是防冒领的根基);
  2. 状态流转不能乱(东西还回去了、信息还挂着"待认领",别人就会白跑一趟);
  3. 只有发布者能改自己的信息;
  4. 搜索得搜得到(搜"校园卡"找不到"校园卡一张(学号 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,选中分类时显示。写法上有两点是刻意的:

  1. 不是"请搜 X",而是"请搜 X,Y 搜不到"——只说搜得动的字段,用户照样会去搜"颜色"。所以每条引导语都带一句"哪些细节不公开"。
  2. 显示与否由字典决定,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 测试人员如何运行

  1. 把整个 campus-lost-found 文件夹下载到本地(git clone 或 Code → Download ZIP 后解压);
  2. 用谷歌浏览器 Chrome 打开根目录下的 index.html(双击即可);
  3. 第一次打开会自动灌入 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 一份简易教程:从零开始给这个项目加一条测试

  1. 想清楚要测什么。 不是"测这个函数",而是"什么情况会出错"。比如发布表单,要测的不是 validatePost 能返回结果,而是"漏填物品名称时会不会拦住"。
  2. 造一份必然合法的输入作为基准。 test/specs/fixtures.js 里放了 validLost() / validFound() 两份基准数据(含一套标准验证题和一份标准作答),每条用例只在这份基准上改一个字段——这样一旦失败,出错原因就是唯一的那个。
  3. 按下界和上界各取两个点。 例如把 title 改成 1 个字断言报错、2 个字断言通过。
  4. 把当前时间固定下来。 不固定 now,"日期不能晚于今天"这类断言会随运行时刻改变,测试就会间歇性地红。
  5. 用内存存储隔离每条用例。 每条 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 流程:

  1. 林为峰创建主仓库 Aidname/102301230-102401135;
  2. 何慧渊 fork 出 Victoria029/102301230-102401135,在本地按功能拆分提交,每完成一个功能就 push 一次;
  3. 阶段性完成后向主仓库发起 Pull Request(PR #4),由林为峰 review 差异后合并到主仓库 main。

本次 PR:#4,20 个提交 / 49 个文件 / +43106 −475 行,由林为峰合并(合并提交 f3d1c8b)。

GitHub 提交记录

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

PR #4

图 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 藏掉那次,我的第一反应是"不可能,我测过了",而不是"那我去看看"。这是这次结对里搭档帮我校准得最多的一点。

如果重做一次,我会把"攻击自测"提前到写第一版实现之前:先想清楚"什么样的人会来钻空子",再动手写规则,而不是写完再扮演坏人。

posted @ 2026-10-09 21:43  Victoria_L  阅读(7)  评论(0)    收藏  举报