第四次软工作业
【软件工程结对作业二】校园失物招领系统的设计与实现(Web)
本文为 2026 秋《软件工程》第二次结对作业——程序实现的结项博客。
作业要求:基于第一次结对作业的原型,完成「发布信息—浏览/搜索—查看详情—联系发布者—更新状态」核心闭环,不做后台、实名认证、即时聊天与地图定位。
〇、作业链接信息
| 项目 | 链接 / 信息 |
|---|---|
| 成员 A(仓库拥有者)博客园 | https://www.cnblogs.com/gaoyipeng |
| 成员 B(Fork + Pull Request)博客园 | https://home.cnblogs.com/u/lamelo |
| 本篇作业博客地址 | https://www.cnblogs.com/gaoyipeng/p/23253907 |
| GitHub 仓库 | https://github.com/Gaoyypp/102401330-102401331 |
| 成员 | A:高一鹏(学号 102401330,GitHub:Gaoyypp);B:林明亮(学号 102401331,GitHub:lameeloo) |
已在班级群结对表格中填写上述 GitHub 项目地址(deadline1 前完成)。
一、具体分工
| 成员 | 主要负责内容 |
|---|---|
| 成员 A(102401330) | 项目脚手架与目录规范、storage.js 数据访问层、首页信息墙(搜索/筛选/统计/卡片)、发布表单页、Git 仓库与提交管理、README 编写 |
| 成员 B(102401331) | utils.js 核心纯函数(校验/搜索/时间/XSS)、详情页与状态更新闭环、「我的发布」页面、Mocha 单元测试与 Node 运行器、走查测试与截图、博客整理 |
| 共同完成 | 需求复盘、附加特点头脑风暴、数据模型设计、代码互审(Code Review)、结对调试与最终走查 |
协作方式:成员 A 在 GitHub 创建主仓库,成员 B Fork 后通过 Pull Request 提交;开发中两人共用一套提交信息规范(feat/test/docs/chore: 中文描述),每完成一个可运行的功能即提交一次。
二、PSP 个人开发流程记录
开工前共同估计各阶段耗时,完工后回填实际耗时。单位:分钟。
| PSP2.1 阶段 | 具体内容 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning · 计划 | 明确作业范围,确定 Web 纯前端方案 | 30 | 35 |
| Estimate · 估计任务时间 | 拆分功能点并估计总工作量 | 20 | 20 |
| Development · 开发 | |||
| Analysis · 需求分析(含学习新技术) | 复盘第一次原型、确定不做的功能边界;学习 Mocha/Chai 与 localStorage | 120 | 150 |
| Design Spec · 生成设计文档 | 确定数据模型、页面流转、函数划分 | 40 | 35 |
| Design Review · 设计复审 | 互审设计,补上状态闭环的入口与权限问题 | 20 | 15 |
| Coding Standard · 代码规范 | 约定命名、目录、提交信息格式 | 20 | 20 |
| Design · 具体设计 | 纯函数层 / 数据层 / 页面层三层设计 | 60 | 70 |
| Coding · 具体编码 | 四个页面 + 七个 JS 文件 + 样式 | 300 | 360 |
| Code Review · 代码复审 | 互查逻辑、XSS、边界条件 | 40 | 50 |
| Test · 测试(自测、改代码、提交) | 35 个单元测试 + 浏览器全流程走查 + 修 Bug | 90 | 120 |
| Reporting · 报告 | |||
| Test Report · 测试报告 | 整理测试结果与截图 | 30 | 35 |
| Size Measurement · 计算工作量 | 统计代码规模与 PSP 偏差 | 15 | 15 |
| Postmortem & Process Improvement · 事后总结 | 分析超时环节与改进计划 | 30 | 30 |
| 合计 | 815(约 13.6 小时) | 955(约 15.9 小时) |
偏差分析:
- 需求分析 +30 分钟:单元测试部分比预想难,Mocha 的浏览器引入、Chai 断言风格花了额外时间看教程。
- 编码 +60 分钟:图片压缩、发布者鉴权、状态按钮文案都出现过返工(见第九节)。
- 测试 +30 分钟:原计划只测「正常路径」,后来按白盒方法补了边界值、异常输入、越权操作等用例,从十几个扩到 35 个。
- 经验:下次会把「走查异常路径」和「测试用例设计」单独估时间,而不是塞进编码里。
三、解题思路与设计实现说明
3.1 代码实现思路(文字描述)
第一次原型作业暴露的核心问题是「重页面、轻闭环」,所以本次实现先抓住一条主线:
发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态
围绕这条主线做了三个关键决策:
- 纯前端 + localStorage,零环境依赖。作业要求「下载所有文件后用 Chrome 打开 HTML 就能看到预期结果」,因此不引入后端、数据库和构建工具,原生 HTML/CSS/JS 即可运行;数据用浏览器
localStorage持久化。 - 三层结构,把可测的逻辑从页面里抽出来:
js/utils.js:纯函数层,不碰 DOM 和存储——表单校验、搜索筛选排序、时间格式化、HTML 转义等,输入确定则输出确定,单元测试可以直接覆盖;js/storage.js:数据访问层,封装 localStorage 的增删改查、发布者标识与状态鉴权,并支持注入内存存储(测试用);- 页面 JS(
app/publish/detail/my.js):视图层,只负责渲染和事件绑定。
- 状态闭环有明确的「谁来改、从哪改、改完别人看到什么」:首次访问自动生成本机发布者标识
publisherId,只有信息的发布者能把状态从「进行中」改为「已找到/已归还」;结束后的信息在列表中置灰沉底,详情页显示「请勿重复联系」横幅,也支持误操作后重新开放。
数据模型(一条信息):
{
id: 'LF20261009185747YT1Y', // 信息编号:LF + 时间 + 随机串
type: 'lost' | 'found', // 寻物启事 / 失物招领
itemName: '黑色皮质钱包', // 物品名称
category: 'card' | 'electronics' | 'books' | 'keys' | 'clothing' | 'other',
location: '图书馆一楼大厅', // 遗失/拾取地点
eventDate: '2026-10-09', // 遗失/拾取日期
description: '……', // 详细描述
image: 'data:image/jpeg;…', // 可选,压缩后的图片
contactName: '测试同学',
contact: '13912345678',
status: 'active' | 'resolved',
createdAt: 1728456000000,
resolvedAt: null,
publisherId: 'Uxxxx' // 发布者标识(本机自动生成)
}
3.2 关键流程图与数据流图
(1)核心业务流程图
(2)数据流与三层架构图
(3)信息状态转换图
(4)发布表单校验流程
3.3 重要代码片段与解释
片段一:发布信息整体校验(js/utils.js,单元测试重点)
function validatePost(data) {
var errors = [];
data = data || {};
if (TYPE_KEYS.indexOf(data.type) === -1) {
errors.push('请选择信息类型(寻物启事或失物招领)');
}
var itemName = String(data.itemName || '').trim();
if (!itemName) {
errors.push('物品名称不能为空');
} else if (itemName.length > 30) {
errors.push('物品名称不能超过 30 个字');
}
/* 类别、地点、日期、描述、联系人同理…… */
if (!String(data.contact || '').trim()) {
errors.push('联系方式不能为空');
} else if (!isValidContact(data.contact)) {
errors.push('联系方式格式不正确,请填写手机号 / QQ号 / 微信号 / 邮箱,或“微信:账号”形式');
}
return { valid: errors.length === 0, errors: errors };
}
解释:校验函数不直接操作 DOM,而是返回 {valid, errors} 结构。这样发布页可以把错误逐条映射回对应字段标红,测试也可以直接断言错误数量与内容。联系方式单独抽出 isValidContact,支持手机号、QQ、微信号、邮箱以及「微信:账号」这类带标签写法,贴合校园真实使用习惯。
片段二:多关键词搜索 + 组合筛选 + 排序(js/utils.js)
function filterPosts(posts, cond) {
cond = cond || {};
var result = (posts || []).slice();
if (cond.type && cond.type !== 'all') {
result = result.filter(function (p) { return p.type === cond.type; });
}
if (cond.category && cond.category !== 'all') {
result = result.filter(function (p) { return p.category === cond.category; });
}
/* status、location 同理…… */
if (cond.keyword && String(cond.keyword).trim()) {
result = result.filter(function (p) { return matchKeyword(p, cond.keyword); });
}
// 进行中优先;同状态下新发布在前
result.sort(function (a, b) {
if (a.status !== b.status) return a.status === 'active' ? -1 : 1;
return (b.createdAt || 0) - (a.createdAt || 0);
});
return result;
}
其中多关键词采用 AND 语义(空格分隔的每个词都要命中),搜索范围覆盖物品名称、地点、描述、联系人:
function matchKeyword(post, keyword) {
var kw = String(keyword || '').trim().toLowerCase();
if (!kw) return true;
var terms = kw.split(/\s+/).filter(Boolean);
var haystack = [post.itemName, post.location, post.description, post.contactName]
.join(' ').toLowerCase();
return terms.every(function (t) { return haystack.indexOf(t) !== -1; });
}
片段三:新增信息与「仅发布者可改状态」的鉴权(js/storage.js)
function add(input) {
var result = AppUtils.validatePost(input);
if (!result.valid) return { ok: false, errors: result.errors };
var posts = getAll();
var post = {
id: AppUtils.generateId(),
/* ……字段来自 input…… */
status: 'active',
createdAt: Date.now(),
resolvedAt: null,
publisherId: getUserId() // 首次访问自动生成的本机标识
};
posts.unshift(post);
saveAll(posts);
return { ok: true, post: post };
}
function updateStatus(id, status) {
if (['active', 'resolved'].indexOf(status) === -1) {
return { ok: false, error: '状态值不合法' };
}
var posts = getAll();
var target = posts.filter(function (p) { return p.id === id; })[0];
if (!target) return { ok: false, error: '信息不存在' };
if (target.publisherId !== getUserId()) {
return { ok: false, error: '只有发布者可以修改状态' };
}
target.status = status;
target.resolvedAt = status === 'resolved' ? Date.now() : null;
saveAll(posts);
return { ok: true, post: target };
}
解释:纯前端没有登录系统,我们用 localStorage 中首次访问自动生成的随机 publisherId 标识「本机发布者」,从数据层拦住非本人的状态修改;非法状态值、不存在的编号也都返回结构化错误,而不是直接崩溃。
片段四:一键复制联系方式(js/common.js,附加特点)
function fallbackCopy(text) {
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
window.copyText = function (text, okMsg) {
function done() { toast(okMsg || '已复制到剪贴板', 'success'); }
function fail() { toast('复制失败,请手动选择文本复制', 'error'); }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(function () {
fallbackCopy(text) ? done() : fail();
});
} else {
fallbackCopy(text) ? done() : fail();
}
};
解释:file:// 直接打开页面时部分浏览器会禁用 Clipboard API,因此做了 execCommand('copy') 降级;复制内容同时包含联系人、联系方式、物品名和信息编号,方便对方核对(详情页按钮一键复制整段)。
四、附加特点设计与展示
4.1 创意点与意义
| # | 特点 | 解决的问题(意义) |
|---|---|---|
| 1 | 一键复制联系方式 | 联系失主/拾主时要手动对照输入,还常忘报是哪件物品;一键复制「联系人+方式+物品+编号」,减少出错 |
| 2 | 多维筛选 + 关键词高亮 + AND 搜索 | 群聊信息刷屏难找;按类型/类别/状态/地点组合过滤,命中关键词用 <mark> 高亮 |
| 3 | 图片上传本地压缩 | 图片直接存 localStorage 容易超出 5MB 配额;用 Canvas 压到最长边 800px、JPEG 0.72 质量 |
| 4 | 完整的状态闭环与权限控制 | 明确「谁能改、从哪改、改完别人看到什么」,结束信息沉底并提示勿重复联系,直击作业指出的痛点 |
| 5 | 字段级表单校验 + 错误定位 | 空输入、超长、未来日期、乱填联系方式都会被拦,并自动滚动到第一处错误 |
| 6 | 空状态引导、统计概览、相对时间、响应式布局 | 无结果/无发布时给明确引导;首页实时统计总数/寻物/招领/已结束;适配手机宽度 |
| 7 | XSS 防护 | 所有用户输入渲染前先 HTML 转义,搜索高亮在转义之后进行,避免 <script> 注入 |
4.2 实现思路(以图片压缩和 XSS 高亮为例)
图片压缩用 FileReader 读出图片 → Image 加载 → 按最长边等比缩放到 canvas → 导出 JPEG DataURL:
window.compressImage = function (file, opt) {
var maxSize = (opt && opt.maxSize) || 800;
var quality = (opt && opt.quality) || 0.72;
return new Promise(function (resolve, reject) {
if (!file || !/^image\//.test(file.type)) return reject(new Error('请选择图片文件'));
if (file.size > 8 * 1024 * 1024) return reject(new Error('图片不能超过 8MB'));
var reader = new FileReader();
reader.onload = function (e) {
var img = new Image();
img.onload = function () {
var w = img.width, h = img.height;
if (w > maxSize || h > maxSize) {
if (w >= h) { h = Math.round(h * maxSize / w); w = maxSize; }
else { w = Math.round(w * maxSize / h); h = maxSize; }
}
var canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
};
搜索高亮先转义再包裹 <mark>,从根上杜绝 XSS:
function highlight(text, keyword) {
var safe = escapeHtml(text); // 第一步:转义
var terms = String(keyword || '').trim().split(/\s+/).filter(Boolean).map(escapeHtml);
terms.forEach(function (t) {
safe = safe.replace(
new RegExp('(' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'),
'<mark>$1</mark>'); // 第二步:高亮
});
return safe;
}
4.3 成果展示
` 首页信息墙(统计卡片 + 搜索筛选 + 寻物橙/招领蓝卡片 + 已结束沉底)
` 发布表单(类型大按钮、图片上传区)
` 进行中详情页(一键复制、发布者操作区)
` 已结束详情页(绿色防重复联系横幅、结束时间、重新开放)
` 我的发布(标记已找到/已归还)
` 搜索无结果空状态
五、目录说明与使用说明
5.1 目录如何组织
campus-lost-found/
├── index.html # 首页:统计、搜索筛选、信息卡片列表
├── publish.html # 发布页:寻物/招领表单、校验、图片上传
├── detail.html # 详情页:完整信息、复制联系方式、状态更新
├── my-posts.html # 我的发布:本人信息管理
├── css/
│ └── style.css # 全局样式(卡片、徽章、表单、响应式)
├── js/
│ ├── utils.js # 纯函数:校验/筛选/排序/转义/时间/统计(测试重点)
│ ├── storage.js # 数据访问层:CRUD、发布者标识、鉴权、演示数据
│ ├── common.js # 公共 UI:Toast、一键复制、图片压缩
│ ├── app.js # 首页逻辑
│ ├── publish.js # 发布页逻辑
│ ├── detail.js # 详情页逻辑
│ └── my.js # 我的发布逻辑
├── assets/
│ └── logo.svg # 站点图标
├── docs/screenshots/ # 7 张运行截图
├── test/
│ ├── tests.html # 浏览器单元测试页(Mocha+Chai,离线可开)
│ ├── tests.js # 35 个测试用例(浏览器/Node 共用)
│ ├── node-runner.js # 命令行零依赖运行器
│ └── lib/ # 本地化的 mocha.js / mocha.css / chai.js
├── .gitignore
└── README.md
设计原则:纯函数层(utils)不依赖 DOM,数据层(storage)可注入内存存储,因此核心业务逻辑无需浏览器即可被单元测试直接引用。
5.2 测试人员如何运行
运行网页(推荐,作业要求方式):
- 下载仓库全部文件(
git clone或下载 ZIP 解压),保持目录结构不变; - 用 Google Chrome 直接双击根目录
index.html,无需安装任何环境、无需联网; - 首次打开自动注入 6 条演示数据(其中 2 条属于本机发布者,便于体验「我的发布」与状态更新)。
运行单元测试:
- 浏览器:双击打开
test/tests.html,顶部显示绿色通过数; - 命令行(可选):在项目根目录执行
node test/node-runner.js,全部通过时退出码为 0。
推荐体验路径:首页搜索「校园卡」→ 切换寻物/招领 Tab → 发布页直接点提交看校验 → 完整填写并发布 → 详情页一键复制 → 我的发布标记「已找到/已归还」→ 回首页看信息沉底 → 搜不存在的词看空状态。
六、单元测试
6.1 测试工具选型与自学简易教程
工具:Mocha(测试框架,提供 describe/it 组织结构与红绿结果)+ Chai(断言库,本项目用 assert 断言风格)。
为什么选它:作业附录推荐了 Mocha,教程多;它可以直接在浏览器里跑,适合纯前端项目;我们把框架文件下载到 test/lib/ 实现完全离线,助教断网也能打开测试页。
学习路径(廖雪峰 Mocha 教程、阮一峰《测试驱动开发》《Mocha 实例教程》,均为作业附录链接):
- 理解「测试框架 = 用例容器 + 结果展示,断言库 = 判断对错」;
- 浏览器版三步搭起来:
<!-- test/tests.html 骨架 -->
<link rel="stylesheet" href="lib/mocha.css">
<div id="mocha"></div>
<script src="lib/mocha.js"></script>
<script src="lib/chai.js"></script>
<script>mocha.setup('bdd');</script>
<script src="../js/utils.js"></script> <!-- 被测源码 -->
<script src="../js/storage.js"></script>
<script src="tests.js"></script> <!-- 用例 -->
<script>mocha.run();</script>
- 一个最小用例长这样:
describe('加减法(演示)', function () {
it('1 + 1 应该等于 2', function () {
assert.equal(1 + 1, 2);
});
});
- 为了让测试能进每日构建自动跑,我们又写了零依赖的
test/node-runner.js:用 Node 内置assert实现与 Mocha 兼容的describe/it最小接口,浏览器和命令行共用同一份tests.js,避免维护两套用例。命令行结果:
测试结果:通过 35 个,失败 0 个,共 35 个
✅ 全部测试通过
6.2 部分测试代码与被测函数说明
describe('【6】validatePost 发布信息整体校验', function () {
it('完整合法数据校验通过', function () {
var r = AppUtils.validatePost(validPost());
assert.equal(r.valid, true);
assert.equal(r.errors.length, 0);
});
it('缺少必填字段时逐条报错', function () {
var r = AppUtils.validatePost({});
assert.equal(r.valid, false);
['信息类型','物品名称','物品类别','地点','日期','详细描述','联系人称呼','联系方式']
.forEach(function (kw) {
assert.ok(r.errors.some(function (e) { return e.indexOf(kw) !== -1; }),
'应包含“' + kw + '”错误');
});
});
it('日期不能晚于今天、格式必须合法', function () {
var r1 = AppUtils.validatePost(validPost({ eventDate: '2099-01-01' }));
assert.ok(r1.errors.some(function (e) { return e.indexOf('晚于今天') !== -1; }));
var r2 = AppUtils.validatePost(validPost({ eventDate: '10/09/2026' }));
assert.ok(r2.errors.some(function (e) { return e.indexOf('日期格式') !== -1; }));
});
});
describe('【9】Store 数据访问层(内存存储)', function () {
var mem = Store.memoryStorage();
var store = Store.createStore(mem, { postsKey: 'test_posts', userKey: 'test_user' });
it('非发布者无权修改状态(换一个用户标识)', function () {
var r = store.add(validPost({ itemName: '别人的校园卡' }));
var other = Store.createStore(mem, { postsKey: 'test_posts', userKey: 'test_user_other' });
var res = other.updateStatus(r.post.id, 'resolved');
assert.equal(res.ok, false);
assert.ok(res.error.indexOf('发布者') !== -1);
assert.equal(store.getById(r.post.id).status, 'active'); // 原状态不变
});
});
- 第一段测
validatePost:合法基线、空对象、非法日期三类输入; - 第二段测
updateStatus的越权防护:换一个用户标识去改别人的信息,必须失败且原状态不变。
6.3 测试数据构造思路(如何考虑各种情况与「刁难」)
采用白盒测试的等价类划分 + 边界值分析,并以「一条合法基线数据 + 覆盖(override)派生异常数据」组织:
function validPost(over) {
var base = {
type: 'lost', itemName: '校园卡', category: 'card',
location: '图书馆三楼', eventDate: AppUtils.todayStr(),
description: '卡面贴有哆啦A梦贴纸的校园卡,测试用描述。',
image: '', contactName: '张同学', contact: '13800138000'
};
Object.keys(over || {}).forEach(k => base[k] = over[k]);
return base;
}
35 个用例覆盖的「刁难」场景:
| 类型 | 具体场景 |
|---|---|
| 空值与缺失 | 空对象提交、各必填字段单独留空 |
| 边界长度 | 物品名 30/31 字、描述 4/5 字、联系人 10/11 字、联系方式上下限 |
| 非法格式 | 未来日期、10/09/2026 错误日期格式、abc 这类无效联系方式 |
| 多格式合法值 | 手机号 / QQ / 微信 / 邮箱 / 「微信:xxx」「QQ: 12345」均要通过 |
| 枚举篡改 | type:'hacked'、category:'weapon' 必须被拒 |
| 搜索 | 多关键词 AND、命中描述/地点、无结果空数组、组合筛选、排序规则 |
| 时间 | 刚刚/分钟/小时/天/7 天后回退日期、未来时间戳不得出现负数 |
| 安全 | <script> 经转义不可执行、高亮不得产生新标签 |
| 权限与异常 | 非发布者改状态、不存在的编号、非法状态值、空库才注入种子且不覆盖用户数据 |
自评:35 个用例覆盖了核心纯函数和数据层的正常、边界、异常与权限路径,满足本程序的测试要求;未覆盖的是浏览器事件绑定与 Canvas 压缩(强依赖 DOM),这部分通过手工全流程走查 + 截图验证(见第四节)。
浏览器 Mocha 测试页 35/35 全绿。
七、GitHub 代码签入记录
仓库地址:https://github.com/Gaoyypp/102401330-102401331
按功能划分提交,每完成一个可运行功能即 commit 一次,提交信息遵循 Conventional Commits:
| 提交信息 | 对应功能 |
|---|---|
chore: 初始化项目目录结构与忽略规则 |
目录脚手架、.gitignore、站点图标 |
feat: 新增核心工具函数与 localStorage 数据访问层 |
utils.js + storage.js |
test: 搭建 Mocha+Chai 测试框架并补充 35 个单元测试 |
test/ 全部 |
feat: 完成首页信息墙(统计概览、搜索筛选、卡片列表) |
首页 + 全局样式 + 公共脚本 |
feat: 实现寻物/招领信息发布表单 |
发布页 |
feat: 实现详情页与状态更新闭环 |
详情页 |
feat: 实现我的发布页面与状态管理 |
我的发布 |
docs: 完善 README 使用说明与运行截图 |
README + 截图 |
docs: 补全结对学号与 GitHub 仓库地址 |
结对信息 |
成员 B 通过 Fork 仓库 → 修改推送 → Pull Request 的方式协作,A 审核后合并。
GitHub 仓库 commits 页面(按日期分组的提交记录);
成员 B 的 Pull Request 页面(合并后状态为 Merged)。
八、遇到的困难与解决方法
困难 1:详情页把「进行中」当成了结束按钮文案
- 问题描述:详情页发布者按钮一度显示「标记为『进行中』」,语义完全反了——进行中是当前状态,按钮应该引导结束(已找到/已归还)。
- 尝试:最初复用了
statusLabel(post)(它在进行中返回"进行中"、结束后返回"已找到/已归还"),没区分「当前状态」和「结束动作」两个概念。 - 是否解决:✅ 已解决。把结束动作文案按类型固定为
isLost ? '已找到' : '已归还',当前状态文案单独用statusLabel,并补了状态文案相关单元测试防止回归。 - 收获:状态机里「状态名」和「动作名」是两回事,命名和测试都要把它们分开。
困难 2:已结束卡片的水印和类别图标重叠
- 问题描述:列表卡片结束后要显示「已找到」半透明水印,但水印和中间的大 Emoji 图标叠在一起显得杂乱。
- 尝试:先调透明度,效果仍不理想;最后用 CSS
:has(.badge-resolved-corner)让结束卡片缩略图隐藏 Emoji、只保留水印,角落类型徽章不变。 - 是否解决:✅ 已解决(见首页截图第六张卡片)。
- 收获:界面画出来和用户看着舒服是两件事,状态变化要有明确但不干扰的视觉层级。
困难 3:纯前端没有登录,怎么保证「只有发布者能改状态」
- 问题描述:不做实名认证和后台,但谁都能改状态会让信息墙失控。
- 尝试:评估过输入口令、简单密码等方案,都增加使用负担;最终采用首次访问自动生成
publisherId存 localStorage 的方案,数据层updateStatus强制校验归属,单元测试中专门用「第二个用户标识」验证越权会被拒绝。 - 是否解决:✅ 已解决。同时在 README 中诚实说明这是本机标识、不具备跨设备安全性(需要后端才能做到),守住作业范围。
- 收获:权限可以在数据层兜底;也要清楚方案边界并写进文档,不夸大。
困难 4:单元测试既要浏览器看、又想命令行自动跑
- 问题描述:Mocha 浏览器页适合展示,但每日构建希望命令行一条命令跑完。
- 尝试:不想维护两份用例,于是把用例写成接收
describe/it/assert依赖的工厂形式;再写约 40 行的node-runner.js,用 Node 内置assert模拟 Mocha 接口,同一份 tests.js 两处运行。 - 是否解决:✅ 已解决,35 个用例在浏览器和 Node 下结果一致。
- 收获:把框架依赖通过参数注入,核心用例就能跨环境复用。
困难 5:图片塞进 localStorage 可能超配额
- 问题描述:手机拍的照片动辄 3~5MB,几张就撑爆 localStorage 约 5MB 的配额。
- 尝试:用 Canvas 在前端把图片等比缩到最长边 800px 并转 JPEG 0.72 质量,体积降到几十 KB;同时限制 8MB 上传、只允许单张。
- 是否解决:✅ 已解决。
- 收获:前端存储是稀缺资源,入库前先压缩;可选功能也要考虑失败提示。
九、评价我的队友
成员 A 视角评价 B(林明亮)
- 值得学习的地方:
- 测试意识强,主动把用例从十几个扩充到 35 个,坚持补「越权改状态」「未来时间戳」这类刁钻场景,让我们后期改代码很有底气;
- 做详情页时把状态闭环的每个入口(谁来改、从哪改、结束后展示什么)都列清楚才动手,逻辑很严密。
- 需要改进的地方:
- 一开始写代码不习惯小步提交,经常攒一大堆再 commit,后期按功能拆分提交后规范了很多;
- 遇到前端 API 兼容性问题(如 Clipboard)有时会直接换方案,建议先查文档确认降级路径。










浙公网安备 33010602011771号