2026秋软件工程第二次结对作业之程序实现

2026秋软件工程结对作业 —— 校园失物招领处

GitHub 仓库地址:[ifaith0507/102301528: 2026秋软件工程第二次结对作业之程序实现]


一、具体分工

此项目为个人独自完成。


二、PSP表格

任务 预估耗时 实际耗时
需求分析与原型设计(第一次作业) 180min 200min
技术方案设计与目录规划 30min 25min
数据层实现(storage + icons) 50min 45min
工具函数实现(utils) 40min 35min
主应用逻辑(app 路由/渲染/交互) 120min 110min
HTML 页面结构 40min 30min
CSS 设计系统与票根样式 100min 90min
单元测试编写 60min 50min
README 与文档 30min 25min
测试与调试 40min 35min
合计 690min 645min

三、解题思路描述与设计实现说明

3.1 代码实现思路

校园失物招领的核心问题是信息集中发布、按关键词搜索、状态可更新。我们将需求转化为六个页面:首页、搜索页、发布页、详情页、我的发布页、发布成功页,用单页应用(SPA)方式实现。

架构分层:

1791632582313

路由机制: 用 go(key) 函数切换屏幕,维护 history_ 栈支持返回操作。每次切换时隐藏所有 .screen,显示目标屏幕并添加入场动画(fwd/back),然后调用 renderAll() 刷新数据。

数据流: 所有操作(发布、搜索、状态更新)都经由 storage.js 的函数处理,数据写入 localStorage,渲染层从 DB(内存中的全局数组)读取并生成 HTML。数据层与渲染层完全解耦,便于单元测试。

渲染策略: 采用字符串模板(非框架),ticketHTML() 函数将数据对象拼装为票根卡片 HTML,插入 DOM。所有用户输入经 esc() 转义后再插入,防止 XSS。

3.2 关键流程图

1791632838594

3.3 关键代码片段

片段一:搜索函数 —— 多字段模糊匹配

// storage.js
function searchData(q, typeFilter){
  // 先按类型过滤
  var data = DB.filter(function(it){
    return typeFilter === 'all' ? true : it.type === typeFilter;
  });
  // 再按关键词在四个字段中模糊匹配
  if(q && q.trim()){
    var kw = q.trim().toLowerCase();
    data = data.filter(function(it){
      return (it.title + it.cat + it.place + it.desc)
        .toLowerCase().indexOf(kw) !== -1;
    });
  }
  return data;
}

说明: 搜索覆盖物品名称、类别、地点、描述四个字段,用户输入任一关键词都能匹配到。先按类型(全部/寻物/招领)过滤,再按关键词二次过滤,两层过滤互不干扰。toLowerCase() 确保大小写不敏感。

片段二:编号生成 —— 防重复

// storage.js
function genId(){
  var stamp = new Date().toISOString().slice(0,10).replace(/-/g,'');
  var n = DB.length + 1, id;
  do {
    id = 'LF-' + stamp + '-' + String(n).padStart(2,'0');
    n++;
  } while(DB.some(function(i){ return i.id === id; }));
  return id;
}

说明: 编号格式为 LF-20260927-04(日期+序号),用 do-while 循环检查唯一性,即使删除过记录导致序号不连续,也不会产生重复编号。这模拟了真实失物招领处的编号管理。

片段三:XSS 防护 —— HTML 转义

// utils.js
function esc(s){
  return String(s).replace(/[&<>"']/g, function(c){
    return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
  });
}

说明: 所有用户输入的内容在插入 DOM 前都经过 esc() 转义。例如用户在描述中输入 <script>alert('xss')</script>,会被转为 &lt;script&gt;...,作为纯文本显示而非执行。这在票根模板中大量使用:esc(it.title)、esc(it.place) 等。

片段四:SPA 路由 —— 屏幕切换 + 历史栈

// app.js
function go(key, opt){
  if(!opt) opt = {};
  if(key === current && !opt.force) { if(opt.reset) history_ = [key]; return; }
  var order = SCREENS.map(function(s){ return s.key; });
  var dir = (order.indexOf(key) >= order.indexOf(current)) ? 'fwd' : 'back';
  document.querySelectorAll('.screen').forEach(function(s){
    s.hidden = true; s.classList.remove('fwd','back');
  });
  var el = document.getElementById('s-' + key);
  el.hidden = false;
  if(!opt.silent){ void el.offsetWidth; el.classList.add(dir); }
  current = key;
  if(opt.reset) history_ = [key];
  else if(history_[history_.length-1] !== key) history_.push(key);
  renderAll();
  renderRail();
}

说明: 不依赖任何路由框架,纯手写 SPA 导航。根据目标屏幕在 SCREENS 数组中的位置判断前进/后退方向,添加对应动画。history_ 栈支持 back() 函数逐级返回。void el.offsetWidth 强制浏览器重排以重新触发 CSS 动画。

