结对作业二:校园失物招领小程序实现

结对作业二:校园失物招领小程序实现

这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业要求在哪里 作业要求
这个作业的目标 实现前一次的原型模型
github链接 102402137-102402139
成员1 102402137 翁齐文
成员2 102402139 徐张睿

一、具体分工

成员 学号 主要负责
翁齐文 102402137 仓库搭建、数据层 store.js、首页/搜索页、UI 与交互迭代、README
徐张睿 102402139 发布/详情/我的页面、单元测试用例编写、博客整理、功能走查

二、PSP 表格

PSP2.1 阶段 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 40
— Estimate 估计 30 40
Development 开发 480 620
— Analysis 需求分析 40 60
— Design Spec 设计文档 30 30
— Design Review 设计复审 20 20
— Coding Standard 代码规范 10 10
— Design 具体设计 60 80
— Coding 具体编码 280 380
— Code Review 代码复审 40 50
— Test 自测与修改 40 60
Reporting 报告 90 110
— Test Report 测试报告 30 40
— Size Measurement 工作量统计 10 10
— Postmortem 事后总结 50 60
合计 600 770

三、解题思路与设计实现

3.1代码实现思路

整个项目按三层组织,层与层之间单向依赖:

页面层(index.js / publish.js / detail.js / my.js / search.js)
   ↓ 只调用
数据层 store.js  ←→  localStorage
   ↑ 共用
公共层 common.js(转义、高亮、卡片渲染、复制、toast)
  • 数据层 store.js:是唯一碰数据的地方。对外暴露 add / updateItem / getById / updateStatus / remove / query / paginate / findMatches / getStats 等函数。它不引用任何 DOM 对象,判断浏览器还是 Node 全靠 typeof localStorage,所以同一份代码既能在浏览器跑、又能被 Node 直接 require 来单测。
  • 公共层 common.js:放跨页面复用的纯函数。比如把一条物品数据渲染成卡片的 cardHtml(),首页、搜索页、我的页三处共用,避免复制粘贴。
  • 页面层:每个页面只做三件事——调 Store 拿数据、用 common 渲染成 HTML、给按钮绑事件。页面之间通过 URL 参数通信(detail.html?id=xxx\&from=my)。

校验是数据层的一部分:Store.validate(raw) 返回 {ok, errors, item},发布和编辑复用同一套校验,不会出现“发布能过、编辑反而能绕过”的漏洞。

3.2关键实现的流程图 / 数据流图

程序运行的整体流程

exported_image

整体数据流

flowchart TB U[用户操作] --> V[页面 JS] V -->|调用| S[Store 数据层] S -->|读写| L[(localStorage)] S -->|返回数据| V V -->|渲染卡片/详情| C[common.js] C --> DOM[页面 DOM]

发布一条信息的时序

sequenceDiagram participant U as 用户 participant P as publish.js participant S as Store participant L as localStorage U->>P: 填写表单,点立即发布 P->>S: Store.add(raw) S->>S: validate(raw) 逐条校验 alt 校验失败 S-->>P: {ok:false, errors:\[...]} P-->>U: 顶部红框列出错误 else 校验通过 S->>L: 追加一条并保存 S-->>P: {ok:true, item} P->>S: Store.findMatches(item) S-->>P: 可能相关的相反类型信息 P-->>U: 发布成功页 + 智能配对提示 end

3.3重要代码片段与解释

一:智能配对

function titleOverlap(a, b) {
  a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
  for (var i = 0; i < a.length - 1; i++) {
    var g = a.substr(i, 2);          // 取相邻两字的二元组
    if (b.indexOf(g) >= 0) return true;
  }
  return false;
}

function findMatches(newItem) {
  var opposite = newItem.type === 'lost' ? 'found' : 'lost';
  return query({ type: opposite }).filter(function (it) {
    if (it.id === newItem.id) return false;
    if (it.status !== 'active') return false;
    if (it.category !== newItem.category) return false;
    return titleOverlap(newItem.title, it.title);
  }).slice(0, 3);
}

解释:不做站内聊天,也能把“丢的人”和“捡的人”接上。逻辑很克制——必须是相反类型(寻物↔招领)、同一分类、标题有两字重合、且仍在进行中,才推荐。titleOverlap 用中文二元组粗匹配,“蓝色折叠雨伞”和“藏蓝色折叠雨伞”会命中“色折/折叠/叠雨”这些公共片段。

二:关键词高亮先转义再包 <mark>,防 XSS

