一、作业信息
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 软件工程 |
| 这个作业要求在哪里 | 作业要求 |
| 这个作业的目标 | 把第一次结对作业的原型实现成可运行的代码,并完成单元测试 |
| 姓名 | 张陈雨菲 刘俊延 |
| 学号 | 102401208 102401133 |
| 代码仓库 | https://github.com/52HzWhale111/102401208-102401133 |
二、分工
第一次作业我们按「设计」分工(队友进行UI设计,我绘制原型),这次按用户流程分工,两个人各拥有一条能独立演示、独立测试的完整链路:
| 负责的链路 | 具体产出 | |
|---|---|---|
| 张陈雨菲(102401208) | 发布链路 | 首页、发布页、发布成功页、编辑页;js/core.js、js/store.js、js/seed.js、js/layout.js、js/card.js、js/form.js、shared/app.css;core.test.js / edit.test.js / square.test.js |
| 刘俊延(102401133) | 浏览链路 | 搜索页、广场页、详情页、我的页;侧滑菜单与空状态;browse.test.js |
首页由两人共同讨论后实现。切分方式不是按页面个数平均分,而是先由队友把数据层和接口契约做出来(数据字段、状态取值、LF.search 的 query 约定、页面之间的参数怎么传),我再拿着这份契约并行开发浏览链路。所以几个 PR 基本都是「我提分支 → 队友 review 后合并」,中途没有出现过两边对同一个字段各叫各的名字、合并时大面积返工的情况。
三、PSP 表格(借鉴了作业要求里的那一份)

在开发过程中比较耗时间的三项额外工作主要都和接口契约的细节有关。刚开始我们以为,只要确定好各个字段的名称和含义,就算完成了接口约定。但在实际开发过程中,我们发现还有很多细节需要提前明确,比如没有填写时间的信息应该如何排序、?kind=lost 和 ?kind=found 以外的参数应该如何处理,以及状态筛选为 all 时是否需要过滤数据等。
这些问题虽然看起来比较细小,但如果事先没有统一约定,两个人在开发不同页面时就可能采用不同的处理方式,导致后续出现不一致的情况。因此,我们需要花更多时间完善接口契约,明确各种特殊情况的处理规则。这部分原计划用时2小时,实际花了3小时,也是本次PSP计划与实际耗时产生偏差的主要原因。
四、解题思路与设计实现
4.1 整体思路:将数据处理与页面展示分离
本项目的一个重要要求是零依赖运行,也就是下载项目后,不需要安装额外的运行环境或启动本地服务器,直接使用 Chrome 打开 index.html 就能运行。为了满足这一要求,我们在开发过程中对项目结构和技术方案进行了相应的调整。
首先,项目没有使用 ES module。由于页面需要通过 file:// 协议直接打开,使用 <script type="module"> 可能受到浏览器跨域安全策略的限制,因此项目统一使用传统的 <script src> 引入 JavaScript 文件。
其次,项目没有使用 fetch 或 XMLHttpRequest 读取本地 JSON 文件。考虑到本地文件访问的限制,我们将初始数据放在 js/seed.js 中,通过 JavaScript 变量提供给其他模块使用。
最后,所有 HTML 页面都放在项目根目录下。由于数据中保存的图片路径采用相对路径,例如 assets/items/item-01.svg,如果将 HTML 页面放进子目录,图片路径就可能发生变化,导致图片无法正常显示。
基于这些要求,我们将项目划分为几个相对独立的部分,整体思路是:页面负责展示,公共模块负责处理业务逻辑,数据层负责统一读写数据。
页面中的脚本主要负责获取数据、渲染界面和处理用户操作。对于关键词匹配、状态判断等通用逻辑,则统一放在 js/core.js 中,并通过纯函数实现,尽量减少对页面和存储的依赖。
这样设计的一个好处是,页面和单元测试能够使用同一套核心逻辑,避免出现测试通过了,但页面实际使用的是另一套代码的情况。
| 层次 | 主要内容 | 主要职责 |
|---|---|---|
| 页面层 | 9 个 HTML 页面及其内联脚本 | 展示页面、获取数据、响应用户操作 |
| 公共模块层 | core.js、layout.js、card.js、form.js、side-menu.js |
提供通用的业务处理、页面布局、卡片渲染和表单等功能 |
| 数据层 | store.js、seed.js |
管理数据的读取、添加、修改和初始数据 |
| 持久化层 | localStorage |
保存用户数据;不可用时使用内存数据作为临时替代 |
4.2 主要业务流程与数据流转
流程图:

