2026秋软件工程结对作业(第二次之程序实现)
一、作业与结对信息
- 结对成员一:李嘉星(102401128),博客地址:jasonxdd
- 结对成员二:李莹莹(102401104),博客地址:astronomyyy
- 本次作业链接:2026 秋软件工程第二次结对作业
- 本次作业博客文章地址:2026秋软件工程结对作业(第二次之程序实现)
- GitHub 项目地址:102401128-102401104
- 功能 Pull Request:PR #1
- 项目形式:PC Web,同时适配手机尺寸
- 运行方式:使用 Google Chrome 直接打开根目录的
index.html
二、项目简介与范围
“拾见”是我们这次做的校园失物招领网页。想找东西时,翻群聊往往要找很久,所以我们把寻物和招领信息放到一个页面里,让发布、查找、联系和更新状态能接着做下去。

这次先把作业要求的几个主要功能做完整,复杂后台、实名认证、即时聊天和地图定位就没有继续加。考虑到助教需要下载后直接打开网页,我们用 localStorage 保存数据。刷新后记录还在,但换电脑或浏览器就看不到了,这一点也写进了 README。
三、具体分工
分工上,我们选择先后接着做:李嘉星先完成首页、发布和数据接口,李莹莹再 fork 项目,补上搜索、详情和状态更新。最后合并到一起,再检查整个流程。
| 阶段 | 成员 | 工作内容 |
|---|---|---|
| 第一阶段 | 李嘉星(102401128) | 创建仓库和页面结构;设计共享数据模型与本地存储接口;完成首页浏览、类型切换、寻物/招领发布、表单校验、图片预览和响应式基础样式;编写接口约定与队友任务说明。 |
| 第二阶段 | 李莹莹(102401104) | fork 项目;实现关键词搜索、详情页、折叠联系方式、“我的发布”、已找到/已归还状态更新;按功能提交 commit,并通过 PR #1 合并。 |
| 合并与优化 | 两人交叉检查,李嘉星集中修复 | 将搜索改为首页原地筛选;修复返回逻辑、清空搜索、移动端导航和布局;增加组合搜索、只看进行中、认领核对提示、相似线索、非线性动画和桌面/手机适配;完善 README。 |
| 测试与博客 | 两人 | 李嘉星负责核心规则、存储层与图片读取的自动化用例、运行结果、接口说明及问题修复记录,并汇总博客;李莹莹负责第二阶段搜索、详情、联系方式及状态更新模块的实现说明与复核;双方分别负责自己的 PSP 和队友评价。 |
先把接口定下来,第二阶段就可以接着同一份数据开发。合并后,我们再回头检查页面之间的跳转和状态是否一致。
协作提交顺序如下:
- 李嘉星先在主仓库建立页面骨架、字段约定和共享 Store,完成首页与发布;
- 李莹莹从最新 main fork,基于既有接口实现搜索、详情、联系方式、我的发布和状态维护;
- 李莹莹发起 PR #1,李嘉星检查并合并;
- 合并后两人交叉走查,后续修复搜索逻辑、移动端导航、状态动画和测试问题。
四、PSP 表格
开发前我们先完成了时间预估。复盘后,两人的人工投入合计估算为 1300 分钟,各阶段实际耗时为回溯估算分配,并非逐项计时结果。人机协作中的模型运行等待时间不计入,计入的是需求拆解、提示与审阅、界面调整、调试、验收和博客整理等人工工作。
表中的 Planning、Development 和 Reporting 是父阶段小计,计算总计时不再重复累加它们下面的子项。
| PSP 2.1 | Personal Software Process Stages | 李嘉星预估(分钟) | 李嘉星实际(分钟) | 李莹莹预估(分钟) | 李莹莹实际(分钟) |
|---|---|---|---|---|---|
| Planning | 计划 | 30 | 20 | 30 | 20 |
| Estimate | 估计任务时间 | 30 | 20 | 30 | 20 |
| Development | 开发(小计) | 895 | 580 | 905 | 560 |
| Analysis | 需求分析与技术学习 | 60 | 30 | 70 | 35 |
| Design Spec | 生成设计文档 | 45 | 25 | 45 | 30 |
| Design Review | 设计复审 | 30 | 15 | 30 | 20 |
| Coding Standard | 制定代码规范 | 25 | 10 | 25 | 10 |
| Design | 具体设计 | 75 | 60 | 75 | 55 |
| Coding | 具体编码 | 420 | 295 | 420 | 275 |
| Code Review | 代码复审 | 60 | 50 | 60 | 45 |
| Test | 测试、修复和提交 | 180 | 95 | 180 | 90 |
| Reporting | 报告(小计) | 125 | 60 | 125 | 60 |
| Test Report | 测试报告 | 60 | 30 | 60 | 30 |
| Size Measurement | 工作量统计 | 20 | 10 | 20 | 10 |
| Postmortem & Process Improvement Plan | 事后总结与改进计划 | 45 | 20 | 45 | 20 |
| 合计 | 1050 | 660 | 1060 | 640 |
两人的实际投入估算分别比预估少了 390 和 420 分钟,相差 20 分钟,合计 1300 分钟(21 小时 40 分钟)。本次大量使用代码代理辅助实现,缩短了初稿生成和重复编码的时间,但需求仍要自己说清楚,生成的代码也要审阅和调试。后续搜索入口、手机导航和卡片动画的调整,以及边界测试与修复,仍需要人工检查。人工走查已于 2026 年 9 月 30 日完成,测试环境与结果见第 8.4 节。
五、解题思路与设计实现
5.1 从原型到程序的取舍
动手前,我们对照老师的反馈重新看了 HW3 原型。有些页面画出来很完整,真正写代码时还得把操作顺序和异常情况补上,主要调整了下面几点:
- 先完成发布、搜索、详情、联系和状态维护,其他功能往后放;
- 补全“联系后如何结束信息”的流程:只有本浏览器发布的记录可以在“我的发布”中结束;寻物对应“已找到”,招领对应“已归还”;
- 补充异常路径,包括必填项缺失、未来时间、图片格式或大小错误、无搜索结果、无效正则、详情 ID 不存在、复制失败和存储失败;
- 明确原型演示与真实能力的区别,不把静态页面写成具有后台、账号认证或跨设备同步能力的系统。
5.2 总体结构
项目没有使用构建工具和远程依赖。index.html 按固定顺序加载 JavaScript:
core.js → storage.js → image-reader.js → app.js → search.js → detail.js → my-posts.js
core.js保存无界面副作用的数据规则,便于独立测试;storage.js封装localStorage,所有页面通过同一个 store 读写数据;image-reader.js管理图片读取,取消旧任务并忽略过期回调,防止连续选图时预览和保存内容不一致;app.js负责导航、首页、发布、通用卡片和页面注册;pages/detail.js与pages/my-posts.js分别注册详情页和我的发布页;style.css统一管理桌面端、移动端、状态和动画样式。
三个页面都从同一个 Store 取数据。这样在“我的发布”里点了“已找到”,回到首页或者详情时也会看到新状态,不用再分别修改几份数组。
5.3 数据结构与状态规则
每条信息包含以下主要字段:
{
id, ownerId, type, name, location,
date, time, description, contact,
imageData, status, createdAt
}
type 为 lost 或 found。寻物初始状态是“寻找中”,结束状态是“已找到”;招领初始状态是“招领中”,结束状态是“已归还”。首次打开网页时,程序生成浏览器自己的 clientId;新发布记录使用该 ID 作为 ownerId,“我的发布”只查询相同 ownerId 的记录。
这里的 clientId 只是用来区分本浏览器发布的记录,还没有真正的账号系统。换电脑、换浏览器或清除数据后,记录不会同步。
5.4 完整业务流程