function highlight(text, keyword) {
  var s = String(text == null ? '' : text);
  var kw = (keyword == null ? '' : String(keyword)).trim();
  if (!kw) return escapeHtml(s);
  var lower = s.toLowerCase(), target = kw.toLowerCase();
  var out = '', from = 0, idx;
  while ((idx = lower.indexOf(target, from)) !== -1) {
    out += escapeHtml(s.slice(from, idx)) +
      '<mark class="hl">' + escapeHtml(s.slice(idx, idx + kw.length)) + '</mark>';
    from = idx + kw.length;
  }
  return out + escapeHtml(s.slice(from));
}

解释:搜索结果要把命中的字高亮,但物品标题是用户输入的,直接拼 HTML 会被 XSS(比如标题写 <img src=x onerror=alert(1)>)。这里的关键是每一段原文都先过 escapeHtml 再插入 <mark>,连命中片段本身也不例外,保证高亮功能本身不会变成漏洞入口。

四、附加特点设计与展示

4.1 智能配对提醒

① 意义

传统失物招领网站只是被动地 “把信息列出来”,丢的人和捡的人还是要自己一条条翻。我们加了一个主动搭桥的小功能:当用户发布完一条信息时,系统自动在后台找一找 —— 有没有人刚好捡到了 / 丢了相似的东西 —— 然后在成功页提示一句 “你可能要找的是这条,去联系看看”。

它的意义在于:不做聊天、不做地图,就把 “失主” 和 “拾主” 这两个本来互不相识的人第一次主动关联起来。对用户来说,从 “我发完就只能干等” 变成了 “刚发完就有人推荐了一条可疑信息”,找回概率明显提高。这也是我们对 “怎么减少无效联系” 的回答 —— 与其让大家互相乱问,不如系统先帮你筛一遍。

② 实现思路

配对条件刻意收得很窄,避免乱推荐:

  1. 类型相反:寻物(lost)只去匹配招领(found),反之亦然 —— 同一个方向的两条信息不可能是同一物体;
  2. 分类相同:都选了 “雨具” 才可能是同一把伞;
  3. 仍在进行中:已经标记 “已找到 / 已归还” 的不再推荐;
  4. 标题有两字重合:用中文二元组粗匹配,比如 “蓝色折叠雨伞” 和 “藏蓝色折叠雨伞” 会命中 “折叠”“叠雨” 等公共片段。

满足以上四条的信息最多取 3 条,显示在发布成功页。

③ 代码片段

function titleOverlap(a, b) {
  a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
  for (var i = 0; i < a.length - 1; i++) {
    var g = a.substr(i, 2);              // 相邻两字组成的二元组
    if (b.indexOf(g) >= 0) return true;
  }
  return false;
}

function findMatches(newItem) {
  var opposite = newItem.type === 'lost' ? 'found' : 'lost';
  return query({ type: opposite }).filter(function (it) {
    if (it.id === newItem.id) return false;
    if (it.status !== 'active') return false;
    if (it.category !== newItem.category) return false;
    return titleOverlap(newItem.title, it.title);
  }).slice(0, 3);
}

解释:titleOverlap 不取整个标题做相等比较(那样太严格,“蓝色雨伞” 和 “蓝色折叠雨伞” 就匹配不上),也不算编辑距离(实现复杂),而是把标题切成所有相邻两字的片段,任一公共片段命中即认为相似。中文两字是最自然的语义单元,对 “伞 / 卡 / 耳机” 这类短标题特别合适。

④ 成果展示

屏幕截图 2026-10-07 221320

4.2物品分类 + 校园地点双重筛选

① 意义

光靠关键词搜索,用户脑子里得先想出 “该搜哪个词”。但很多时候人是按场景找东西的 ——“我今天在图书馆丢的”“在食堂附近捡的”。我们在搜索框下方加了两组一键筛选胶囊:

  • 分类:证件卡 / 数码电子 / 生活用品 / 雨具 / 包袋 / 随身物品 / 图书资料 / 其他;
  • 地点:图书馆 / 食堂 / 教学楼 / 体育馆 / 宿舍 / 校车站 / 实验楼 / 操场。

点一下就能筛,不用打字。地点筛选用的是模糊包含匹配:点 “图书馆” 能命中 “图书馆三楼自习区”“图书馆门口” 等所有包含该词的记录,不用和原文一字不差。它和关键词、寻物 / 招领 tab、最新 / 最热排序可以叠加使用。

② 实现思路

筛选逻辑集中在 Store.query(),每加一个条件就是一条 continue:

function query(opt) {
  var items = getAll(), out = [];
  for (var i = 0; i < items.length; i++) {
    var it = items[i];
    if (opt.type     && opt.type !== 'all'     && it.type     !== opt.type)    continue;
    if (opt.category && opt.category !== 'all' && it.category !== opt.category) continue;
    // 地点模糊匹配:用户选“图书馆”,地点字段里包含“图书馆”即可
    if (opt.location && opt.location !== 'all' && it.location.indexOf(opt.location) === -1) continue;
    if (opt.keyword) {
      var hay = (it.title + ' ' + it.description + ' ' + it.location + ' ' + it.category).toLowerCase();
      if (hay.indexOf(opt.keyword.toLowerCase()) === -1) continue;
    }
    out.push(it);
  }
  // 排序:处理中在前,已解决沉底
  ...
}

胶囊按钮的选项是从 Store.CATEGORIES 和 Store.HOT_LOCATIONS 两个常量渲染出来的,改一处常量,首页和搜索页同步更新,不会写死在 HTML 里。

③ 代码片段

// 地点筛选:点胶囊 = 选地点关键词
function renderLocChips() {
  var box = document.getElementById('locChips');
  var html = '<span class="chips-label">地点</span>' +
    '<span class="chip' + (state.location === 'all' ? ' active' : '') +
    '" data-loc="all">全部</span>';
  Store.HOT_LOCATIONS.forEach(function (l) {
    html += '<span class="chip' + (state.location === l ? ' active' : '') +
      '" data-loc="' + App.escapeHtml(l) + '">' + App.escapeHtml(l) + '</span>';
  });
  box.innerHTML = html;
}

④ 成果展示

屏幕截图 2026-10-07 223026

4.3联系方式一键复制

① 意义

详情页里电话、QQ、微信本来就是给人联系失主用的,但手机 / 电脑上要 “长按选中→复制→切到微信粘贴” 很绕。我们在每条联系方式后面放一个 “复制” 按钮,点一下就进剪贴板,提示 “已复制到剪贴板”。对方拿到号码直接就能加好友,减少一步操作摩擦。

② 实现思路

现代浏览器有 navigator.clipboard,但本项目是双击 html 在 file:// 下打开,这种协议下浏览器往往不暴露 Clipboard API。所以我们做了降级:优先用 navigator.clipboard,不可用就创建一个隐藏的 <textarea> 用 document.execCommand('copy') 兜底。

③ 代码片段

function copyText(text) {
  function fallback() {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed'; ta.style.opacity = '0';
    document.body.appendChild(ta); ta.select();
    try { document.execCommand('copy'); return true; }
    finally { document.body.removeChild(ta); }
  }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    return navigator.clipboard.writeText(text).then(() => true, fallback);
  }
  return Promise.resolve(fallback());
}

④ 成果展示

image

4.4失物招领最终成果简要展示

|detail|index|
|my|publish|
|search||

五、目录说明与运行方法

目录结构

102402137-102402139/
├── index.html          # 首页:浏览 + 搜索框 + 类型/分类/地点筛选 + 排序 + 加载更多
├── search.html         # 搜索页:关键词搜索 + 热门搜索 + 搜索历史
├── publish.html        # 发布/编辑页:寻物招领表单 + 校验 + 照片/图标 + 发布成功页(含智能配对)
├── detail.html         # 详情页:物品详情 + 联系方式一键复制 + 发布者状态维护/编辑/删除
├── my.html             # 我的:个人资料卡 + 编辑弹窗(含上传头像)+ 我的发布管理
├── css/
│   └── style.css       # 全部样式(居中手机式应用列,移动端竖屏风格)
├── js/
│   ├── store.js        # 【数据层】localStorage 增删改查、校验、搜索/筛选/排序/分页、
│   │                   #   状态维护、统计、智能配对、个人资料;兼容浏览器与 Node
│   ├── common.js       # 【公共层】HTML 转义、关键词高亮、时间格式化、卡片渲染、
│   │                   #   一键复制、toast、悬浮预览
│   ├── index.js        # 首页逻辑
│   ├── search.js       # 搜索页逻辑
│   ├── publish.js      # 发布/编辑表单逻辑
│   ├── detail.js       # 详情页逻辑
│   └── my.js           # 「我的」页逻辑
├── test/               # 单元测试(不影响网页运行,助教可选择性执行)
│   ├── helpers.js      # 测试辅助:内存 localStorage 桩、构造合法数据的工厂函数
│   ├── store.test.js   # 数据层 Store 测试
│   ├── common.test.js  # 公共函数测试
│   ├── flow.test.js    # 业务主流程集成测试
│   ├── pages.test.js   # 页面静态一致性检查
│   └── dom.flow.test.js# DOM 级集成测试(可选,需 jsdom,未装自动跳过)
├── package.json        # 只有 test 脚本,无运行依赖
└── README.md           # 本文件