(1)发布信息
用户在发布页填写名称、类别、地点、描述和联系方式。提交后系统调用 LF.validateDraft() 校验表单,不通过则提示修改;通过则用 LF.normalizeDraft() 整理数据、LF.nextId() 生成编号,最后由 LF.store.add() 保存,尽量避免不合规数据进入存储层。
(2)保存数据
数据用 localStorage 保存,统一键名 shiguang.items.v1。各页面不直接操作存储,而是通过 LF.store 的方法读写。本地存储不可用时自动降级为内存存储:功能照常可用,只是刷新后数据丢失。
(3)浏览和搜索信息
首页、搜索页、广场页、我的页都通过 LF.store.all() 取数据,再用 js/core.js 中的函数筛选、排序,最后调 LF.card.renderList() 展示。四个页面共用同一个卡片渲染模块,减少重复代码,也保证字段和样式一致。
(4)查看详情和联系发布者
点击卡片进入 detail.html?id=具体ID,详情页根据地址栏 ID 调用 LF.store.get(id) 取信息并展示。找不到时显示提示而非空白页。联系方式通过复制按钮提供,方便保存使用。
(5)更新信息状态
发布者找到或归还物品后,可在「我的」页改状态。系统先用 LF.canTransit() 判断是否为发布者、信息是否仍在进行中,再调 LF.store.markDone() 更新。全项目统一用 open / done 两个逻辑状态,展示文案按类型生成;更新后的数据仍存在同一份存储中,其他页面下次读取即为最新状态。至此,从发布到更新状态的流程形成闭环。
4.3 关键实现与代码说明
(一)使用 UMD 方式兼顾页面运行与单元测试
js/core.js 用 UMD 包装,同一份代码既能在浏览器运行,也能在 Node.js 测试环境加载。
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.LF = factory();
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ……核心逻辑
return {
statusText,
search,
markDone
// 其他函数
};
});
浏览器中通过 <script src="js/core.js"> 加载,函数挂在全局 LF 上;测试中用 require('./js/core.js') 引入同一份逻辑。这样页面和测试共用一份核心代码,减少重复实现,且 core.js 不依赖外部文件,适应双击打开的运行方式。
(二)区分发布和编辑的数据处理逻辑
发布页和编辑页共用同一套表单,但保存方式不能完全一样:发布要生成新 ID、发布时间、发布者和初始状态 open;编辑要保留 id、createdAt、author、images、state,只改表单允许修改的字段。若编辑直接复用发布时的 normalizeDraft(),会覆盖原有数据,甚至把已完成的信息打回进行中。因此单独设计了 applyEdit():
function applyEdit(item, draft, at) {
if (!item) return item;
var when = at || new Date().toISOString();
var next = {};
// 复制原有信息,避免直接修改原对象
for (var k in item) {
if (Object.prototype.hasOwnProperty.call(item, k)) {
next[k] = item[k];
}
}
var d = draft || {};
var contact = d.contact || {};
// 只更新表单中允许修改的字段
next.kind = d.kind === 'found' ? 'found' : 'lost';
next.title = trim(d.title);
next.category = d.category;
next.place = trim(d.place);
next.happenedAt = trim(d.happenedAt);
next.desc = trim(d.desc);
next.contact = {
type: contact.type,
value: trim(contact.value)
};
next.updatedAt = when;
next.timeline = (item.timeline || []).concat([{
state: item.state,
at: when,
action: 'edit'
}]);
return next;
}
函数先复制原对象、再更新白名单字段、最后返回新对象,不直接改传入的数据。timeline 记录本次编辑,便于区分「编辑」与「状态变更」,也方便写单元测试。
(三)统一搜索和筛选逻辑
搜索页和广场页都要筛选信息,各写各的既重复又容易出现同一条件在不同页面表现不一致。因此把逻辑统一放在 js/core.js:matchesQuery() 判断单条是否符合,search() 返回筛选并排序后的结果。
function matchesQuery(item, query) {
if (!item) return false;
var q = query || {};
if (q.kind && q.kind !== 'all' && item.kind !== q.kind) {
return false;
}
if (q.category && q.category !== 'all' &&
item.category !== q.category) {
return false;
}
if (q.state && q.state !== 'all' &&
item.state !== q.state) {
return false;
}
if (q.place && q.place !== 'all' &&
String(item.place || '').indexOf(q.place) === -1) {
return false;
}
if (!withinRange(item, q.within, q.now)) {
return false;
}
var keyword = trim(q.keyword).toLowerCase();
if (keyword) {
var haystack = [
item.title,
item.place,
item.desc,
categoryLabel(item.category)
].join(' ').toLowerCase();
if (haystack.indexOf(keyword) === -1) {
return false;
}
}
return true;
}
/**
* 根据条件筛选信息,并按照发布时间排序。
*/
function search(items, query) {
var q = query || {};
var hit = (items || []).filter(function (item) {
return matchesQuery(item, q);
});
return sortByTime(hit, q.sort === 'asc' ? 'asc' : 'desc');
}
这套逻辑支持关键词、类型、类别、地点、状态和时间范围等条件,条件为空或为 all 时不按该条件筛选。关键词同时匹配名称、地点、描述和类别名,不区分大小写——输入「图书馆」能找到地点含该词的信息,输入类别名也能命中对应物品。统一之后各页面只需传入自己的条件,就用同一套规则完成检索。
4.4 开发过程中遇到的问题
在实际开发和测试过程中,我们也遇到了一些不太容易发现的问题。这些问题主要与 JavaScript 的参数传递方式和 CSS 的默认行为有关。
问题一:直接将函数传给 filter() 会产生意料之外的结果
在“我的”页面中,需要筛选出当前用户发布的信息。最开始可能会想到直接写:
LF.store.all().filter(LF.isMine); // 不推荐
但 Array.prototype.filter() 在调用回调函数时,会依次传入当前元素、数组下标和原数组。而 LF.isMine(item, user) 的第二个参数表示需要判断的用户。
直接传入 LF.isMine 时,第二个参数就可能变成数组下标,导致判断结果不符合预期。由于这类问题不一定会产生明显的报错,排查起来比较困难。
因此,实际使用时应该通过匿名函数明确传入需要的参数:
function mineItems() {
var list = LF.store.all().filter(function (item) {
return LF.isMine(item);
});
return LF.sortByTime(list, 'desc');
}
这样就只将每条信息传给 LF.isMine(),避免数组下标被误当作用户参数。
问题二:设置 hidden 后,元素仍然可能显示
开发过程中还遇到了一个 CSS 问题:部分元素虽然已经设置了 hidden 属性,但页面上仍然可以看到它们。
原因是这些元素本身设置了 display: flex 或 display: grid。在这种情况下,作者编写的显示规则可能会覆盖浏览器默认的隐藏样式。
因此,对于需要通过 JavaScript 控制显示与隐藏的元素,可以补充明确的 CSS 规则:
.filters[hidden],
.result-bar[hidden],
.item-list[hidden],
.state-page[hidden],
.search-clear[hidden] {
display: none;
}
这样,在搜索无结果、清空搜索框或切换筛选条件时,相关区域就能按照预期显示或隐藏。
为了减少类似问题再次发生,项目还通过 test/browse.test.js 对相关页面的隐藏样式进行检查,帮助发现遗漏的情况。
问题三:overflow: hidden 可能影响侧滑菜单的定位
在实现侧滑菜单时,我们最初考虑使用 overflow: hidden 裁剪超出容器的部分,但实际测试时发现,打开菜单的过程中,菜单可能出现偏移。
排查后发现,这与浏览器在聚焦屏幕外元素时自动调整滚动位置有关。如果容器同时形成了滚动区域,浏览器为了让获取焦点的按钮进入可视范围,可能会改变容器的滚动位置,进而影响菜单的显示。
因此,这里改用 overflow: clip 来实现裁剪:
.side-menu {
position: fixed;
top: 0;
bottom: 0;
left: 50%;
width: 100%;
max-width: 430px;
transform: translateX(-50%);
/* 只裁剪超出内容,不建立滚动容器 */
overflow-x: clip;
overflow-y: visible;
}
与 overflow: hidden 相比,overflow: clip 可以在满足裁剪需求的同时,避免建立相应的滚动容器,从而减少菜单打开时发生位置偏移的可能性。
通过这个问题,我们也认识到,页面出现异常时不一定是 JavaScript 逻辑有问题,CSS 的布局和浏览器自身的行为也需要考虑。
五、附加特点
作业只要求「发布 / 浏览 / 详情 / 联系 / 状态」五件事。以下是我们在要求之外多做、并且做进了测试的部分。
5.1 设计的创意独到之处,这个设计的意义
(1)「先找后发」的引导式浏览入口
首页的两个大入口不是直接跳到发布页,而是先进入一个引导浏览页(browse.html):
- 点「我要寻物」→ 先看到别人发布的招领信息(有没有人捡到我的东西?)
- 点「我要招领」→ 先看到别人发布的寻物信息(是不是有人在找这个?)
意义:失物招领的核心痛点是「信息不对称」。多数人丢东西的第一反应是发一条寻物,但更高效的做法是先看看有没有人已经捡到了。这个设计把「先找后发」的产品理念落到了交互路径上——先帮用户找到,再让他发布,能减少无效帖子和重复联系。
(2)「状态只有两个逻辑值」的统一收敛
原型和需求描述里先后出现过「寻找中 / 待认领 / 找到 / 已找回 / 寻找失主中 / 焦急寻找中 / 已完成」七八种叫法。代码里统一收敛为 state: 'open' | 'done',页面显示文案由 LF.statusText() 派生:
| 信息类型 | state: 'open' |
state: 'done' |
|---|---|---|
寻物 lost |
焦急寻找中(橙) | 已找到 |
招领 found |
寻找失主中(绿) | 已归还 |
意义:状态流转只有一条规则可判断,不需要为每种叫法各写一个分支;全站对同一条信息的说法永远一致。
(3)「我的」页统计格可点击筛选
「我的」页顶部三个统计格(全部发布 / 进行中 / 已完成)本身就是筛选项,点哪一格,下面的记录就只留这一种状态
(4)广场页「排序 + 时间范围」合并下拉
广场页右上角一个下拉按钮同时管两件事:按什么顺序排(最新 / 最早在前)和只看最近多久(近 7 天 / 30 天 / 半年 / 一年)。
意义:校园失物招领有明显的时效性——三个月前的帖子基本没有参考价值。把时间范围和排序放在一起,用户一个动作就能完成「看最近一周的最新信息」这类常见需求,而不用在两个控件之间来回切。
(5)联系方式的「一键复制」
详情页的联系弹层里,手机号 / 微信号 / QQ 号一律只给「复制」按钮,点完自动弹出「已复制」吐司。
意义:作业是在电脑上用 file:// 打开验收的,复制是跨平台最稳的交互,微信 / QQ 号也天然只能复制。
(6)完整的空状态与异常处理
- 搜索无结果 → 小狗插画 + 「重新搜索 / 发布寻物」两个出口
- 广场时间范围筛空 → 计数条保留,提示「把时间范围放宽再看看」
- 详情页 id 无效 → 「这条信息不存在或已被删除」+ 去广场 / 回首页
- 存储不可用(隐私模式)→ 静默降级为内存存储,功能全可用
意义:不按一条顺利成功的路线设计产品,把「发布不完整、搜索无结果、链接失效」这些真实会发生的分支都考虑到了。
以下为搜索成功、失败的展示:
5.2 实现思路
(1)逻辑与界面分离,同一份代码既跑页面又跑测试
js/core.js 用 UMD 包裹:
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory(); // Node / Mocha
else root.LF = factory(); // 浏览器
})(typeof self !== 'undefined' ? self : this, function () { /* ... */ return LF; });
因此 npm test 测的是页面上正在跑的那一份代码,不存在「测试通过但页面走的是另一套逻辑」的情况。file:// 下 Chrome 会按 CORS 拦截 ES module,所以全站只用传统 <script src>,这也是选 UMD 而非 ESM 的原因。
(2)数据层统一走 LF.store,localStorage 不可用时静默降级
所有页面的数据读写都通过 LF.store,它内部封装了 localStorage 的读写、种子数据灌入、以及不可用时的内存降级。页面代码里不出现任何 localStorage 字样。
(3)卡片渲染统一走 LF.card.renderList
首页、搜索、广场、我的四处共用同一套卡片组件,视觉和交互完全一致,改一处全站生效。
(4)表单逻辑统一走 js/form.js
发布页和编辑页共用同一份表单标记与校验,只有 mode 不同(create / edit),按钮文案和草稿行为由 mode 决定。
(5)页面外壳统一走 js/layout.js
顶栏、底部 TabBar、发布选择弹层、轻提示、一键复制都由 layout.js 统一生成,各页面只写自己的业务逻辑。
5.3 重要的代码片段及解释
(1)状态文案的唯一来源(js/core.js)
var STATUS_TEXT = {
lost: { open: '焦急寻找中', done: '已找到' },
found: { open: '寻找失主中', done: '已归还' }
};
LF.statusText = function (item) {
var kind = item.kind === 'lost' ? 'lost' : 'found';
var state = item.state === 'done' ? 'done' : 'open';
return STATUS_TEXT[kind][state];
};
解释:全站任何地方要显示一条信息的状态,都必须调用 LF.statusText()。首页卡片右下角的 .pill、「我的」页的状态标签、标记完成后的吐司提示,全部走这一个函数。状态文案只在这里定义一次,改文案只需要改这一个地方。
(2)搜索与筛选的单一入口(js/core.js)
LF.search = function (list, opt) {
opt = opt || {};
var kw = LF.trim(opt.keyword).toLowerCase();
var out = list.filter(function (item) {
if (opt.kind && opt.kind !== 'all' && item.kind !== opt.kind) return false;
if (opt.state && opt.state !== 'all' && item.state !== opt.state) return false;
if (opt.category && opt.category !== 'all' && item.category !== opt.category) return false;
if (opt.within && opt.within !== 'all' && !LF.withinRange(item, opt.within)) return false;
if (!kw) return true;
return [item.title, item.place, item.desc, LF.categoryLabel(item)]
.join(' ').toLowerCase().indexOf(kw) >= 0;
});
return LF.sortByTime(out, opt.sort || 'desc');
};
解释:搜索页、广场页、引导浏览页三个页面的筛选逻辑全部走这一个函数。关键词会在标题、地点、描述、类别四个字段里找;类型、状态、类别、时间范围各自独立判断;排序也在同一个函数里完成。页面代码只负责把参数传进来,不自己写筛选逻辑,保证三个页面的筛选行为完全一致。
(3)编辑权限的唯一判断(js/core.js)
LF.canEdit = function (item) {
return !!item && LF.isMine(item);
};
LF.isMine = function (item, user) {
var me = user || LF.CURRENT_USER;
return !!item && !!item.author && item.author.name === me.name;
};
解释:edit.html 进入时先调 LF.canEdit(item),不是本人发布的一律拒绝并给出说法;LF.store.update() 内部再调一次同样的判断兜底。权限规则只定义一次,页面层和存储层共用。
(4)localStorage 不可用时的静默降级(js/store.js)
var backend = (function () {
try {
if (typeof localStorage === 'undefined' || !localStorage) return null;
var probe = '__shiguang_probe__';
localStorage.setItem(probe, '1');
localStorage.removeItem(probe);
return localStorage;
} catch (e) {
return null;
}
})();
var persistent = backend !== null;
解释:隐私模式或禁用 localStorage 时,LF.store.persistent 为 false,内部自动改用内存对象存储。所有功能照常可用,只是关掉页面数据就没了。首页会在控制台打一句警告,但不影响用户操作。
(5)区分发布和编辑的数据处理逻辑(js/core.js)
function applyEdit(item, draft, at) {
if (!item) return item;
var when = at || new Date().toISOString();
var next = {};
// 复制原有信息,避免直接修改原对象
for (var k in item) {
if (Object.prototype.hasOwnProperty.call(item, k)) next[k] = item[k];
}
var d = draft || {};
var contact = d.contact || {};
// 只更新表单中允许修改的字段
next.kind = d.kind === 'found' ? 'found' : 'lost';
next.title = trim(d.title);
next.category = d.category;
next.place = trim(d.place);
next.happenedAt = trim(d.happenedAt);
next.desc = trim(d.desc);
next.contact = { type: contact.type, value: trim(contact.value) };
next.updatedAt = when;
next.timeline = (item.timeline || []).concat([{
state: item.state, at: when, action: 'edit'
}]);
return next;
}
解释:发布和编辑不能共用同一套归一化逻辑。发布要生成新 ID、发布时间、发布者、初始状态 open;编辑必须保留 id、createdAt、author、images、state,只改表单里允许改的字段。若直接复用发布时的 normalizeDraft(),会覆盖原有数据、甚至把已完成的信息打回进行中。函数先浅拷贝原对象、再更新白名单字段、最后返回新对象,不直接改传入的数据;timeline 顺手记一笔,方便后续区分「编辑」和「状态变更」。
一句话总结:附加特点的核心思路是把真实使用场景里的摩擦点一个个抠掉——先找后发、状态收敛、统计即筛选、排序与时间合一、联系方式一键复制、空状态有出口。这些设计不增加新模块,但让「发布—浏览—详情—联系—更新」这条主流程在真实手机使用中更顺、更少卡壳。
六、目录与使用说明
6.1 运行(不需要安装任何东西)
下载仓库全部文件后,用谷歌浏览器打开根目录下的 index.html 即可,无需 Node.js、无需本地服务器、无需构建。
双击 index.html ← 或者:右键 → 打开方式 → Google Chrome
建议的体验路径:首页「最新发布」→ 点底部中间的「+」→ 选「发布寻物」→ 填表 → 确认发布 → 回首页看它排在最顶部 → 刷新页面数据还在 → 进「我的」点整行打开详情 → 点「⋯ → 编辑」改一下 → 进「广场」点右上角「最新 ▾」切成「最早发布在前」,再选「近 7 天」看条数变化。
6.2 跑单元测试(可选,这一步需要 Node.js v18+)
npm install # 只装一个 mocha,首次需要联网
npm test # 预期输出:100 passing
6.3 目录结构
102401208-102401133/
├─ index.html 首页 —— 入口文件,助教打开的第一个文件
├─ publish.html 发布页 —— 发布寻物 / 发布招领(读 ?kind= 区分)
├─ publish-done.html 发布成功页 —— 回显刚发布的信息(读 ?id=)
├─ edit.html 编辑页 —— 修改本人发布过的信息(读 ?id=)
├─ search.html 搜索页 —— 关键词 + 类别 + 类型组合筛选
├─ square.html 广场页 —— 浏览全部信息(读 ?kind=)
├─ detail.html 详情页 —— 完整字段 + 发布者联系方式(读 ?id=)
├─ mine.html 我的页 —— 发布统计 + 标记已找到 / 已归还
├─ shared/app.css 全站样式:设计令牌 + 页面外壳 + 卡片 / 表单 / 按钮
├─ js/
│ ├─ core.js ★ 纯逻辑层(UMD):校验、状态、搜索、时间格式化、ID
│ ├─ store.js 数据层:localStorage 读写(不可用时降级为内存)
│ ├─ seed.js 种子数据:9 条示例信息
│ ├─ layout.js 页面外壳:顶栏 / 底部 TabBar / 发布选择弹层 / 轻提示
│ ├─ card.js 信息卡片渲染:首页、搜索、广场、我的四处共用
│ └─ form.js 发布 / 编辑表单:两页共用,只有 mode 不同
├─ assets/
│ ├─ avatar.png 品牌头像
│ └─ items/item-01..09.svg 按物品类别生成的矢量占位图(不依赖外部素材)
├─ test/ 4 个测试文件,共 100 个用例
├─ package.json npm test 配置(唯一依赖:mocha)
└─ README.md 详细说明(与本节内容一致,并多一节「需求实现对照」)
七、单元测试
npm test 一次跑完 test/ 下四个文件、共 100 个用例。分工与覆盖范围:
| 文件 | 用例数 | 覆盖什么 |
|---|---|---|
core.test.js |
34 | 表单校验(validateDraft)、补全成完整信息(normalizeDraft)、状态文案与配色、状态流转(canTransit / markDone)、搜索筛选(search)、escapeHtml、时间格式化、ID 生成、store 增删改查 |
edit.test.js |
29 | 谁可以编辑(canEdit)、按草稿改写(applyEdit)、store.update、时间回填(toLocalInput),以及编辑页与「我的」页的结构自检 |
square.test.js |
20 | 时间范围(withinRange)、排序与筛选叠加、广场页下拉的结构自检、种子数据的时间跨度 |
browse.test.js |
17 | 页面外壳、[hidden] 兜底、搜索页空状态、详情页、我的页、侧滑菜单、空状态插画 |
举两个有代表性的测试:
一是防 XSS 的转义。 物品名称、描述都是用户自由输入,如果直接拼进 innerHTML,一条名字叫 <img onerror=...> 的信息就能打穿页面。所有输出路径都过 LF.escapeHtml,测试里专门用脚本串去戳它。
二是把「不能回归的 UI 细节」钉住。 走查时发现两个肉眼容易漏、改一次就可能复发的问题:完成状态的标签右边漏出一个下拉箭头、编辑页的返回键被两个字挤成两行。修完之后我们都补了测试(用例 ㉘㉙),断言的正是「这个标签不该有箭头」「返回键里不该有第二个字」。理由是:这类问题的正确形态只有一行 CSS 的差别,靠眼睛走查一定会复发,只有写成断言才能防住。
写测试还有个额外收获:因为 core.js 是 UMD 的,测试直接 require 了页面在用的那份代码,所以「测试通过」这句话是可以信的。