搜索、类型筛选和“只看进行中”可以同时生效。用户从筛选结果进入详情后,返回首页仍保留搜索词、类型和状态条件,避免重新操作。
5.5 关键数据流

发布时先调用 validateDraft,再由 createItem 生成规范化记录,最后写入 store。搜索调用 listItems;结束信息调用 finishItem,它会检查记录是否存在、是否属于当前浏览器、是否已经结束。
5.6 关键代码一:表单校验
function validateDraft(draft, now = new Date()) {
const errors = {};
if (!VALID_TYPES.includes(draft.type)) errors.type = "请选择寻物或招领";
if (!text(draft.name)) errors.name = "请填写物品名称";
if (!text(draft.location)) errors.location = "请填写地点";
if (!text(draft.date)) errors.date = "请选择日期";
if (!text(draft.time)) errors.time = "请选择发生时间";
if (!text(draft.contact)) errors.contact = "请填写 QQ 或校内账号";
if (text(draft.date) && text(draft.time)) {
const date = text(draft.date);
const time = text(draft.time);
const happenedAt = new Date(`${date}T${time}:00`);
const [year, month, day] = date.split("-").map(Number);
const [hour, minute] = time.split(":").map(Number);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time) ||
Number.isNaN(happenedAt.getTime()) || happenedAt.getFullYear() !== year ||
happenedAt.getMonth() + 1 !== month || happenedAt.getDate() !== day ||
happenedAt.getHours() !== hour || happenedAt.getMinutes() !== minute) errors.date = "日期或时间无效";
else if (happenedAt.getTime() > now.getTime()) errors.date = "发生时间不能晚于现在";
}
if (text(draft.name).length > 60) errors.name = "物品名称不能超过 60 字";
if (text(draft.location).length > 100) errors.location = "地点不能超过 100 字";
if (text(draft.description).length > 500) errors.description = "补充描述不能超过 500 字";
if (text(draft.contact).length > 100) errors.contact = "联系方式不能超过 100 字";
return errors;
}
我们把校验和页面显示分开,函数只返回哪些字段有问题。日期解析后还要逐项核对年月日和时分,避免 JavaScript 把“2 月 30 日”自动转成 3 月日期后误判为合法。测试时还能传入固定的 now,这样测“未来时间”就不用担心结果随着运行日期变化。
5.7 关键代码二:发布者结束信息
function finishItem(items, id, ownerId) {
const item = getItem(items, id);
if (!item) return { items, error: "信息不存在", item: null };
if (!text(ownerId) || item.ownerId !== ownerId) {
return { items, error: "只能修改自己发布的信息", item: null };
}
if (isFinished(item)) return { items, error: "这条信息已结束", item };
const updated = { ...item, status: FINISHED_STATUS[item.type] };
return {
items: items.map((entry) => entry.id === id ? updated : entry),
error: null,
item: updated,
};
}
结束信息前要先检查三件事:记录在不在、是不是自己发布的、是否已经结束。通过后只替换对应的记录,其他信息保持原样。页面按钮之外再做这层检查,也能避免重复操作或修改别人的记录。
5.8 关键代码三:组合搜索
return {
terms: query
.split(/[\s,,、]+/u)
.filter(Boolean)
.map(normalizeSearchText),
regex: null,
error: null,
};
普通搜索支持空格、英文逗号、中文逗号和顿号分隔多个线索。每个线索可以分别命中名称、描述或地点,但所有线索都必须命中。例如“东三 雨伞”和“东3,雨伞”都能匹配地点为“东3”、名称含“雨伞”的记录。数字归一化还支持“一/二/三”与“1/2/3”的常见写法。
六、附加特点设计与展示
6.1 智能组合搜索与高级正则搜索
搜索时可以把记得的线索一起输进去,比如“东三 雨伞”。程序会拆开关键词,要求每个词都能在名称、描述或地点中找到。“东三”和“东3”也会按同一种写法处理。想同时找雨伞或耳机,可以用 /雨伞|耳机/;正则写错时会显示提示。
正则采用受限语法,支持字面量、字符类、锚点、或条件及简单重复,例如 /^蓝色.*雨伞$/;表达式最多 80 个字符,标志支持 i、m、s、u。为避免搜索卡顿,不支持分组、回溯引用、Unicode 属性类或花括号重复,每个或条件分支最多一个 *、+ 或 ?。错误或不支持的输入会显示提示,并保留上次有效查询结果。
加这个功能主要是考虑到,大家不一定记得物品的完整名称,地点也可能有人写“东三”、有人写“东3”。允许把零碎线索组合起来,查找时就少一些限制。
组合搜索效果见第 12 节:电脑端输入“玫瑰 卡”匹配校园卡;手机端输入“东三 305 耳机”匹配东3-305的无线耳机,展示多个关键词与地点数字归一化共同生效。
第 12 节进一步展示桌面端与手机端的正则搜索:/雨伞|耳机/ 返回雨伞和耳机两类记录,/[/ 触发错误提示并保留上次有效查询结果。正常查询和错误输入都有明确反馈。
6.2 自动发现相似线索
发布成功后,程序自动在“相反类型且仍进行中”的记录中查找候选:寻物只匹配招领,招领只匹配寻物。如果地点完全相同,或物品名称存在至少两个字的共同片段,就在“我的发布”中显示最多三条可能相关的线索和匹配原因。
function similarityReasons(source, candidate) {
if (!source || !candidate || source.id === candidate.id ||
source.type === candidate.type ||
isFinished(source) || isFinished(candidate)) return [];
const reasons = [];
const sourceLocation = normalizeSearchText(source.location).replace(/\s+/g, "");
const candidateLocation = normalizeSearchText(candidate.location).replace(/\s+/g, "");
if (sourceLocation && sourceLocation === candidateLocation) {
reasons.push(`地点相同:${text(source.location)}`);
}
const candidateName = normalizeSearchText(candidate.name)
.replace(/[^\p{L}\p{N}]+/gu, "");
const shared = nameFragments(source.name)
.filter((fragment) => candidateName.includes(fragment))
.sort((a, b) => b.length - a.length || a.localeCompare(b))[0];
if (shared) reasons.push(`名称共同关键词:${shared}`);
return reasons;
}
相似线索不一定就是同一件物品,所以这里只给提示,让用户点进详情后自己核对。以后同一个浏览器里又发布了新信息,再打开“我的发布”也会重新匹配已有记录。
实现效果见第 12.1 节“我的发布:更新前”截图:校园卡下方显示可能相关的招领记录,用户可以从线索卡片进入详情继续核对。
6.3 隐私与认领核对提示
发布招领信息时,页面提醒发布者只公开物品的大致特征,保留一项独特细节,联系后让认领者先描述该细节。联系方式默认折叠,只有用户主动点击才显示;详情页还提供一键复制,并在剪贴板 API 不可用时降级为传统复制或选中文字供手动复制。
如果把物品的所有独特特征都写出来,认领时反而不好核对,所以我们加了这个提醒。联系方式可以主动展开和复制,操作也比较直接。第 12 节放了招领提示和联系方式的截图。
6.4 “只看进行中”与局部动画
“只看进行中”可以和关键词、类型筛选一起用。我们后来把动画范围缩小到要隐藏的卡片,避免开关一切换,整个页面都跟着动。设置了 prefers-reduced-motion 的用户会看到减少动画后的效果。
第 12.1 节展示电脑端开关关闭时的 8 条记录与开启后的 4 条记录;第 12.2 节展示手机端开启后的列表。截图说明状态筛选结果,动画效果可在页面中切换开关观察。
七、目录说明与使用说明
7.1 目录结构
.
├── README.md 项目说明、运行步骤和数据限制
├── index.html Chrome 直接打开的网页入口
├── css/
│ └── style.css 桌面端、移动端和动画样式
├── js/
│ ├── core.js 校验、搜索、状态和相似匹配规则
│ ├── storage.js localStorage 与统一数据接口
│ ├── image-reader.js 最新图片读取与页面生命周期保护
│ ├── app.js 导航、首页、发布和通用卡片
│ └── pages/
│ ├── search.js 早期独立搜索页兼容代码
│ ├── detail.js 详情、联系方式和复制功能
│ └── my-posts.js 我的发布、状态更新和相似线索
├── tests/
│ ├── core.test.js 校验、搜索、状态和相似匹配测试
│ ├── storage.test.js 本地保存、回滚、迁移和损坏数据测试
│ └── image-reader.test.js 连续选图、清空选择及页面退出测试
├── docs/
│ ├── 接口约定.md 第二阶段使用的接口说明
│ └── img/ 项目范围、业务流程及数据流 SVG/PNG
└── prototype/
├── App.tsx HW3 原型源码,仅作设计参考
├── 校园失物招领小程序_核心流程.mmd
├── 校园失物招领小程序_需求分析与试用记录.md
└── 校园失物招领小程序_过程记录.md
7.2 测试人员运行方法
-
从 GitHub 下载 ZIP 或执行:
git clone https://github.com/kasinglee/102401128-102401104.git -
进入项目目录;
-
使用 Google Chrome 直接打开根目录的
index.html; -
运行网页无需安装 Node.js、依赖、数据库或服务器;执行单元测试需要 Node.js;
-
首次打开会显示三条虚构演示数据;新发布数据保存在当前浏览器;
-
清除记录时,按 F12 打开 Chrome 开发者工具,在 Application(应用)→ Local Storage(本地存储)中选择当前页面对应的来源,删除键名
shijian.hw4.v1,再刷新网页。此操作会清除本浏览器新增的记录、图片和状态更新,并恢复三条初始演示数据。

推荐按以下路径验收:发布寻物 → 首页搜索 → 查看详情 → 展开并复制联系方式 → 我的发布 → 标记已找到 → 返回首页确认状态同步。再发布一条招领信息,检查“已归还”和相似线索。
八、单元测试
8.1 测试工具与学习过程
测试部分参考了邹欣老师的《单元测试与回归测试》和 Node.js 的 node:test 文档。写法可以理解为:准备一组输入,调用函数,再用断言检查结果。我们选了 Node.js 自带的 node:test 和 node:assert/strict,不用额外安装测试框架。core.js、storage.js 和 image-reader.js 可以独立测试,图片读取测试通过模拟 FileReader 控制回调先后顺序;运行环境是 Node.js 24.18.0。
简易教程:
- 安装 Node.js,在项目根目录打开终端;
- 在
tests/*.test.js中用require('node:test')和require('node:assert/strict')导入测试工具,再导入目标模块; - 用
test('行为描述', () => { ... })写一个独立用例; - 构造固定输入,例如固定的
now和内存版 storage,调用目标函数; - 用
assert.equal、assert.deepEqual、assert.match检查正确结果、错误分支和数据未被意外修改; - 运行
node --test,本次结果为 30 个通过、0 个失败; - 运行
node --test --experimental-test-coverage查看覆盖率。修复代码后再次执行两条命令,检查原有行为是否退化。
8.2 白盒测试用例设计
白盒测试这部分,我们先看函数里的判断,再给每个分支准备输入。比如必填项为空、时间在未来、不是发布者本人、已经结束,分别应该返回什么错误。长度限制还要测刚好到上限和超过一个字的情况,这里用到了判定覆盖和边界值分析。每个用例单独准备数据,日期固定;存储用内存对象模拟,不会改动浏览器里的真实记录。
| 编号 | 测试目标 | 输入或场景 | 预期结果 | 实际结果 |
|---|---|---|---|---|
| T01–T05 | 表单与创建 | 合法草稿、空白必填、无效日期、未来时间、60/61 字边界、招领状态及缺少编号 | 各分支返回明确结果 | 通过 |
| T06–T08 | 搜索解析 | 空格/逗号组合、中文数字与全角归一化、合法及错误正则 | 命中正确记录或返回可读错误 | 通过 |
| T09–T10 | 列表规则 | 类型/发布者/地点/状态组合筛选,进行中优先排序 | 只返回符合条件的记录且排序正确 | 通过 |
| T11–T13 | 状态维护 | 寻物结束、招领结束、旧状态兼容、记录不存在、越权和重复结束 | 正确更新或拒绝;输入不被原地修改 | 通过 |
| T14–T15 | 相似线索 | 同地点或名称片段匹配,同类型/已结束/自身排除,候选上限 | 只返回符合规则的候选 | 通过 |
| T16–T22 | 本地存储 | 初始化、重开后持久化、发布失败回滚、结束失败回滚、旧数据迁移、订阅取消、无效 JSON | 数据保持一致且失败有明确反馈 | 通过 |
| T23–T24 | 日期与正则边界 | 不存在的日期、非闰年闰日、越界时间、危险重复正则及常用正则 | 拒绝非法输入,保留正常匹配能力 | 通过 |
| T25–T27 | 结构损坏数据 | null 条目、缺字段、异常状态、重复编号、空身份及 reload 迁移 | 过滤坏条目,保留有效记录与发布者,异常数据不导致白屏 | 通过 |
| T28–T30 | 图片读取竞态 | A/B 连续选择、旧回调晚到、清空选择、页面退出及读取失败 | 只使用最新有效结果,旧任务取消且不能覆盖新图片 | 通过 |
8.3 部分测试代码
以下片段来自实际运行的 tests/core.test.js(T11):
test('T11 正确发布者结束寻物,输入数组不被原地修改', () => {
const records = [item()];
const result = core.finishItem(records, 'lost-1', 'owner-a');
assert.equal(result.error, null);
assert.equal(result.item.status, '已找到');
assert.equal(records[0].status, '寻找中');
assert.notEqual(result.items, records);
});
这个测试不仅检查结果状态,也检查输入数组没有被原地修改。越权、重复结束和不存在 ID 则覆盖同一函数的其他判断分支。
8.4 如何考虑测试人员的“刁难”
自动化用例覆盖了纯空格、中英文地点数字混用、错误与危险正则、长度边界、未来及不存在的日期、重复结束、越权修改、模拟存储空间不足、结构损坏数据与图片读取竞态。运行结果为 30 个通过,0 个失败;core.js、storage.js 和 image-reader.js 合计行覆盖率 98.93%、分支覆盖率 87.05%、函数覆盖率 96.97%。
这些测试覆盖了核心规则、存储接口和图片读取控制器的主要判断分支,但覆盖率不包含页面 DOM 与 CSS,也不代表所有浏览器交互都已验证。布局、真实文件选择和剪贴板权限还需要结合人工走查评估。
自动化测试之外,我们于 2026 年 9 月 30 日在电脑端 Google Chrome 中进行了人工走查,检查了两种 PC 视口尺寸,并通过开发者工具模拟 iPhone 16 Pro Max 的移动端视口。所检查的页面布局与操作未发现异常;移动端结果来自浏览器模拟,尚未在真实手机上验证。
自动化测试与覆盖率结果
在项目根目录运行 node --test --experimental-test-coverage,30 个测试通过,没有失败。通过率看的是现有测试有没有出错,覆盖率则帮我们找还没测到的地方。
tests 30
pass 30
fail 0
文件 行覆盖率 分支覆盖率 函数覆盖率
core.js 98.58% 86.76% 96.97%
image-reader.js 100.00% 90.48% 90.00%
storage.js 99.22% 86.57% 100.00%
合计 98.93% 87.05% 96.97%
九、GitHub 提交与协作记录
项目按功能拆分 commit,例如:
feat: define shared item rules and browser storefeat: add offline home and publishing flowfeat: add homepage search entry and keyword search pagefeat: build item detail page with folded contactfeat: add my posts status flowfeat: support combined clues and regex searchfeat: add active posts only filterfeat: automatically suggest similar opposite postsstyle: polish surfaces and add eased motionfix: guard regex search, stored records, dates and image reads
李莹莹在 fork 中完成第二阶段后提交 PR #1,李嘉星检查并合并;合并以后再按实际问题逐项修复。提交信息使用 feat、fix、style、docs、refactor 等前缀说明改动目的。
按功能提交的代码记录
下面的记录能看到两人各自的提交,以及 PR #1 的合并。搜索、详情、状态筛选和动画分别提交,后面发现的问题也单独修复,回头找某次改动比较方便。

通过 Pull Request 合并第二阶段功能
这是李莹莹提交并已合并的第二阶段 PR,包含搜索、详情和“我的发布”等功能,合并记录可以在上一张截图中看到。

十、遇到的问题、尝试与解决方法
10.1 搜索页面的空间和返回逻辑不自然
- 问题:早期版本点击搜索后进入单独页面,视觉上像突然进入发布页;返回箭头位置也与正文宽度不一致。
- 尝试:先补充返回按钮和搜索页面说明,再结合实际操作重新评估信息层级。
- 解决:将搜索框移动到首页,在当前列表上直接筛选;从结果进入详情后返回时保留筛选条件。
- 收获:单看搜索页没什么问题,连着首页和详情一起用才发现别扭。以后检查页面时要顺着操作流程走,不能只看每页能不能打开。
10.2 手机导航和内容密度问题
- 问题:移动端导航曾出现在页面顶部或随内容滚动,发布表单与信息流也显得过密。
- 尝试:调整 header、底部导航、页面安全间距、卡片间距和表单字段间距,并在多个手机宽度截图下观察。
- 解决:手机端品牌居中,导航固定在视口底部;正文预留底部空间;首页、发布和我的页面分别调整密度。
- 收获:电脑上看着合适的间距,到手机上可能就很挤。底部导航、按钮大小和表单间距都得单独看。
10.3 “只看进行中”导致整个页面跳动
- 问题:初版切换筛选时重新渲染整个页面,用户感觉所有内容都在动。
- 尝试:缩短动画时间仍然没有解决空间跳变。
- 解决:只给即将隐藏的已结束卡片添加消失状态,动画结束后移除这些卡片;关闭筛选时只补回缺失卡片。
- 收获:这次只让结束的卡片动就够了,整页都动反而让人找不到刚才看的位置。
10.4 静态网页的数据共享限制
- 问题:作业要求下载文件后直接打开,项目没有服务器,因此无法实现真实校园用户之间的数据同步和登录权限。
- 尝试:分析是否加入后端,但这会增加部署和测试环境要求,也超出本次核心范围。
- 解决:使用 localStorage 演示完整流程,通过浏览器生成的 clientId 判断“本浏览器发布”;在 README 和页面中明确限制。
- 收获:直接打开 HTML 很方便,但也带来了数据只能保存在本地的限制。使用说明里要把这一点写清楚,否则别人换个浏览器就会以为信息丢了。
10.5 边界检查暴露的四类问题
- 问题:复杂正则可能导致搜索卡顿,合法 JSON 中的损坏条目可能使页面初始化报错,JavaScript 会自动归一化不存在的日期,连续选择图片时旧读取结果也可能覆盖新图片。
- 尝试:构造危险重复正则、含 null 的记录、2 月 30 日与 A/B 图片回调乱序,检查原有测试未覆盖的路径。
- 解决:限制正则语法;初始化与重新加载时过滤坏条目;解析日期后核对年月日与时分;切换图片或退出发布页时取消旧读取,并忽略过期回调。新增 T23–T30 八个回归用例,全部通过。
- 收获:常规流程走通后还要检查边界输入和异步时序。修复后重新运行全套测试,能及时发现对已有功能的影响。
十一、队友评价
李嘉星对李莹莹的评价
值得学习的地方:她负责的搜索、详情和“我的发布”接在我写的基础接口上,还通过 fork 和 PR 完成了合并。几个页面都沿用了同一个 Store,后面联调时不需要再整合两套数据,这一点让我省了不少事。
可以改进的地方:合并后,搜索入口和返回按钮这些地方还需要调整。以后做完一个页面,可以多顺着用户的操作点几步,比如从搜索结果进详情,再返回首页,看看条件还在不在。空列表、无结果和重复操作也可以早点一起检查。
李莹莹对李嘉星的评价
值得学习的地方:他前面把数据接口和页面结构整理好了,我接着做功能时有东西可以参考。后面他也一直在改搜索、手机布局和动画这些细节,README 和测试也补得比较完整,方便别人下载后运行和检查。
可以改进的地方:有些交互是在代码写完后才重新确定的,比如搜索最后改成了首页筛选,布局也改过几轮。下次可以先把页面跳转和手机端布局商量清楚再分工。用代码代理帮忙时,要求也最好一次写具体一点,少一些生成以后再来回改的情况。
十二、成果展示与总结
下面是完成后的页面。发布、搜索、查看详情、获取联系方式和更新状态都已经接起来,另外加了相似线索、认领提示和状态筛选。电脑和手机尺寸都做了调整,下载后用 Chrome 打开就能运行。
12.1 电脑端
首页浏览
首页以双列卡片集中展示寻物和招领信息,物品名称、地点、时间及状态可以直接查看;搜索框和类型、状态筛选放在列表上方。

组合搜索:地点与物品名称一起匹配
输入“玫瑰 卡”后,地点和物品关键词同时参与匹配,首页留下两条相关校园卡记录。搜索直接作用于当前列表,用户可以继续切换类型或状态,也可以一键清除搜索。

高级正则:同时查找雨伞或耳机
输入 /雨伞|耳机/,其中 | 表示“或”,列表同时返回黑色无线耳机与蓝色折叠雨伞。与普通关键词组合的 AND 匹配不同,正则可以表达多个物品任选其一的查询条件。

错误正则:明确提示并保留上次有效结果
输入 /[/ 时,方括号没有闭合,搜索框下方显示“正则表达式无效,请检查括号、转义或标志”。无效输入不会作为新的搜索条件执行,列表与搜索摘要仍保留上一次有效查询的结果,用户可以修改表达式后重试。

“只看进行中”:筛选前后对比
关闭开关时共有 8 条记录,已找到或已归还的信息也保留供查看;开启后只显示 4 条进行中记录,减少无效联系。下方两张截图依次展示关闭和开启的结果;静态截图展示筛选结果,卡片消失动画需实际操作观察。


发布寻物
选择“我在寻物”,填写名称、地点、发生时间和联系方式;描述与图片可选。桌面端表单限制宽度并居中展示,避免输入框过长。

发布招领与认领核对提示
切换到“我来招领”后出现认领核对提示,提醒发布者保留一项独特特征,联系后再核对,减少冒领风险。

详情与联系方式
详情页集中展示物品状态和具体信息。联系方式可展开、收起,并提供复制按钮;返回按钮方便用户回到原来的列表。

我的发布:更新前
“我的发布”区分进行中与已结束记录。进行中的校园卡提供“标记已找到”按钮,并展示一条可能相关的招领线索。

我的发布:更新后
点击“标记已找到”后,校园卡状态改为“已找到”,进行中数量减少,重复结束按钮消失,并出现状态更新提示。

12.2 手机端
手机上把卡片改成单列,Logo 放在顶部中间,“首页 / 发布 / 我的”放到底部。下面按操作顺序放了截图。
| 首页浏览 | 发布寻物 |
|---|---|
| 单列卡片展示物品信息,搜索和筛选位于列表上方。 | 表单适配窄屏,字段分组清楚,底部导航便于切换页面。 |
![]() |
![]() |
| 发布招领 | 查看详情 |
|---|---|
| 选择招领后展示认领核对提示,提醒保留独特特征。 | 展示上传图片和物品信息,展开联系方式后可联系发布者。 |
![]() |
![]() |
| 状态更新前 | 状态更新后 |
|---|---|
| 进行中的记录提供“标记已找到”按钮,此时共有两条进行中记录。 | 课本变为“已找到”,进行中数量降为一条,并显示成功提示。 |
![]() |
![]() |
| 组合搜索:东三 305 耳机 | 只看进行中 |
|---|---|
| 多个线索同时匹配,“东三”与“东3”等价,结果只留下东3-305的无线耳机。 | 开启状态筛选后,只展示寻找中与招领中的记录,已结束信息被隐藏。 |
![]() |
![]() |
| 高级正则搜索 | 错误正则提示 |
|---|---|
| 使用正则表达式匹配“雨伞或耳机”,结果显示两条记录。 | 输入 /[/ 后显示红色错误提示,并保留上次有效搜索的结果。 |
![]() |
![]() |
这次做下来,花时间的不只是把功能写出来,还有很多小地方要改:搜索完怎么返回、清空输入后列表要不要马上恢复、手机底部导航会不会挡住内容、切换状态时哪些卡片该动。这些在原型里不太明显,真正点起来才会注意到。下次我们会早点把完整流程走一遍,少留一些问题到合并后再改。如果继续做这个项目,下一步会考虑后端和账号,让不同用户能够看到同一份失物信息。











浙公网安备 33010602011771号