第二次结队作业

从一条线索到一次找回:校园失物招领项目“拾光”的设计与实现

本次博客作者:[102401502] 王钰涵 结对同学:[102401601]李佳雨

结对同学的博客链接:https://www.cnblogs.com/Lrain310/p/23244197
本作业博客链接:https://www.cnblogs.com/Hyalin/p/23244271
我们队创建的仓库 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 中提供的测试命令仅供开发验证,不是网页启动步骤。

四、系统设计与数据流

模块数据流图

flowchart LR U[用户操作] --> UI[index.html 视图与 app.js 事件] UI --> L[logic.js 规则与筛选] UI --> S[storage.js 本地读写] S --> LS[(localStorage)] D[app.js 内置演示记录] --> UI LS --> S --> UI L --> UI UI --> V[首页 / 搜索 / 详情 / 发布成功 / 我的发布]

初始化时,app.js 将 js 内的演示记录与 storage.js 读取到的本地记录合并在内存数据中。页面展示和用户操作由 app.js 协调,复杂筛选及状态规则则交给 logic.js。新发布记录在校验成功后写入本地存储,再更新当前页面的数据;状态写入成功后,应用重新渲染首页、搜索结果、“我的发布”和详情。

localStorage 的选择与边界

此项目没有服务器和数据库,因此使用浏览器 localStorage 保存本地发布信息。发布记录键为 shiguang.items.v1,本地发布者标识键为 shiguang.owner.v1。正常情况下,刷新同一浏览器页面后数据仍可读取。

演示记录保存在 js/app.js,不写入用户发布存储,也不进入“我的发布”。本地发布者标识只用于区分当前浏览器的数据,不等同于登录或实名认证。数据不支持跨浏览器、跨设备同步;清理浏览器数据后不会自动恢复。由于 file:// 的存储策略受浏览器环境影响,存储不可用时页面应提示,而不是伪称发布成功。

五、核心业务流程

以下五张 Mermaid 图对应发布、搜索与组合筛选、状态更新和本地存储关键分支;首图概览系统业务。上一节另列模块数据流图。

1. 系统整体业务流程

flowchart TD Start([打开 index.html]) --> Init[读取演示数据和 localStorage] Init --> Read{本地读取正常?} Read -- 是 --> Merge[合并演示记录与有效用户记录] Read -- 否或损坏 --> Warn[提示异常,不覆盖损坏原文] Warn --> Home[首页] Merge --> Home Home --> Browse[浏览 / 搜索 / 组合筛选] Browse --> Detail[按 ID 打开详情] Home --> Publish[发布寻物或招领] Publish --> Validate{表单校验通过?} Validate -- 否 --> Err[显示字段错误并聚焦] Err --> Publish Validate -- 是 --> Save[创建唯一记录并写入本地存储] Save --> Saved{写入成功?} Saved -- 否 --> SaveErr[提示错误并保留表单内容] SaveErr --> Publish Saved -- 是 --> Success[发布成功页] Success --> Detail Home --> Mine[我的发布] Mine --> Detail Detail --> Contact[查看联系方式 / 手动复制] Detail --> Own{本人记录且可完成?} Own -- 是 --> Confirm[二次确认状态] Confirm --> Sync[成功后持久化并同步视图] Sync --> Home

2. 发布寻物 / 招领

flowchart TD A[选择寻物或招领] --> B[填写六项必填信息] B --> C[提交并设置防重复提交锁] C --> D{必填、长度、类别、类型、时间有效?} D -- 否 --> E[字段中文错误 / aria-invalid / 聚焦首错] E --> B D -- 是 --> F[取得本地 ownerId 并生成 item ID] F --> G[createItem 规范字段并设置初始状态] G --> H[storage.saveItem 校验并写入] H --> I{存储成功?} I -- 否:损坏/重复/容量/不可用 --> J[提示失败,保留填写内容,可重试] J --> B I -- 是 --> K[更新内存并显示成功页和初始状态] K --> L[详情 / 继续发布 / 返回首页]

3. 关键词搜索与组合筛选

flowchart TD A[进入搜索页] --> B[建议词初始状态] B --> C[输入关键词 / 快捷词] C --> D{去首尾空白后有效且不超长?} D -- 否:空白 --> E[提示请输入关键词并聚焦输入框] D -- 否:过长 --> E2[提示长度上限并聚焦输入框] D -- 是 --> F[searchItems 白名单匹配四个字段] F --> G{有结果?} G -- 否 --> H[无结果提示:重新搜索或发布] G -- 是 --> I[按发布时间倒序显示共用卡片] A --> J[首页选择类型、类别、地点] J --> K[filterItems 执行类型 AND 类别 AND 地点子串] K --> M{有匹配项?} M -- 否 --> N[友好空状态,可清除筛选] M -- 是 --> O[按发布时间倒序显示]

4. 物品状态更新

flowchart TD A[从我的发布打开记录详情] --> B{记录存在且属于本地 owner?} B -- 否 --> X[不显示状态操作或提示不可更新] B -- 是 --> C{类型及初始状态正确、未完成?} C -- 否 --> X C -- 是 --> D[打开确认弹窗] D --> E{用户操作} E -- 暂不修改 / Esc / 关闭 --> F[关闭,数据不变,焦点恢复] E -- 确认完成 --> G[锁按钮并重新校验] G --> H{业务校验通过?} H -- 否 --> I[显示错误,不写入] H -- 是 --> J[按唯一 ID 调用 updateItemStatus] J --> K{localStorage 写入成功?} K -- 否:损坏/不可用/容量/不存在 --> L[显示失败,不显示成功状态] L --> D K -- 是 --> M[重绘首页、搜索、我的发布和详情] M --> N[关闭弹窗并显示新状态,不改变发布时间]