八、签入记录
两人共用一个仓库,都用自己的 GitHub 账号提交,通过PR合并。下面是部分pr与commit记录:

(本次作业的提交记录也可以在仓库的 Commits 页面查看:点击此处 )
九、异常与解决
按「现象 → 原因 → 怎么解决」记五个耗时比较久的:
1. 编辑页的时间一保存就被抹掉。
现象:编辑一条已有信息,只要不动时间直接保存,时间就变空了。原因:种子数据里的 happenedAt 是 UTC 的 ISO 字符串,而 <input type="datetime-local"> 只认本地时间格式,两者差 8 小时且格式不同,直接塞进输入框会失败。解决:加了 LF.toLocalInput() 做时区与格式转换,回填和读取都走它,并补了测试。
2. 时间范围筛选会把下拉菜单一起弄没。
现象:选「近 7 天」筛出 0 条时,整条计数栏消失,而排序下拉就在计数栏里,于是用户没法把筛选改回来,进了死胡同。原因:原来写的是 elBar.hidden = !hasHit,没有区分「没有结果」和「工具条本身没有意义」。解决:只要用户设了筛选条件,计数栏就必须留着(哪怕显示 0 条),并补了用例 ⑯ 钉住。
3. 四个时间档位筛出来的条数一模一样。
现象:「近 7 天 / 30 天 / 半年 / 一年」点下去条数一样,看不出筛选到底生效没有。原因:9 条种子数据的时间全是「距今几小时」,都落在最近档里。解决:把其中几条刻意拉开到 13 个月 / 8 个月 / 2 个月 / 20 天前,四档依次筛出 5 / 6 / 7 / 8 条,只有「全部时间」才凑齐 9 条。这也说明测试要盯着数据本身——补了眼界测试(⑲⑳),断言四档条数递增,还断言这几条的文字描述里不能出现「昨天」这种和实际时间对不上的词。
4. 完成状态的标签右边多出一个下拉箭头。
现象:只有「已找到」这个静态标签右边漏出一个 ▾。原因:那个 ▾ 是用 background-image 画出来的,完成态只把内边距收窄了、没有把 background-image 清掉,箭头就留在了本来不该有下拉的标签上。解决:完成态显式 background-image: none。这是典型的「样式是叠加的,改一个属性不等于清掉另一个」。
5. GitHub 直连间歇性失败。
现象:git push 报 RPC failed; curl 55 Send failure、port 443 Could not connect。起初以为是自己的操作错了、反复重来,浪费了不少时间。原因:网络到 GitHub 的链路不稳定。解决:确认本地提交不会丢(这是最要紧的一点,先别慌),再按「重试 → 换网络 → 配代理 → 调 http.version 和 postBuffer」的顺序处理。经验是:网络问题要先把「已经做完的事」和「还没同步的事」分清楚,再去折腾网络。
十、评价队友
队友这次负责整个浏览链路,交付得很扎实。「我的」页那三个统计格,他发现只是显示数字有点浪费,主动改成了可以点的筛选按钮——这个改动作业里没要求,但体验上确实更顺。要说可以更好的地方,是他交给我的页面有几处是「先落地、再补细节」:引导浏览页的第一版只做了骨架,搜索框和类别筛选是后来补的;首页那两个入口一开始也跳错了地方,后面才对齐。好在每一步都有独立的提交,看提交记录就能跟得上他在改什么,我 review 起来不费劲——「改得慢一点但看得见」比「一次改一大坨」对结对来说重要得多。而且队友很有耐心哈哈哈,对我提出的一些意见都很认真地考虑并且落实了,最开始的框架搭得也很牢靠,分工也是队友提出来的,总而言之是很愉快的一次合作!
十一、个人总结
第一次作业我们做的是设计,摆在面前的是画板,改一版成本很低;这次是两个人各写各的页面,同一个字段你叫“马铃薯” 我叫“土豆”,到合并的时候就是一场灾难。所以我们这次开工第一件事是把公共约定先钉死,再继续下一步。事后看这个决定省下了大量的返工——PR合并得都很干净。另外两件小事也记得很清楚:一个是改了内边距却没清background-image;另一个是编辑功能里差点把状态和发布时间一起覆盖掉,所以(ai建议)修完bug就补一个测试把它钉住,最后测试从34个涨到了惊人的100个。。。要说做得不好的地方,我俩事后聊,都认同一点:有些该先做的事做晚了。总的来说,这次最大的收获不是写出了 100 个测试,而是明白了:两个人写代码,难的从来不是代码本身,是让两份代码能对上。而让它们对上的功夫,几乎全在动手之前完成...
浙公网安备 33010602011771号