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)方式实现。
架构分层:
路由机制: 用 go(key) 函数切换屏幕,维护 history_ 栈支持返回操作。每次切换时隐藏所有 .screen,显示目标屏幕并添加入场动画(fwd/back),然后调用 renderAll() 刷新数据。
数据流: 所有操作(发布、搜索、状态更新)都经由 storage.js 的函数处理,数据写入 localStorage,渲染层从 DB(内存中的全局数组)读取并生成 HTML。数据层与渲染层完全解耦,便于单元测试。
渲染策略: 采用字符串模板(非框架),ticketHTML() 函数将数据对象拼装为票根卡片 HTML,插入 DOM。所有用户输入经 esc() 转义后再插入,防止 XSS。
3.2 关键流程图
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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
});
}
说明: 所有用户输入的内容在插入 DOM 前都经过 esc() 转义。例如用户在描述中输入 <script>alert('xss')</script>,会被转为 <script>...,作为纯文本显示而非执行。这在票根模板中大量使用: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 运行方式
- 下载/克隆本项目全部文件
- 用谷歌浏览器打开
index.html - 即可使用全部功能,数据保存在浏览器本地
运行单元测试:用谷歌浏览器打开 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('<') !== -1, '应将 < 转义为 <');
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 代码签入记录
主要 commit 记录:
init: 项目初始化,创建目录结构feat: 实现数据存储层 storage.jsfeat: 实现工具函数 utils.jsfeat: 实现主应用逻辑 app.jsfeat: 完成票根设计系统 CSStest: 添加 9 组单元测试docs: 完善 READMEfix: 修复搜索大小写敏感问题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 注释。

浙公网安备 33010602011771号