片段五:票根组件 —— 数据到视图的映射

// app.js
function ticketHTML(it, opts){
  var cls = it.type === 'lost' ? 'type-lost' : 'type-found';
  var stampCls = it.status === 'closed' ? 'closed' : 'open';
  return ''
  + '<article class="ticket" data-id="' + it.id + '">'
  +   '<span class="punch t"></span><span class="punch b"></span>'
  +   '<div class="tk-main">'
  +     '<div class="tk-icon ' + cls + '">' + catIcon(it.cat) + '</div>'
  +     '<div class="tk-body">'
  +       '<h3 class="tk-title">' + esc(it.title) + '</h3>'
  +       '<p class="tk-meta">' + svg(ICON.pin, 2) + esc(it.place) + '</p>'
  +       '<div class="tk-tags">'
  +         '<span class="tag ' + (it.type==='lost'?'tag-lost':'tag-found') + '">' + TYPE_TXT[it.type] + '</span>'
  +         '<span class="tag">' + esc(it.cat) + '</span>'
  +         '<span class="tag">' + relTime(it.time) + '</span>'
  +       '</div>'
  +     '</div>'
  +   '</div>'
  +   '<div class="stub">'
  +     '<span class="stamp ' + stampCls + '">' + statusTxt(it) + '</span>'
  +   '</div>'
  + '</article>';
}

说明: 这是整个应用最核心的渲染函数。将一条数据记录映射为票根卡片 HTML:左侧是分类图标+标题+地点+标签,右侧票根存根上是橡皮图章(状态印章)+ 编号。打孔、虚线、图章角度旋转都由 CSS 实现。catIcon() 根据分类名匹配对应的 SVG 图标路径。


四、附加特点设计与展示

4.1 设计创意:「招领票根」视觉概念

创意独到之处:

不同于常见的卡片列表 UI,我们以校园失物招领处的实体票根为视觉隐喻:

  • 每条信息是一张可撕的票根,左侧是信息主体,右侧是存根
  • 虚线撕边 + 打孔效果模拟真实票据
  • 状态用橡皮图章(旋转-8°的印章风格)展示,"已找到"时盖绿色章
  • 发布成功时有盖章动画(从大到小弹性落下的 CSS keyframes)
  • 配色:红=寻物(急迫感)、绿=招领(已收好)、墨=中性操作、黄=胶带高亮

设计意义: 票根是校园生活中常见的物理载体(食堂饭票、借书条等),用这个隐喻让用户产生熟悉感。图章动效在状态更新时给予强烈视觉反馈,减少"我点了吗"的疑惑。

4.2 实现思路

票根结构:

┌───────────────────────────┐ ┌────────┐
│  [图标]  物品名称          │ │ 印章   │
│         📍 地点            │ │ OPEN   │
│         [寻物] [校园卡]     │ │        │
│                            │ │ #04    │ ← 编号
├───────────────────────────┤ └────────┘
                    ↑ 虚线撕边 + 打孔

用 CSS 伪元素 ::after 实现虚线,.punch 用 border-radius:50% + 背景色模拟打孔。

图章动效 CSS:

@keyframes stampIn{
  0%   { transform: rotate(-8deg) scale(2.1); opacity: 0; }
  55%  { transform: rotate(-8deg) scale(.92); opacity: 1; }
  75%  { transform: rotate(-8deg) scale(1.05); }
  100% { transform: rotate(-8deg) scale(1); opacity: .9; }
}
.stamp.hit { animation: stampIn .5s cubic-bezier(.3,1.4,.5,1); }

联系方式模糊防爬取: 非发布者查看详情时,联系方式以 filter:blur(4.5px) 模糊显示,需点击「显示」按钮才展开。这既保护了隐私,又增加了一步操作门槛,防止脚本批量爬取。

4.3 关键代码片段

联系方式模糊显示:

// app.js — renderDetail() 中
'<div class="val ' + (revealed||it.mine?'':'blur') + '" id="contactVal">'
+ (esc(it.contact) || '未填写') + '</div>'
/* style.css */
.contact-card .val.blur {
  filter: blur(4.5px);
  user-select: none;  /* 禁止选中文本 */
}

说明: revealed 变量默认 false,非发布者看到的是模糊文本。点击「显示」按钮调用 revealContact() 设置 revealed = true 并重新渲染,去掉 .blur 类。user-select:none 防止在模糊状态下复制文本。

4.4 实现成果展示

(此处插入截图:首页票根列表、详情页图章、发布成功盖章动画、我的发布状态管理)


五、目录说明和使用说明

5.1 目录组织