组织思路:

  • js/store.js 是唯一数据入口,所有页面通过它读写;数据层不碰 DOM,将来换后端只需改这一层。
  • js/common.js 放跨页面复用的纯函数(转义、高亮、时间、卡片渲染、复制、悬浮预览)。
  • 每个页面对应一个同名 .js,职责单一:取数据 → 渲染 → 绑事件。
  • 脚本都是普通 <script>,没有模块化 import,所以 file:// 直接打开也能跑。

运行方法

1. 拿到代码

git clone https://github.com/wqwabc/102402137-102402139.git

或在 GitHub 页面 Code → Download ZIP 后解压。

2. 打开网页(无需任何环境)

  1. 保持目录结构不变;
  2. 用谷歌浏览器 Chrome 双击打开根目录下的 index.html;
    • 项目按 file:// 协议即可正常工作(没用 fetch / ES module);
    • 若浏览器限制较严,也可用 VS Code 的 Live Server 以 http:// 打开,效果一致。
  3. 首次打开会自动写入 6 条演示数据(其中 3 条带示例图片),首页立刻有内容。

统一用 Chrome,避免不同浏览器在日期控件、localStorage 上的行为差异。

3. 使用说明

步骤 操作 预期结果
① 发布 首页点「我丢了东西 / 我捡到东西」→ 填必填项 → 立即发布 发布成功页,显示编号与摘要;若有相关信息会提示“智能配对”
② 校验 不填必填项直接发布 顶部出现红色错误清单,不白屏
③ 搜索 输入「雨伞」「校园卡」 列表实时过滤,命中字黄色高亮;搜不到有空状态提示
④ 筛选 点分类/地点胶囊、寻物招领 tab、最新/最热 列表内容随之变化
⑤ 悬浮预览 鼠标停在任意卡片上 旁边浮出简介、照片、地点时间
⑥ 详情 点任意卡片 进入详情:分类/地点/时间/编号/描述/照片
⑦ 联系 详情页点「联系失主/拾取人」 显示联系方式,点「复制」提示已复制
⑧ 状态 打开自己发的那条,底部「发布者管理」 可标记已找到/已归还、编辑、删除
⑨ 我的 底部导航进「我的」 个人资料卡 + 我的发布;点铅笔可改昵称/头像/学院/校区/联系方式

六、单元测试(10 分)

6.1 选用的测试工具、学习过程与简易教程

选用工具:Node.js 自带的 node:test + node:assert/strict。

我们是边查文档边写的:先看 Node 官方文档里 node:test 的例子,再对照项目里 store.js 的函数签名一个个补用例。

自己写一个测试的最小教程:

# 1. 确认 Node >= 18
node -v

# 2. 跑全部测试
npm test
# 等价于:
node --test test/store.test.js test/common.test.js test/flow.test.js test/pages.test.js

# 3. 只跑某个文件
node --test test/store.test.js

为什么 store.js 能在 Node 里直接测?因为它结尾做了环境判断:

if (typeof module !== 'undefined' && module.exports) module.exports = Store;
else window.Store = Store;

浏览器里挂到 window,Node 里走 module.exports,同一份业务代码不用改一行。Node 里没有 localStorage,我们在 test/helpers.js 里用一个 Map 自己实现了同样的接口:

function createLocalStorage() {
  var map = new Map();
  return {
    getItem(k)  { return map.has(String(k)) ? map.get(String(k)) : null; },
    setItem(k,v){ map.set(String(k), String(v)); },
    removeItem(k){ map.delete(String(k)); },
    clear()     { map.clear(); }
  };
}

每条用例开始前都换一个全新的内存版 localStorage 并 Store._reset(),所以用例之间互不污染、可以随便重复跑。


6.2 部分测试代码与说明

