第二次结对作业
从一条线索到一次找回:校园失物招领项目“拾光”的设计与实现
本次博客作者:[102401601] 李佳雨 结对同学:[102401502]王钰涵
结对同学的博客链接:https://www.cnblogs.com/Hyalin/p/23244271
本作业博客链接:https://www.cnblogs.com/Lrain310/p/23244197
我们队创建的仓库 GitHub 地址:https://github.com/Lrainnn310/102401601-102401502
仓库代码签入记录页面:https://github.com/Lrainnn310/102401601-102401502/commits/main/
一、分工与PSP
分工表
| 成员 | 模块与任务 | 验收重点 |
|---|---|---|
| A:李佳雨 | 项目初始化;首页与信息卡片;页面高度修复;发布模块;关键词搜索;详情与联系方式;我的发布与状态更新;类别和地点组合筛选;README 完善。 | 对照对应提交检查功能和代码:1b6419f、c8f7019、23be8a0、32f3477、a1c5a45、a3c3be3、adbe0d0、867176b、73cfe65。 |
| B:王钰涵 | 通过 Fork 分支提交并参与六次 PR:主页交互、发布、搜索、详情与联系方式、我的发布与状态管理、主页筛选及交互优化。 | 对照 PR #1–#6 及合并后的代码检查相关功能和回归情况。对应提交分别以 2b45609、5d7eaf8、eea8809、5110f4d、4890cd8、0e63136 开头。 |
| 共同 | 以课程需求和第一阶段 Figma 原型为项目依据,通过 Fork、功能分支和 PR 合并到 main 的方式协作,并进行交付检查。 |
仓库记录显示六次 PR 已合并;项目记录的自动化测试为 132 项。。 |
PSP表
时间单位:分钟。
| PSP | 阶段 / 本项目任务 | 建议预估(分钟) | A 用时(分钟) | B 用时(分钟) |
|---|---|---|---|---|
| Planning | 计划:明确课程目标、阶段范围与交付项 | 30 | 25 | 17.5 |
| Estimate | 估算:拆分页面、业务逻辑、测试和文档任务 | 22.5 | 17.5 | 15 |
| Development | 开发(小计) | 502.5 | 412.5 | 407.5 |
| Analysis | 需求分析与技术学习:整理功能、状态规则及本地存储限制 | 45 | 37.5 | 27.5 |
| Design Spec | 设计文档:页面结构、数据字段、状态定义和流程说明 | 40 | 32.5 | 27.5 |
| Design Review | 设计复核:对照原型检查页面流程与交互 | 22.5 | 17.5 | 20 |
| Coding Standard | 代码规范:检查原生代码组织、安全渲染及命名一致性 | 15 | 10 | 7.5 |
| Design | 具体设计:手机风格布局、卡片、筛选和弹窗样式 | 60 | 50 | 45 |
| Coding | 编码:首页、发布、搜索、详情、我的发布及组合筛选 | 180 | 155 | 145 |
| Code Review | 代码复审与集成:检查分支改动、合并 PR 并处理集成问题 | 50 | 40 | 55 |
| Test | 测试与修改:自动化测试、语法检查及问题修复 | 90 | 70 | 80 |
| Reporting | 报告(小计) | 90 | 65 | 57.5 |
| Test Report | 测试报告及 README 测试说明 | 45 | 32.5 | 30 |
| Size Measurement | 工作量统计:整理测试项、PR 和交付文件等客观数量 | 15 | 10 | 7.5 |
| Postmortem & Process Improvement Plan | 总结与改进:归纳已记录问题、限制和后续验证项 | 30 | 22.5 | 20 |
| 合计 | 明细之和(阶段小计不重复计入) | 645 | 520 | 497.5 |
二、功能介绍
首页浏览与组合筛选
首页展示寻物、招领和按最新发布的信息卡片。卡片包含物品名称、类别图标、地点、发布时间、信息类型和状态。用户可以通过顶部栏目切换信息类型,并继续按类别、地点进行筛选。
类别选项与发布表单一致:校园卡、水杯、钥匙、雨伞、电子设备、书籍文具、其他。地点筛选支持部分匹配、首尾空白清理和英文字母大小写归一。类别、地点与寻物/招领条件可以组合使用,结果按发布时间倒序;没有匹配记录时显示空状态与清除筛选入口。
关键词搜索
搜索支持物品名称、类别、地点和描述,不搜索联系方式、ownerId 等隐私或内部字段。用户可以输入关键词、按 Enter 搜索,或选择“水杯”“钥匙”“笔记本”快捷关键词。无结果时可以重新搜索或转到发布寻物。
发布寻物与招领
发布表单包含物品名称、类别、丢失/拾取地点、丢失/拾取时间、物品描述和联系方式。表单会检查必填、空白、字符长度、类别、日期有效性和是否为未来时间。校验失败时显示中文错误;保存成功后显示物品名称和初始状态,并提供查看详情、继续发布、返回首页的入口。
详情、联系方式与我的发布
首页、搜索结果和发布成功页面均通过唯一物品 ID 打开详情。详情显示类型、状态、类别、地点、事件时间、描述和发布时间。联系方式在详情弹窗中展示;若浏览器不允许剪贴板操作,可以手动选择只读文本复制。
“我的发布”仅展示当前浏览器本地发布者标识对应的记录,并提供全部、进行中、已完成筛选。首页内置演示数据不属于本地用户发布记录,也不能更新状态。
状态更新只能从本人本地记录的详情发起,且需要二次确认:
| 信息类型 | 初始状态 | 完成状态 | 转换 |
|---|---|---|---|
| 寻物(lost) | 寻找中(searching) | 已找到(recovered) | searching → recovered |
| 招领(found) | 待认领(pending) | 已归还(returned) | pending → returned |
状态转换是单向操作。取消确认不更改数据;完成后保留记录和发布时间,不提供恢复为进行中的操作。
三、技术栈与目录结构
技术栈
- HTML5、CSS3、原生 JavaScript;不使用前端框架、打包器或联网运行依赖。
- 浏览器 localStorage 保存用户发布记录和本地发布者标识。
- 普通 script 按逻辑、存储、应用的依赖顺序加载,保持直接通过 file:// 打开入口页的方式。
- Node.js 内置 node:test、node:assert/strict 和 vm 用于开发阶段自动化测试;Node.js 不是网页运行依赖。
目录结构
.
├── index.html
├── css/
│ └── style.css
├── js/
│ ├── app.js
│ ├── logic.js
│ └── storage.js
├── tests/
│ ├── publishing.test.js
│ ├── search.test.js
│ ├── detail.test.js
│ ├── status.test.js
│ ├── home-filters.test.js
│ └── helpers/
│ └── app-fixture.js
├── docs/
│ ├── blog-materials.md
│ └── blog-final-draft.md
├── .gitignore
└── README.md
index.html 是页面入口和视图结构;css/style.css 负责响应式手机风格布局、卡片、表单、导航和弹窗;js/app.js 负责演示数据、页面导航、DOM 渲染和事件;js/logic.js 放发布校验、搜索、筛选、ID 查询和状态规则;js/storage.js 负责 localStorage 读取、发布记录保存和状态持久化。tests 中按功能划分测试文件,app-fixture.js 提供轻量浏览器环境模拟。
运行方式
网页无需安装依赖、启动服务器或配置数据库。下载仓库后,使用 Google Chrome 直接打开项目根目录的 index.html 即可运行。README 中提供的测试命令仅供开发验证,不是网页启动步骤。
四、系统设计与数据流
模块数据流图
初始化时,app.js 将 js 内的演示记录与 storage.js 读取到的本地记录合并在内存数据中。页面展示和用户操作由 app.js 协调,复杂筛选及状态规则则交给 logic.js。新发布记录在校验成功后写入本地存储,再更新当前页面的数据;状态写入成功后,应用重新渲染首页、搜索结果、“我的发布”和详情。
localStorage 的选择与边界
此项目没有服务器和数据库,因此使用浏览器 localStorage 保存本地发布信息。发布记录键为 shiguang.items.v1,本地发布者标识键为 shiguang.owner.v1。正常情况下,刷新同一浏览器页面后数据仍可读取。
演示记录保存在 js/app.js,不写入用户发布存储,也不进入“我的发布”。本地发布者标识只用于区分当前浏览器的数据,不等同于登录或实名认证。数据不支持跨浏览器、跨设备同步;清理浏览器数据后不会自动恢复。由于 file:// 的存储策略受浏览器环境影响,存储不可用时页面应提示,而不是伪称发布成功。
五、核心业务流程
以下五张 Mermaid 图对应发布、搜索与组合筛选、状态更新和本地存储关键分支;首图概览系统业务。上一节另列模块数据流图。
1. 系统整体业务流程
2. 发布寻物 / 招领
3. 关键词搜索与组合筛选
4. 物品状态更新
5. localStorage 数据读写
六、关键代码分析
以下七段代码分别对应当前发布校验、创建与保存、状态持久化、搜索、筛选、联系方式复制和测试。函数名称与文件路径来自项目实际实现;说明只涵盖代码已经提供的行为。
1. 发布表单校验
文件与函数:js/logic.js,validate(values, now)
fields.forEach(([key, label, maxLength]) => {
const value = typeof values[key] === "string" ? values[key].trim() : "";
if (!value) errors[key] = `请填写${label}`;
else if (maxLength && value.length > maxLength) errors[key] = `${label}不能超过${maxLength}个字符`;
});
if (!errors.eventAt) {
const eventTime = parseEventTime(values.eventAt);
const timeLabel = values.type === "found" ? "拾取时间" : "丢失时间";
if (!eventTime) errors.eventAt = `请选择有效的${timeLabel}`;
else if (eventTime.getTime() > now) errors.eventAt = `${timeLabel}不能晚于当前时间`;
}
if (values.category && !categories.includes(values.category)) errors.category = "请选择有效的物品类别";
if (values.type !== "lost" && values.type !== "found") errors.type = "请选择发布寻物或发布招领";
return { valid: Object.keys(errors).length === 0, errors };
输入是表单值和可选当前时间,输出为 valid 标志及逐字段错误。函数对文本去首尾空格,校验必填、长度、类别、类型和时间;parseEventTime 会核对年月日时分,避免 JavaScript Date 自动滚动非法日期。规则独立于 DOM,方便 Node 单测。
2. 唯一 ID、创建记录与持久化调用
文件与函数:js/app.js,handlePublish(event)
ownerId = window.ShiguangStorage.getOwnerId();
let id = window.ShiguangStorage.createId("item");
const existingIds = new Set(allItems.map((item) => item.id));
for (let attempts = 0; existingIds.has(id) && attempts < 5; attempts += 1) id = window.ShiguangStorage.createId("item");
if (existingIds.has(id)) {
showSaveError("duplicate");
return;
}
item = window.ShiguangLogic.createItem(values, ownerId, id, new Date().toISOString());
const result = window.ShiguangStorage.saveItem(item);
if (!result.ok) {
showSaveError(result.reason);
return;
}
输入是已校验的表单值;ID 通过 storage.createId 创建并对内存记录做有限次数冲突重试,再由 logic.createItem 统一数据字段和初始状态,最终交给 saveItem 持久化。写入失败会进入错误反馈分支,不跳转发布成功页。
3. 状态更新的本地持久化
文件与函数:js/storage.js,updateItemStatus(id, nextStatus)
function updateItemStatus(id, nextStatus) {
if (typeof id !== "string" || !id.trim()) return { ok: false, reason: "invalid-id" };
const loaded = loadItems();
if (!loaded.ok) return { ok: false, reason: loaded.reason };
const matches = loaded.items.filter((item) => item.id === id);
if (matches.length !== 1) return { ok: false, reason: matches.length ? "ambiguous" : "not-found" };
const item = matches[0];
const ownerId = loadOwnerId();
if (!ownerId || item.ownerId !== ownerId || item.ownerId === "demo") return { ok: false, reason: "not-owned" };
const transitions = {
lost: { from: "searching", to: "recovered" },
found: { from: "pending", to: "returned" }
};
const transition = transitions[item.type];
if (!transition) return { ok: false, reason: "invalid-type" };
if (nextStatus !== transition.to) return { ok: false, reason: "invalid-transition" };
if (item.status !== transition.from) {
return { ok: false, reason: ["recovered", "returned"].includes(item.status) ? "already-completed" : "invalid-status" };
}
const updatedItem = { ...item, status: transition.to };
const updatedItems = loaded.items.map((entry) => entry.id === id ? updatedItem : entry);
try {
global.localStorage.setItem(ITEMS_KEY, JSON.stringify(updatedItems));
return { ok: true, item: updatedItem };
} catch (error) {
return { ok: false, reason: error.name === "QuotaExceededError" ? "quota" : "unavailable" };
}
}
函数按 ID 找到唯一记录,检查当前浏览器 owner、记录类型、目标状态和允许的单向转换;成功时复制对象,只更新目标记录状态。存储异常以失败结果返回,不更改页面上的成功状态。
4. 关键词搜索
文件与函数:js/logic.js,searchItems(items, keyword)
function searchItems(items, keyword) {
if (!Array.isArray(items) || typeof keyword !== "string") return [];
if (keyword.trim().length > searchKeywordLimit) return [];
const normalizedKeyword = normalizeSearchText(keyword);
if (!normalizedKeyword) return [];
const searchableFields = ["name", "category", "location", "description"];
return items.filter((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
if (typeof item.id !== "string" || !item.id.trim()) return false;
return searchableFields.some((field) => typeof item[field] === "string" &&
normalizeSearchText(item[field]).includes(normalizedKeyword));
}).sort((left, right) => {
const leftTime = typeof left.publishedAt === "string" ? Date.parse(left.publishedAt) : Number.NEGATIVE_INFINITY;
const rightTime = typeof right.publishedAt === "string" ? Date.parse(right.publishedAt) : Number.NEGATIVE_INFINITY;
const safeLeftTime = Number.isFinite(leftTime) ? leftTime : Number.NEGATIVE_INFINITY;
const safeRightTime = Number.isFinite(rightTime) ? rightTime : Number.NEGATIVE_INFINITY;
return safeRightTime === safeLeftTime ? 0 : safeRightTime - safeLeftTime;
});
}
函数只匹配名称、类别、地点和描述,避免把联系方式与内部字段变成搜索条件。空关键词、异常记录和超长关键词安全返回空结果;通过 filter 的副本排序,不修改传入数组。
5. 类别与地点组合筛选
文件与函数:js/logic.js,filterItems(items, options)
if (type !== "latest" && item.type !== type) return false;
if (category && item.category !== category) return false;
if (location) {
const itemLocation = typeof item.location === "string"
? item.location.trim().replace(/\s+/g, " ").toLowerCase()
: "";
if (!itemLocation.includes(location)) return false;
}
return true;
输入条件来自首页类型标签、类别下拉框和地点关键词框。类型和类别按相应值匹配,地点做规范化后的部分匹配,形成 AND 组合;输出另按发布时间倒序。地点清除不会自动清除类别,空结果区域则可提供一键清除细化条件的操作。
6. 联系方式复制
文件与函数:js/app.js,copyContact()
async function copyContact() {
const contact = contactValue.value;
if (!contactDialog.open || !contact || isCopying) return;
const session = contactSession;
isCopying = true;
// 禁用当前焦点按钮前先移动到只读文本,等待权限确认时仍可用键盘操作。
if (document.activeElement === copyButton) contactValue.focus();
copyButton.disabled = true;
copyButton.setAttribute("aria-busy", "true");
copyButton.textContent = "正在复制…";
copyFeedback.textContent = "";
try {
const clipboard = window.navigator.clipboard;
if (!clipboard || typeof clipboard.writeText !== "function") throw new Error("clipboard-unavailable");
await clipboard.writeText(contact);
if (session === contactSession && contactDialog.open) copyFeedback.textContent = "复制成功";
} catch (_) {
if (session === contactSession && contactDialog.open) {
selectContactText();
copyFeedback.textContent = `自动复制失败或浏览器不支持。${copyFeedback.textContent}`;
}
} finally {
if (session === contactSession && contactDialog.open) {
isCopying = false;
copyButton.disabled = !contactValue.value;
copyButton.setAttribute("aria-busy", "false");
copyButton.textContent = "一键复制";
}
}
}
复制内容来自当前详情弹窗的只读文本。仅当 Clipboard API Promise 成功后显示“复制成功”;失败时选中文本并给出手动复制提示。会话标记避免旧复制请求影响后来打开的另一个物品弹窗。
7. 搜索排序与输入不变的测试
文件与测试:tests/search.test.js,“多条结果按发布时间倒序且不改变输入数组”
test("多条结果按发布时间倒序且不改变输入数组", () => {
const records = [
item({ id: "old", name: "杯子旧记录", publishedAt: "2026-10-01T10:00:00Z" }),
item({ id: "new", name: "杯子新记录", publishedAt: "2026-10-09T10:00:00Z" })
];
const before = JSON.stringify(records);
const results = searchItems(records, "杯子");
assert.deepEqual(results.map((record) => record.id), ["new", "old"]);
assert.equal(JSON.stringify(records), before);
assert.notEqual(results, records);
});
用例检查新记录排在前面,同时原数组内容不变且结果数组独立。它验证了输出排序和纯函数不修改输入的约束。
附加特点设计与展示
相关代码见上文
1. 类别与地点组合筛选
- 设计特点与意义:首页支持同时按信息类型、物品类别和地点筛选。例如,选择“招领”、类别“钥匙”,并输入“食堂”,即可缩小查找范围。地点支持部分匹配,不要求用户输入完整地点,也不依赖地图定位或外部服务。这种组合筛选适合校园场景中地点描述不完全统一的情况。
- 实现思路:
js/logic.js中的filterItems(items, options)依次检查信息类型、类别和地点条件,只有同时满足条件的记录才会显示。地点匹配会忽略首尾空格、统一英文大小写;筛选结果按发布时间倒序排列,筛选不会修改原始数据。
2. 联系方式一键复制
- 设计特点与意义:用户可以在物品详情中查看并复制该条信息对应的联系方式。联系方式不参与关键词搜索,也不显示在首页或搜索结果卡片中,减少列表中不必要的个人信息暴露。
- 实现思路:
js/app.js中的copyContact()优先调用浏览器 Clipboard API。复制完成后才显示成功提示;若浏览器不支持剪贴板或复制失败,则提供手动选择文本的方式。处理异步复制时还会检查当前弹窗会话,避免旧请求影响后来打开的物品详情。
3. 键盘与无障碍交互
- 设计特点与意义:项目在搜索、卡片操作和弹窗交互中保留键盘操作支持,并使用无障碍属性和焦点管理,减少只能依赖鼠标操作的情况。
- 实现思路:页面交互由原生按钮和表单控件承载;弹窗打开和关闭时管理焦点,状态确认弹窗支持键盘关闭与焦点循环。具体可操作范围应以实际代码和 Chrome 人工检查为准。
- 实现效果:自动化测试覆盖了部分键盘和焦点行为;完整键盘导航、读屏播报和不同视口下的实际交互仍需人工验证。
七、测试设计与结果
测试工具与运行命令
测试使用 Node.js 内置 node:test 与 node:assert/strict。测试辅助文件通过 vm 加载项目实际脚本,并模拟必要的 DOM、时间、事件及 localStorage。它能检查业务分支和交互状态,但不代替真实 Chrome 的布局、权限和辅助技术验证。
在仓库根目录打开 PowerShell,运行全部测试:
$testFiles = Get-ChildItem tests -Filter '*.test.js' | Sort-Object Name
foreach ($testFile in $testFiles) {
node $testFile.FullName
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
检查 JavaScript 语法并检查已跟踪差异的空白:
Get-ChildItem js,tests -Recurse -Filter '*.js' | ForEach-Object {
node --check $_.FullName
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
git diff --check
简易单元测试教程:从编写到判断结果
本项目不需要额外安装测试框架。可以按“准备数据—调用函数—断言结果”的顺序,为一个独立业务规则编写测试。下面的例子取自 tests/search.test.js,检查物品名称、类别、地点和描述都能被搜索命中:
test("按物品名称、类别、地点和描述匹配", () => {
const records = [
item(),
item({
id: "demo-2",
name: "钥匙串",
category: "钥匙",
location: "第一食堂",
description: "带蓝色挂饰"
})
];
assert.equal(searchItems(records, "蓝色水杯")[0].id, "demo-1");
assert.equal(searchItems(records, "钥匙")[0].id, "demo-2");
assert.equal(searchItems(records, "图书馆")[0].id, "demo-1");
assert.equal(searchItems(records, "校园贴纸")[0].id, "demo-1");
});
其中 item() 是测试文件里的数据工厂,先生成字段完整的记录,再通过传入不同属性构造第二条记录;searchItems 是被测函数;assert.equal(实际值, 预期值) 用于核对搜索结果中的物品 ID。若实际值与预期值不一致,断言会使该测试失败,Node.js 会报告测试名称和不一致的值;全部断言通过时,该用例显示通过。测试失败表示当前实现与这个用例的预期不符,之后应检查失败分支和输入数据,而不是删除断言来消除失败。
在项目根目录运行单个测试文件:
node tests/search.test.js
想验证具体功能,可按同样模式增加一个 test("用例名称", () => { ... }),为正常输入、无匹配输入和边界输入分别准备数据,然后调用 assert.equal、assert.deepEqual 或 assert.ok。改完后先运行对应文件,最后再运行上面的全量测试命令,确认没有回归。若输出显示失败,应先阅读用例名称、错误位置及 Expected/Actual 信息,并对照被测函数定位原因。
检查 JavaScript 语法并检查已跟踪差异的空白:
Get-ChildItem js,tests -Recurse -Filter '*.js' | ForEach-Object {
node --check $_.FullName
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
git diff --check
白盒测试思路与具体用例
测试围绕代码分支设计:输入有效/无效,寻物/招领,本地本人/示例/其他发布者,搜索有结果/无结果,存储成功/损坏/不可用,剪贴板成功/失败,以及确认/取消。边界覆盖空白、超长、日期、特殊字符、缺失字段、重复操作和 NodeList 类型。测试还检查数组不变、发布时间不变、其他记录不受影响。
以下 16 项具体用例均来自现有测试文件;实际结果为记录中的全量运行结果:
| # | 文件与输入 | 预期结果 | 实际结果 |
|---|---|---|---|
| 1 | publishing.test.js:空表单、名称仅空格 | 显示中文必填错误,不写入数据 | 通过 |
| 2 | publishing.test.js:未来时间 2026-10-09T12:01,固定当前时间 12:00 | 拒绝未来时间;非法日历日期也拒绝 | 通过;当前时间与闰日边界通过 |
| 3 | search.test.js:关键词分别出现在名称、类别、地点、描述 | 四个白名单字段均可命中 | 通过 |
| 4 | search.test.js:不存在、空串和空白关键词 | 返回空数组 | 通过 |
| 5 | search.test.js:关键词前后含空格“ 水杯 ” | 去除首尾空白后匹配 | 通过 |
| 6 | search.test.js:用 bLuE 搜索 Blue Bottle | 英文大小写不敏感 | 通过 |
| 7 | search.test.js:新旧发布时间的同类记录 | 新记录在前,输入不变 | 通过 |
| 8 | search.test.js:名称含 |
特殊字符按字面文本处理 | 通过 |
| 9 | detail.test.js:字符串 ID “1”与数字 1 | 严格区分 ID 类型,不误匹配 | 通过 |
| 10 | detail.test.js:演示 item-001 与用户 user-a | 打开各自详情,联系方式按物品 ID 对应 | 通过 |
| 11 | detail.test.js:剪贴板 Promise 成功与拒绝 | 成功后才报告成功;拒绝时提供手动复制 | 通过 |
| 12 | home-filters.test.js:招领、钥匙、教学楼组合后切换最新 | 组合命中,类型切换保留类别/地点 | 通过 |
| 13 | home-filters.test.js:地点词“ lIbRaRy ”匹配 Library East/West | 部分匹配、空格与大小写处理正确 | 通过 |
| 14 | home-filters.test.js:筛选无结果后点击清除 | 展示空状态,清除后恢复列表 | 通过 |
| 15 | status.test.js:本人寻物确认完成或取消 | 确认变为 recovered;取消不改变存储 | 通过 |
| 16 | status.test.js:示例记录、错误目标状态及重复完成 | 拒绝不允许的转换和重复写入 | 通过 |
测试分组统计
| 测试文件 | 覆盖范围 | 数量 | 结果 |
|---|---|---|---|
| publishing.test.js | 发布表单、首页及搜索交互回归 | 24 | 24 通过 |
| search.test.js | 关键词搜索纯逻辑 | 15 | 15 通过 |
| detail.test.js | 详情、返回、联系方式和复制反馈 | 42 | 42 通过 |
| status.test.js | 我的发布、状态更新和持久化 | 36 | 36 通过 |
| home-filters.test.js | 类别与地点组合筛选 | 15 | 15 通过 |
| 总计 | 132 | 132/132 通过 |
项目检查记录为:132/132 自动化测试通过;全部 JavaScript 文件语法检查通过;git diff --check 通过。需注意,Git 默认的 git diff --check 不包含未跟踪文件;对新建博客稿还做了独立的行尾空白检查。测试夹具不能验证真实页面像素布局或浏览器授权。
八、结对编程与 PR 协作
提交历史中,Lrainnn310 承担主线分阶段功能提交,Hyaliiin 的六次功能优化以 PR 合并到 main。以下记录以 Git 作者、提交标题和 merge commit 为依据;提交历史不能还原口头分工、审查意见或每位成员实际耗时。
主线功能提交
| Commit | 提交说明 | 对应阶段 |
|---|---|---|
| 1b6419f | chore: initialize Shiguang web project | 项目初始化 |
| c8f7019 | feat: implement homepage and item cards | 首页与卡片 |
| 23be8a0 | fix: stabilize homepage layout height | 页面高度布局 |
| 32f3477 | feat: implement lost and found publishing | 发布模块 |
| a1c5a45 | feat: implement keyword search | 搜索模块 |
| a3c3be3 | feat: implement item details and contact modal | 详情与联系 |
| adbe0d0 | feat: implement my posts and status updates | 我的发布与状态 |
| 867176b | feat: add category and location filters | 类别地点筛选 |
| 73cfe65 | docs: finalize project README | README 完善 |
队友的六次 PR
| PR | 优化 Commit | 合并 Commit | 模块和问题范围 |
|---|---|---|---|
| #1 | 2b456099ee9d3cf62709e78beb8a4cf86faebfa5 | 8e6a07e0cb829bb196aac8b915e9c3397126b454 | 首页交互和无障碍优化,涉及 HTML、CSS、app.js。 |
| #2 | 5d7eaf8667a1a58f0d6c4c29b914f72f53ede90d | 7e50cb15c91cfb0804cb8bceb020d34e4c642fd0 | 发布校验、错误反馈与表单易用性,扩充发布测试。 |
| #3 | eea880908dab2644705ee07b67d49a932bccfede | d448d87c41314b2e6c99867e96bb0292f46ad73b | 搜索输入法、焦点、空/超长输入和边界测试。 |
| #4 | 5110f4d1e5b3ed12381a1649fdff66c8fa1b0e3c | 3279d78d72f8146c39c64ca21025a2b58d01dc0d | 详情、联系方式、异步复制和焦点处理,补充详情测试。 |
| #5 | 4890cd832b01df33e2c703a0e287b0bd6bba0236 | 79343ebe0840aea1603364ce8b8ce055e9853c72 | 我的发布/状态管理;NodeList 索引兼容和 owner 校验。 |
| #6 | 0e63136a6a17cb77b6be842786323672156e2311 | c75e7b2df3e9bc597535a84a09ebea1648b52167 | 首页筛选键盘导航与可访问名称优化,补充筛选测试。 |


九、问题与解决过程
页面高度随列表变化
信息数量不同会改变内容自然高度,底部导航也可能产生视觉位置变化。项目在 23be8a0 对桌面手机风格容器采用视口约束的高度和最小高度,让内容区域滚动,并把底部导航放在容器内定位;窄屏保留正常页面滚动空间。方案避免用固定高度截断较长列表。提交与 CSS 可以核对这一布局策略,但本稿没有新的 Chrome 像素级复验结果。
发布日期校验
JavaScript Date 对部分非法日期可能自动进位,仅判断日期对象有效不足以拒绝如 2 月 30 日的输入。parseEventTime 会解析日期各部分并与 Date 结果核对;validate 还拒绝晚于当前时间的记录。publishing.test.js 覆盖非法日历日期、闰日、未来时间及边界。
搜索输入与焦点
中文输入法组合期间的 Enter 不应意外触发搜索。搜索交互记录组合状态,输入变化清除旧结果,空关键词和超长输入显示提示,并将焦点转到输入框或结果状态。自动化测试覆盖输入法、结果清空、焦点和返回流程。
详情刷新和返回
详情 URL 片段携带编码后的物品 ID,因此刷新后可按 ID 尝试恢复详情;会话内 detailReturn 保存来源页面、记录 ID 与滚动位置,支持返回搜索结果或列表。刷新后此前来源状态不在内存时,应用回退到首页。详情测试覆盖特殊 ID、无效地址和搜索往返。
NodeList 与数组 API 差异
querySelectorAll 返回 NodeList,不能假设它具备数组的 indexOf。PR #5 将标签位置查找改为 Array.prototype.indexOf.call,并在测试夹具中加入原生 NodeList 形态测试。
十、项目局限与后续改进
项目没有登录、实名认证、云同步、服务器、数据库或后台管理。“本人发布”只指当前浏览器本地 ownerId,不具备安全身份保证。用户换设备、换浏览器或清除浏览数据后,不会自动同步或找回记录。项目暂不支持编辑、删除、即时聊天、地图定位和真实图片上传。
后续可以在课程允许且需求明确的前提下,研究服务端与账号方案、数据备份迁移、图片上传和真实浏览器/辅助技术验收;这些是后续方向,不是当前已实现功能。
十一、结对过程
队友评价与个人总结
-
对队友王钰涵协作过程的真实评价:
我认为队友对项目完善做出了持续且具体的贡献。根据仓库记录,队友通过 Fork 分支提交了六次 PR,改动涉及首页交互、发布表单、关键词搜索、物品详情与联系方式、“我的发布”与状态管理,以及首页筛选优化。这些工作覆盖了从信息发布、查找、查看详情到管理状态的多个核心环节,不只是补充单一页面,也进一步完善了项目的整体使用流程。
我比较认可这种围绕已有功能逐步改进的协作方式:每次改动都有对应的功能分支和 PR,合并记录可以追踪,也便于把优化内容与项目主线对应起来。队友 B 的工作与主线开发形成了互补,让项目在持续增加功能的同时,也有机会对交互细节、边界情况和模块衔接进行完善。 -
我的实际分工与个人感受:
我主要负责项目初始化,以及首页与信息卡片、页面高度修复、发布、关键词搜索、物品详情与联系方式、“我的发布”与状态更新、类别和地点筛选等功能,也负责了 README 文档完善。主线提交记录可以对应这些阶段的开发工作。
回顾整个项目,我的工作不只是把页面搭建出来,还需要同时考虑数据模型能否被不同页面复用、输入校验是否覆盖边界、状态更新是否只作用于本人本地记录,以及刷新后数据是否仍然保留。随着功能增加,首页、搜索、详情和“我的发布”之间的数据同步也成为需要反复检查的部分。这让我更清楚地认识到,前端项目的业务逻辑、数据持久化和界面反馈需要一起设计,不能只关注单个页面是否显示正常。
测试也贯穿了功能开发过程。项目使用 Node.js 内置测试工具检查业务规则和交互分支,最终整理为 132 项自动化测试。与此同时,自动化测试不能完全代替浏览器中的实际检查:布局、剪贴板权限、键盘焦点和窄屏操作仍需要结合真实浏览器验证。这个项目让我更重视把“功能实现”“自动化检查”和“浏览器人工验证”区分开来,并在文档中如实说明已验证和待验证的范围。 -
结对过程中印象最深的真实技术决策或沟通经历:
项目选择使用原生 HTML、CSS 和 JavaScript,不引入需要安装或联网加载的前端依赖。网页通过 Chrome 直接打开根目录中的
index.html,符合课程演示和助教检查的运行要求。代码按职责拆分为app.js、logic.js和storage.js:页面导航、事件与渲染由应用脚本协调,发布校验、搜索、筛选和状态规则由业务逻辑模块处理,持久化则集中在存储模块中。这样可以在保持网页直接运行的同时,使用 Node.js 对部分业务函数进行自动化测试。数据存储采用浏览器
localStorage,是因为本项目不要求部署后台或数据库,也希望用户刷新后仍能看到本机发布的数据。这个选择有明确边界:数据只保存在当前浏览器,不支持跨设备同步;本地发布者标识也不等同于登录或实名认证。把这些限制写清楚,是为了避免将本地演示能力误解为真实的线上失物招领服务。

浙公网安备 33010602011771号