校园失物招领/
├── index.html              # 主页面入口(用浏览器打开此文件即可)
├── README.md               # 项目说明文档
├── css/
│   └── style.css           # 全部样式(票根设计系统、响应式)
├── js/
│   ├── icons.js            # SVG 图标路径定义 + svg()/catIcon() 辅助函数
│   ├── storage.js          # 数据层(localStorage 增删改查、编号生成、搜索)
│   ├── utils.js            # 工具函数(状态文字、时间格式化、XSS防护、表单验证)
│   └── app.js              # 主应用(路由、渲染、事件、发布、详情、我的发布)
├── tests/
│   ├── tests.html          # 单元测试运行页(浏览器打开即自动运行)
│   └── tests.js            # 9 组测试用例,45 项断言
└── assets/                 # 图片素材目录

组织原则: 按职责分层——css/ 放样式、js/ 按功能拆分四个模块(图标/数据/工具/应用逻辑)、tests/ 独立测试目录。文件命名见名知意。

5.2 运行方式

  1. 下载/克隆本项目全部文件
  2. 用谷歌浏览器打开 index.html
  3. 即可使用全部功能,数据保存在浏览器本地

运行单元测试:用谷歌浏览器打开 tests/tests.html,页面自动运行全部测试并显示结果。


六、单元测试

6.1 测试工具与学习过程

我们选用了自研轻量浏览器测试框架(不依赖 Node.js/npm),原因:

  • 项目要求"下载文件后用浏览器打开 HTML 即可运行",不能要求测试人员安装 Node.js 环境
  • 前端 JS 代码本身就是浏览器环境运行的,测试也应该在浏览器中执行

简易教程(3 步上手浏览器单元测试):

第 1 步: 引入测试框架和被测代码

<!-- tests.html -->
<script src="../js/icons.js"></script>
<script src="../js/storage.js"></script>
<script src="../js/utils.js"></script>
<script src="tests.js"></script>

第 2 步: 编写测试用例

TestRunner.test('测试名称', function() {
  TestRunner.assert(条件, '描述');
  TestRunner.assertEqual(实际值, 期望值, '描述');
});

第 3 步: 运行测试

runAllTests(); // 在页面加载时自动执行,结果渲染到 DOM

核心 API 只有三个:test(名称, 函数) 分组、assert(条件, 描述) 断言真值、assertEqual(实际, 期望, 描述) 断言相等。

6.2 测试代码展示

测试 1:数据存储 —— 新增与查询

T.test('1. 数据存储:新增记录并查询', function () {
  DB = [];  // 重置为空数据库
  var item = addItem({
    type:'lost', cat:'校园卡', title:'测试校园卡',
    place:'图书馆', time:'2026-10-10T10:00',
    desc:'测试', contact:'微信test'
  });
  T.assert(item.id !== undefined, '新增后应返回带 id 的记录');
  T.assertEqual(item.title, '测试校园卡', '名称应正确保存');
  T.assertEqual(item.status, 'open', '初始状态应为 open');
  T.assertEqual(item.mine, true, '新发布记录 mine 应为 true');
  var found = getItem(item.id);
  T.assert(found !== null, 'getItem 应能查到刚新增的记录');
});

测试函数说明: 测试 addItem() 是否正确创建记录(含 id、默认值),以及 getItem() 是否能按 id 查回。

测试 4:搜索与过滤

T.test('4. 搜索与过滤:关键词、类型', function () {
  DB = [ /* 5 条测试数据,含2条"校园卡" */ ];
  var r1 = searchData('校园卡', 'all');
  T.assertEqual(r1.length, 2, '搜索"校园卡"应返回2条');
  var r2 = searchData('校园卡', 'lost');
  T.assertEqual(r2.length, 2, '寻物类型下搜"校园卡"应2条');
  var r3 = searchData('校园卡', 'found');
  T.assertEqual(r3.length, 0, '招领类型下搜"校园卡"应0条');
  var r4 = searchData('图书馆', 'all');
  T.assertEqual(r4.length, 1, '搜索地点"图书馆"应1条');
  var r6 = searchData('', 'all');
  T.assertEqual(r6.length, 5, '空关键词返回全部5条');
});

测试函数说明: 测试 searchData() 的关键词模糊匹配和类型过滤,覆盖正常搜索、类型交叉、空关键词等场景。

测试 6:HTML 转义(XSS 防护)

T.test('6. HTML转义:防止 XSS 注入', function () {
  var malicious = '<script>alert("xss")<\/script>';
  var escaped = esc(malicious);
  T.assert(escaped.indexOf('<script>') === -1, '转义后不应含 <script> 标签');
  T.assert(escaped.indexOf('&lt;') !== -1, '应将 < 转义为 &lt;');
  T.assertEqual(esc(''), '', '空字符串应返回空');
  T.assertEqual(esc('normal text'), 'normal text', '普通文本不改变');
});