const { describe, it, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const h = require('./helpers');
const Store = h.freshStore();

beforeEach(() => { h.installLocalStorage(); Store._reset(); });

describe('Store.validate —— 发布信息校验', () => {
  it('合法数据:返回 ok,并自动补全 id / 编号 / 发布者 / 状态', () => {
    const res = Store.validate(h.validRaw());
    assert.equal(res.ok, true);
    assert.match(res.item.code, /^LF\d{11}$/);   // 编号格式 LF+日期+序号
    assert.equal(res.item.publisher, Store.CURRENT_USER_ID);
    assert.equal(res.item.status, 'active');
  });

  it('标题长度边界:30 字合法、31 字非法', () => {
    assert.equal(Store.validate(h.validRaw({ title: '伞'.repeat(30) })).ok, true);
    assert.equal(Store.validate(h.validRaw({ title: '伞'.repeat(31) })).ok, false);
  });

  it('三种联系方式至少填一种', () => {
    assert.equal(Store.validate(h.validRaw({
      contactPhone: '', contactQq: '', contactWechat: ''
    })).ok, false);
    assert.ok(Store.validate(h.validRaw({ contactPhone: '', contactWechat: 'wx_abc' })).ok);
  });
});

describe('query 排序 —— 进行中在前,已解决沉底', () => {
  it('已标记归还的信息排到后面', () => {
    h.useItems(Store, [
      h.item({ id: 'a', status: 'resolved', createdAt: 100 }),
      h.item({ id: 'b', status: 'active',   createdAt: 200 })
    ]);
    assert.deepEqual(Store.query().map(x => x.id), ['b', 'a']);
  });
});

测的是哪些函数:

测试文件 测的函数
store.test.js validate 表单校验、add/updateItem/updateStatus/remove、query 搜索筛选排序、paginate 分页、findMatches 配对、getStats 统计
common.test.js escapeHtml、highlight 高亮、fmtTime 时间格式化、cardHtml 卡片渲染
flow.test.js 发布→搜索→详情→标记状态→编辑 删除 的主流程贯通
pages.test.js 静态检查:HTML 里的 id、脚本路径、页面互链、UTF-8 编码

6.3 测试数据构造思路 & 怎么防 “刁难”

按等价类划分 + 边界值分析来设计:

  1. 每个字段分合法 / 非法 / 缺失三类:手机号取合法 13800138000、第二位 2 开头非法、长度 10 位不足、含字母 13800138000a 各一组;
  2. 边界取两点:长度限制的字段测 n 和 n+1—— 标题 30/31 字、地点 50/51 字、QQ 5/12/13 位;
  3. 白盒覆盖分支:store.js 里每个 if 都至少有一条用例走到 “真” 和 “假”。比如 updateStatus 只接受 active/resolved,就分别测合法值、非法值 'done'、以及一个根本不存在的 id;
  4. 坏数据兜底:localStorage 被写成 {坏JSON、记录缺字段、null、全是空格、不是数组 —— 断言 “不抛异常 + 有兜底值 + 不破坏已有数据”;
  5. 用例互不干扰:每条用例开始都换新存储并 _reset(),所以测试可以乱序、重复跑,这是回归测试的前提。

面向 “将来测试人员会怎么挑刺”:

  • 可能拿特殊字符试 XSS → escapeHtml 和 highlight 都有专门用例,断言 <img 不会出现在输出里;
  • 可能说 “搜索只匹配了标题” → 用例明确覆盖描述、地点、分类字段,并写死预期条数;
  • 可能换台电脑、清掉浏览器数据 → 测了 “首次访问自动播种”“导出再导入”;
  • 可能点一个已被删除的详情页 → dom.flow.test.js 里有 “id 不存在时给友好提示” 的用例;
  • 不写 assert.ok(true) 这种假测试 —— 每条断言都对着一条具体业务规则。

七、GitHub 签入记录

image


八、遇到的问题与解决

问题 尝试 结果 收获
照片 base64 直接存导致 localStorage 超限 先直接存,发大图报 QuotaExceeded 用 canvas 压到 480px/0.72 质量 上传类功能必须先想体积
悬浮预览在卡片边缘被截断 初始固定右侧 改为跟随鼠标并做边界翻转 浮层要算视口边界
file:// 下 clipboard API 不可用 直接用 navigator.clipboard 加 execCommand 降级 本地打开和线上环境行为不同

九、评价队友

值得学习的地方:这次结对中,搭档在测试走查和边界情况上帮了大忙。一开始我只顾着把 “发布成功” 这条主流程跑通,是他对着界面一条一条点,找出了好几个我漏掉的异常路径:必填项留空直接点发布会不会白屏、搜索一个根本不存在的词、点进一条已经被删掉的详情页。

需要改进的地方:需求对齐可以更早一点。中间有一次他直接按自己的想法加了一个功能,结果和我们砍范围的决定冲突,返工了一轮。如果下次开工前先把 “这次做什么、明确不做什么” 列成一张表双方确认,能少走弯路。整体来说配合很顺畅,下次继续

posted @ 2026-10-08 22:40  WQWa  阅读(3)  评论(0)    收藏  举报