5. localStorage 数据读写

flowchart TD A[loadItems] --> B{getItem 抛错?} B -- 是 --> C[返回 unavailable,不写入] B -- 否 --> D{值为 null?} D -- 是 --> E[返回空数组] D -- 否 --> F[JSON.parse] F --> G{数组及记录字段/状态/日期/ID 有效?} G -- 否 --> H[返回 corrupt,不覆盖原始值] G -- 是 --> I[返回校验后的记录] I --> J[保存前校验格式与唯一 ID] J --> K[追加新记录或替换唯一状态目标] K --> L{setItem 成功?} L -- 否 --> M[返回 quota 或 unavailable,UI 不报成功] L -- 是 --> N[返回成功,再同步内存及页面]

六、关键代码分析

以下七段代码分别对应当前发布校验、创建与保存、状态持久化、搜索、筛选、联系方式复制和测试。函数名称与文件路径来自项目实际实现;说明只涵盖代码已经提供的行为。

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:名称含 < img src=x>、描述含 A+B [C] 特殊字符按字面文本处理 通过
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 首页筛选键盘导航与可访问名称优化,补充筛选测试。

6f4810f3f5b3384105c39c379d8b08c4

281bbee3faca1b0f1ec7fd09f905340a

九、问题与解决过程

页面高度随列表变化

信息数量不同会改变内容自然高度,底部导航也可能产生视觉位置变化。项目在 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,不具备安全身份保证。用户换设备、换浏览器或清除浏览数据后,不会自动同步或找回记录。项目暂不支持编辑、删除、即时聊天、地图定位和真实图片上传。

后续可以在课程允许且需求明确的前提下,研究服务端与账号方案、数据备份迁移、图片上传和真实浏览器/辅助技术验收;这些是后续方向,不是当前已实现功能。

十一、结对过程

队友评价与个人总结

  • 对队友李佳雨协作过程的真实评价:

    在本次结对编程中,队友主要承担 A 角色的功能开发工作,我负责 B 角色的代码审查、边界测试和交互优化。队友能够按照阶段推进任务,逐步完成首页、发布、搜索、详情、我的发布与状态更新等模块,为后续测试和改进提供了基础。
    我比较认可队友按模块组织开发、通过 GitHub 进行协作的方式。清晰的阶段划分让我能够围绕具体功能检查问题,并通过功能分支提交修改。协作过程中,我也体会到,功能实现之后,还需要关注异常数据、键盘操作、焦点管理和窄屏布局等细节,才能让页面更加完善。
    建议队友今后在开发初期就将边界情况和无障碍交互纳入考虑,并在提交时补充测试结果与已知问题,减少后续审查和联调成本。总体而言,队友在功能开发方面承担了重要工作,我们通过开发、审查和修复形成了有效配合,也加深了对结对编程和 GitHub 协作流程的理解。

  • 我的实际分工与个人感受:

    通过本次“拾光——校园失物招领”项目,我对结对编程的理解从简单分工逐渐转变为共同完善软件质量。我主要承担 B 角色的工作,围绕队友完成的功能进行代码审查、边界测试和交互优化,并通过 Fork、功能分支和 Pull Request 参与协作。
    在开发过程中,我发现“功能能够运行”只是第一步。日期是否合法、连续点击是否重复提交、存储损坏后是否安全处理、弹窗关闭后焦点能否恢复,这些细节都会影响用户体验。围绕这些问题,我参与完善了表单验证、异常处理、键盘操作和筛选交互,也逐渐学会从正常流程之外寻找潜在问题。
    这次实践让我更加熟悉 Git 分支管理、代码差异检查和自动化测试,也认识到不同验证方式各有作用:自动化测试适合检查逻辑和边界条件,真实浏览器检查则有助于发现布局、焦点和操作体验上的问题。对于未完成的检查,应如实说明,不能将测试数量等同于软件质量。
    我仍需要提高前期分析和测试规划能力,避免发现一个问题就零散地补充验证。今后会在修改前梳理功能流程、异常路径和回归范围,并在提交中清楚说明修改原因与验证结果,让每次改进更有依据,也让双方协作更加顺畅。

  • 结对过程中印象最深的真实技术决策或沟通经历:

    项目选择使用原生 HTML、CSS 和 JavaScript,不引入需要安装或联网加载的前端依赖。网页通过 Chrome 直接打开根目录中的 index.html,符合课程演示和助教检查的运行要求。代码按职责拆分为 app.js、logic.js 和 storage.js:页面导航、事件与渲染由应用脚本协调,发布校验、搜索、筛选和状态规则由业务逻辑模块处理,持久化则集中在存储模块中。这样可以在保持网页直接运行的同时,使用 Node.js 对部分业务函数进行自动化测试。

    数据存储采用浏览器 localStorage,是因为本项目不要求部署后台或数据库,也希望用户刷新后仍能看到本机发布的数据。这个选择有明确边界:数据只保存在当前浏览器,不支持跨设备同步;本地发布者标识也不等同于登录或实名认证。把这些限制写清楚,是为了避免将本地演示能力误解为真实的线上失物招领服务。

posted @ 2026-10-10 01:27  Hyalin  阅读(13)  评论(0)    收藏  举报