测试函数说明: 测试 esc() 函数是否能正确转义 HTML 特殊字符,防止 XSS 攻击。

6.3 测试数据构造思路

1. 正常路径(Happy Path): 用合法数据验证功能正常工作。如测试 3 中 validatePublish({type:'found', title:'钥匙', place:'食堂', ...}) 应返回 0 个错误。

2. 边界值: 名称长度刚好 20 字符(合法)vs 21 字符(超长),空字符串 vs 仅空格。

3. 异常输入: 非法类型 type:'invalid'、空字段、null/undefined、特殊字符(XSS payload <script>)。

4. 组合场景: 搜索 + 类型过滤交叉("校园卡"在寻物下 2 条、在招领下 0 条)、状态切换 open→closed→open 往返。

5. 防刁难考量:

  • 测试人员可能输入超长字符串 → 名称限制 20 字符并测试
  • 测试人员可能在描述中输入 HTML → esc() 转义并测试
  • 测试人员可能反复切换状态 → 测试 open→closed→open 往返
  • 测试人员可能删除后再查询同 id → 测试 getItem 返回 undefined
  • 测试人员可能批量发布 → 测试 10 条记录编号全部唯一

七、Github 代码签入记录

image-20261010194915665

主要 commit 记录:

  • init: 项目初始化,创建目录结构
  • feat: 实现数据存储层 storage.js
  • feat: 实现工具函数 utils.js
  • feat: 实现主应用逻辑 app.js
  • feat: 完成票根设计系统 CSS
  • test: 添加 9 组单元测试
  • docs: 完善 README
  • fix: 修复搜索大小写敏感问题
  • fix: 修复编号生成重复风险

八、遇到的代码模块异常及解决方法

问题 1:file:// 协议下浏览器测试受阻

描述: 用 file:// 协议直接打开 index.html 时,浏览器安全策略阻止部分功能(如 fetch、clipboard API),且自动化测试工具无法访问本地文件。

尝试: 尝试用 file:// 直接运行,发现 navigator.clipboard 在非安全上下文下不可用。

解决: 在 copyContact() 中添加降级处理:先检测 window.isSecureContext,不可用时直接执行回调(提示已复制),不报错。测试时用 python -m http.server 启动本地服务器绕过 file:// 限制。

收获: 理解了浏览器安全上下文(Secure Context)的概念,学会了为 API 调用编写降级方案。

问题 2:DOM 事件委托中 onclick 字符串拼接

描述: 票根卡片用字符串模板生成 HTML,其中 onclick="openDetail('id')" 需要拼接 ID。如果 ID 含特殊字符,会导致 HTML 解析错误或 XSS。

尝试: 最初直接用 + it.id + 拼接,发现当数据含引号时会断裂。考虑过改用 addEventListener 事件委托,但动态生成的元素需要重新绑定。

解决: 使用 data-id 属性 + 事件委托模式。在 ticketHTML 中输出 data-id="id",点击时通过 e.target.closest('.ticket').dataset.id 获取 ID。同时对 ID 值经过 esc() 转义。

收获: 理解了 innerHTML + 事件委托的最佳实践,避免了内联 onclick 的安全风险。

问题 3:CSS 动画重复触发不生效

描述: 状态更新时给图章添加 .hit 类触发盖章动画,但连续点击第二次时动画不播放(因为类已存在)。

尝试: 尝试用 setTimeout 延迟移除类,但有闪烁。

解决: 使用 void el.offsetWidth 强制浏览器重排(Reflow),然后再添加类:

stamp.classList.remove('hit');
void stamp.offsetWidth;  // 强制重排,重置动画
stamp.classList.add('hit');

收获: 学了浏览器渲染流水线中 Reflow 的作用,以及如何利用它重置 CSS 动画。


九、评价自己

值得表扬的地方

在需求分析和原型设计阶段展现了很强的产品思维,能从用户角度出发设计交互流程。"票根"这个视觉概念非常有创意。在 CSS 方面功底扎实,票根的打孔、虚线、图章旋转等细节都是纯 CSS 实现的,没有用任何图片素材。代码提交习惯好,commit message 清晰规范。

需要改进的地方

在数据层设计初期,全局变量 DB 的使用有些随意,导致单元测试时需要手动保存和恢复 DB 的状态。如果一开始就采用模块化封装(如 IIFE 或 ES Module),测试会更干净。另外,部分函数缺少参数类型说明,阅读时需要猜测数据结构。建议后续添加 JSDoc 注释。

posted @ 2026-10-10 20:03  吕贵兴  阅读(4)  评论(0)    收藏  举报