2026秋软件工程第二次结对作业之程序实现
2026秋软件工程第二次结对作业之程序实现
一、作业信息
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 202601软件工程 |
| 这个作业要求在哪里 | 2026秋软件工程第二次结对作业之程序实现 |
| GitHub 仓库地址 | https://github.com/daezxt/102401603-152402217 |
| 成员1 | 102401603 张馨恬 |
| 成员2 | 152402217 陈霁然 |
二、具体分工
张馨恬负责私信功能、联系申请、账号切换、同义词搜索、字数提示、旧数据迁移、README 文档和单元测试补充,以及github仓库管。
陈霁然负责数据层初版、首页、发布页、搜索页、详情页、我的发布页面和单元测试初版,及时在仓库fork。
两人共同完成需求讨论、代码编写查看和问题排查。
三、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 20 |
| Estimate | 估计这个任务需要多少时间 | 20 | 20 |
| Development | 开发 | 350 | 420 |
| Analysis | 需求分析 | 40 | 45 |
| Design Spec | 生成设计文档 | 30 | 30 |
| Design Review | 设计复审 | 20 | 20 |
| Coding Standard | 代码规范 | 15 | 10 |
| Design | 具体设计 | 40 | 50 |
| Coding | 具体编码 | 200 | 260 |
| Code Review | 代码复审 | 30 | 40 |
| Test | 测试 | 60 | 50 |
| Reporting | 报告 | 90 | 110 |
| Test Report | 测试报告 | 30 | 35 |
| Size Measurement | 计算工作量 | 15 | 12 |
| Postmortem & Process Improvement Plan | 事后总结与改进计划 | 30 | 45 |
| 合计 | 990 | 1167 |
四、解题思路描述与设计实现说明
4.1 代码实现思路
本项目采用纯前端实现,不依赖后端、第三方框架和构建步骤,用浏览器直接打开 index.html 即可运行。数据存储在浏览器的 localStorage 中,所有页面共享同一个数据源,实现"发布—浏览—搜索—查看详情—联系发布者—更新状态"的完整闭环。
整体分为三层:
第一层是数据层,负责数据模型定义、种子数据初始化、localStorage 读写、公共函数。
第二层是页面层,包含首页、发布页、搜索页、详情页、我的发布五个页面。
第三层是逻辑层,每个页面有对应的 JS 文件,负责页面渲染、交互和事件绑定。
核心数据模型包含以下字段:id、type、title、location、time、description、contact、contactMode、category、status、publisher、publishTime、icon。
数据流的过程是:用户在页面上操作,触发对应的 JS 逻辑,JS 读写 localStorage 中的统一数组,其他页面读取同一数据源后自动同步。
4.2 关键实现的流程图

主流程为:进入首页,选择浏览、搜索或发布。浏览时查看信息列表,搜索时输入关键词,发布时填写表单。点击卡片进入详情,可以复制联系方式。发布的信息写入 localStorage 后回到首页。在"我的发布"中可以修改状态,修改后同样写回 localStorage,所有页面同步更新。

状态流转为:寻物信息从"寻找中"变为"已找到",招领信息从"待认领"变为"已归还"。

数据流为: data.js 的种子数据初始化到 localStorage,分为信息、联系申请、当前用户、私信会话四个部分。六个页面都从 localStorage 读写,实现一处修改、处处同步。其中"当前用户"作为全局状态,决定"哪些信息是我的""哪些私信我能看到"。
4.3 重要代码片段及解释
片段一:所有页面都通过 getAllItems 读数据,通过 saveAllItems 写数据,保证数据源唯一。同时为了兼容旧版本的数据格式,加了 migrateItems 迁移函数,把旧的字符串时间自动转换成时间戳,避免用户升级后数据丢失。
function getAllItems() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
const seed = createSeedData();
writeItems(seed);
return seed;
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error("数据格式不正确");
const migrated = migrateItems(parsed);
if (migrated.changed) writeItems(migrated.items);
return migrated.items;
} catch (e) {
const seed = createSeedData();
writeItems(seed);
return seed;
}
}
片段二:搜索函数。queryItems 是首页、搜索页共用的查询入口,同时支持按类型、类别、关键词筛选。搜索时通过 expandKeyword 把同义词扩展成一组关键词,只要命中其中一个就算匹配。
function expandKeyword(keyword) {
const kw = String(keyword == null ? "" : keyword).trim().toLowerCase();
if (!kw) return [];
const terms = [kw];
const seen = { [kw]: true };
SEARCH_SYNONYMS.forEach(group => {
const hit = group.some(word => {
const w = word.toLowerCase();
return w.indexOf(kw) !== -1 || kw.indexOf(w) !== -1;
});
if (!hit) return;
group.forEach(word => {
const w = word.toLowerCase();
if (!seen[w]) { seen[w] = true; terms.push(w); }
});
});
return terms;
}
片段三:私信系统。私信以"会话"为单位,每条会话绑定一条信息(itemId)、两个参与者(participants)和一组消息。发送消息时校验长度和会话存在性,成功后更新会话的 updatedAt 并写回 localStorage。
function sendMessage(conversationId, text, sender) {
const content = String(text == null ? "" : text).trim();
if (!content) return { ok: false, message: "请先输入内容" };
if (content.length > LIMITS.message) {
return { ok: false, message: "单条私信最多 " + LIMITS.message + " 个字" };
}
const conversations = readConversations();
const conversation = conversations.find(entry => entry.id === Number(conversationId));
if (!conversation) return { ok: false, message: "会话不存在" };
const message = {
id: nextMessageId(conversation.messages),
from: sender || getCurrentUser(),
text: content,
createdAt: Date.now(),
read: false
};
conversation.messages.push(message);
conversation.updatedAt = message.createdAt;
writeConversations(conversations);
return { ok: true, message: message, conversation: conversation };
}
五、附加特点设计与展示
5.1 创意独到之处及意义
特点一:联系方式公开方式可选
发布者可以选择「公开联系方式」或「仅站内联系」。这解决了一个真实痛点:有些同学愿意公开 QQ/微信方便联系,有些同学不希望手机号被所有人看到,只接受站内联系。这个设计让发布者自己决定信息的公开范围,而不是被平台强制。
特点二:联系申请机制
当发布者选择「仅站内联系」时,其他同学不会直接看到联系方式,但可以发起"申请查看联系方式"。发布者同意后,申请者才能看到。这样既保护了发布者隐私,又给真正想帮忙的人留了通道,比"一刀切不公开"更实用。
特点三:物品类别筛选 + 同义词搜索
首页支持按物品类别(校园卡、电子设备、钥匙、书籍、雨伞、水杯、其他)筛选,且和"寻物/招领"两个维度可以组合使用。搜索支持同义词,输入「水杯」也能搜到「保温杯」,输入「校园卡」也能搜到「一卡通」,提高了搜索命中率,也更贴近日常说话习惯。
特点四:私信系统 + 未读角标
每条信息都支持"私信发布者",以会话为单位管理消息。底部导航有未读消息角标,进入会话后自动标记已读。这样用户不用交换联系方式也能沟通,隐私性和便利性兼顾。
特点五:演示账号切换
因为项目没有后端,无法做真实的登录。我们设计了一个演示账号切换,让"我的发布"、私信、联系申请都能基于当前身份工作。这既解决了"没有后端怎么做身份"的问题,也让助教测试时能切换不同账号,看到不同视角的效果。
5.2 实现思路
- 联系方式公开方式:发布页增加单选按钮,发布时把
contactMode写入数据;详情页根据contactMode和申请状态,决定显示"复制按钮"、"仅站内联系"、"申请查看联系方式"或"已同意"。 - 联系申请机制:在
localStorage中用单独的 key 存储联系申请。申请者发起申请后,发布者在"我的发布"里能看到待处理申请,可以同意或拒绝。详情页通过统一的判断函数决定当前用户能否查看联系方式。 - 物品类别筛选:数据里增加
category字段,类别定义在CATEGORIES常量中。首页和发布页共用这个常量,避免两处维护。首页增加一行分类按钮,渲染时在类型筛选基础上再按类别过滤。 - 同义词搜索:在数据层维护同义词表,搜索时先扩展关键词,再匹配标题、描述和地点。比如「水杯」会扩展成「水杯 / 保温杯 / 杯子 / 水壶」,命中任意一个就算匹配。
- 私信系统:以会话为单位存储在
localStorage中,每条消息带发送者、内容、时间、已读状态。未读数通过遍历消息计算,底部导航的角标统一更新。 - 演示账号切换:把可选账号定义在常量里,当前用户存在
localStorage中。切换后所有页面重新渲染,"是否是我发布的"判断结果随之变化。
5.3 重要代码片段
联系申请机制
联系方式区域没有写死一个固定展示,而是根据 contactMode 和申请状态,分成五种情况:公开、我是发布者、已同意、待同意、未申请。这样详情页不需要写一堆 if-else,只要一个函数就能覆盖所有状态。
function renderContactSection(item) {
if (item.contactMode !== "private") {
return `
<div class="contact-box">
<span class="contact-label">联系方式</span>
<span class="contact-value">${escapeHtml(item.contact)}</span>
</div>
<div class="detail-actions">
${messageButton(item)}
<button type="button" class="btn-secondary" data-action="copy">📋 复制联系方式</button>
</div>
`;
}
if (hasApprovedRequest(item.id)) {
return `
<div class="contact-box">
<span class="contact-label">联系方式</span>
<span class="contact-value">${escapeHtml(item.contact)}</span>
</div>
<p class="contact-note">发布者已同意你的联系申请。</p>
<div class="detail-actions">
${messageButton(item)}
<button type="button" class="btn-secondary" data-action="copy">📋 复制联系方式</button>
</div>
`;
}
// 未申请 / 等待同意 ...
}
字数提示
发布页对物品名称、地点、描述、联系方式都有字数上限和实时字数统计。用户边输入边能看到当前字数,超出上限时数字会变红提示。这比"提交时才报错"更友好,能减少一次无效提交。
function bindCounters() {
const fields = [
{ input: "itemTitle", counter: "titleCount", limit: LIMITS.title },
{ input: "itemLocation", counter: "locationCount", limit: LIMITS.location },
{ input: "itemDesc", counter: "descCount", limit: LIMITS.description },
{ input: "itemContact", counter: "contactCount", limit: LIMITS.contact }
];
fields.forEach(field => {
const input = document.getElementById(field.input);
const counter = document.getElementById(field.counter);
const update = () => {
const length = input.value.trim().length;
counter.textContent = `${length}/${field.limit}`;
counter.classList.toggle("over", length > field.limit);
};
input.addEventListener("input", update);
update();
});
}
私信未读角标
未读数等于所有会话里"不是我发的且未读"的消息总数。底部导航显示所有会话的未读总数,进入某个会话时只标记该会话为已读。这样用户一眼能看出有没有新消息,也不会把别的会话误标为已读。
function getUnreadCount(conversationId, user) {
const who = user || getCurrentUser();
const scope = conversationId === undefined || conversationId === null
? getConversationsForUser(who)
: readConversations().filter(c => c.id === Number(conversationId));
return scope.reduce((total, conversation) =>
total + conversation.messages.filter(m => m.from !== who && !m.read).length, 0);
}
5.4 实现成果展示
- 首页分类筛选(全部 / 校园卡 / 电子设备 / 钥匙 等)

- 发布页联系方式选项 + 字数提示

- 详情页"仅站内联系" + "申请查看联系方式"按钮

- 发布者收到的联系申请(同意 / 拒绝)

- 私信列表页(未读角标)

- 私信会话页(消息气泡)

- 账号切换下拉框

六、目录说明和使用说明
6.1 目录结构
项目根目录下包含五个核心 HTML 页面:index.html 是首页,publish.html 是发布页,search.html 是搜索页,detail.html 是详情页,mine.html 是我的发布。此外还有 messages.html 和 chat.html 用于私信功能。
css 文件夹下存放 style.css,负责所有页面的样式。
js 文件夹下存放各个页面的逻辑文件,以及公共的 data.js 和 ui.js。
assets 文件夹下存放图标素材。
tests 文件夹下存放单元测试,不上传 GitHub。
README.md 是目录说明和使用说明。
6.2 使用说明
运行环境为 Windows、macOS 或 Linux,浏览器推荐 Chrome,无需安装 Node.js、数据库或后端服务。
运行方式一:下载或克隆项目到本地,用 Chrome 打开 index.html。
运行方式二:用 VS Code 打开项目根目录,右键 index.html 选择 Open with Live Server,浏览器访问本地地址。
功能演示路径:浏览时在首页查看信息并按类型或类别筛选;搜索时点击顶部搜索框输入关键词;发布时点击底部导航"发布",选择类型并填表;详情页可以查看完整信息并复制联系方式;修改状态时进入"我的"页面操作。
数据重置:打开 Chrome 开发者工具,在 Console 中执行清除 localStorage 的命令,刷新页面即可恢复种子数据。
七、单元测试
7.1 选用的测试工具与学习
我们选用了 Mocha 和 Chai 作为测试工具。Mocha 是 JavaScript 测试框架,支持 describe 和 it 的 BDD 风格;Chai 是断言库,提供 expect 语法,接近自然语言。
学习方式是参考廖雪峰的 JavaScript 教程和 Mocha 官方文档,在 HTML 中通过 CDN 引入,直接在浏览器中运行测试,不需要安装 Node.js 环境。
简易教程:
- 在
tests/test.html中引入 Mocha 和 Chai 的 CDN。 - 设置
mocha.setup('bdd'),暴露chai.expect。 - 在
tests/test.js中用describe分组、it写用例。 - 引入项目的
data.js。 - 调用
mocha.run()启动测试。
后来在补充私信、联系申请等功能时,我们又把测试运行器改成了一个极简的纯逻辑版本,不依赖真实的 localStorage,直接在浏览器里跑,速度更快,也更容易排查。
7.2 部分单元测试代码
用例一:HTML 转义防 XSS
测试 escapeHtml 函数,验证它能把 <、>、&、"、' 这些特殊字符转义成 HTML 实体。这是防止用户在标题、描述里注入脚本的第一道防线。
test("escapeHtml 会转义 HTML 标签和引号", () => {
assertEqual(escapeHtml("<img src=x onerror=alert(1)>"),
"<img src=x onerror=alert(1)>");
assertEqual(escapeHtml(`O'Brien "同学" & 你`),
"O'Brien "同学" & 你");
});
用例二:同义词扩展
测试 expandKeyword 函数,验证它能把"水杯"扩展成包含"保温杯"的一组关键词。这是同义词搜索的核心逻辑。
test("同义词扩展:搜「水杯」能带上「保温杯」", () => {
const terms = expandKeyword("水杯");
assert(terms.indexOf("水杯") !== -1);
assert(terms.indexOf("保温杯") !== -1, "同义词组没有展开");
assertEqual(expandKeyword(""), []);
});
用例三:统一查询支持多维度筛选
测试 applyQuery 函数,验证它同时支持按类型、类别、状态三种维度筛选,且结果正确。这是首页、搜索页共用的查询入口。
test("applyQuery 支持类型 / 类别 / 状态筛选", () => {
const items = [
makeItem({ id: 1, type: "寻物", category: "card", status: "寻找中" }),
makeItem({ id: 2, type: "招领", category: "device", status: "待认领" }),
makeItem({ id: 3, type: "寻物", category: "card", status: "已找到" })
];
assertEqual(applyQuery(items, { type: "招领" }).map(i => i.id), [2]);
assertEqual(applyQuery(items, { category: "card" }).map(i => i.id).sort(), [1, 3]);
assertEqual(applyQuery(items, { status: "已找到" }).map(i => i.id), [3]);
});
用例四:私信与未读状态
测试 sendMessage、getUnreadCount、markConversationRead 三个函数。先验证空内容、超长内容、不存在的会话会被拒绝;再验证切换身份后,双方各自看到的未读数正确,进入会话后未读能正确清零。
test("发私信:空内容与超长内容被拒绝", () => {
resetAll();
const item = addItem({ type: "招领", title: "钥匙", location: "操场",
description: "d", contact: "c", category: "key" });
const conversation = getOrCreateConversation(item.id);
assertEqual(sendMessage(conversation.id, "").ok, false, "空白内容应该被拒绝");
assertEqual(sendMessage(conversation.id,
"a".repeat(LIMITS.message + 1)).ok, false, "超长内容应该被拒绝");
assertEqual(sendMessage(9999, "在吗").ok, false, "会话不存在应该被拒绝");
});
// 换成对方身份回复
setCurrentUser("王同学");
assertEqual(getUnreadCount(conversation.id), 1, "王同学应该看到 1 条未读");
const reply = sendMessage(conversation.id, "还在,明天给你");
assertEqual(reply.message.from, "王同学");
setCurrentUser("李同学");
assertEqual(getUnreadCount(conversation.id), 1, "李同学应该收到 1 条未读");
assertEqual(markConversationRead(conversation.id), 1, "应该标记 1 条为已读");
assertEqual(getUnreadCount(conversation.id), 0);
7.3 构造测试数据的思路
- 正常路径:种子数据、按 id 查询、新增、搜索、筛选、状态更新。
- 边界情况:空关键词、不存在的 id、不存在的关键词、未知状态、超出字数上限。
- 数据迁移:把旧版的字符串时间(如"今天 08:20")转成时间戳,保证升级后旧数据不丢失。
- 兼容性:同义词搜索("水杯"能搜到"保温杯")、账号切换后"是否是我发布的"判断结果随之变化。
- 安全:所有插入 HTML 的动态文本都经过转义,防止 XSS。
面对刁难时:
- 数据损坏时,读取函数会回退到种子数据。
- 更新不存在的 id 时,返回空值,不改变数据。
- 搜索空关键词时返回全部,避免空搜索无结果。
- 私信的空内容、超长内容、不存在的会话都会被拒绝。
- 所有插入 HTML 的文本都经过转义。
八、GitHub 签入记录


九、遇到的困难及解决
问题一:多个页面重复定义公共函数,导致 search.js 漏定义后报错。尝试在每个文件里分别保留,后来发现容易漏。解决方式是统一放到 data.js 中,其他页面直接调用。收获是理解了公共代码应该放在被所有页面引入的基础文件里。
问题二:Live Server 自动刷新打断发布跳转。尝试把延迟改短、改用 window.location。解决方式是意识到这是开发工具行为,测试时不动编辑器就能正常跳转。收获是区分代码问题和工具行为。
问题三:分类栏在 375px 宽度下显示不全。尝试加横向滚动。解决方式是精简为 5 个高频分类,同时保留滚动能力。收获是移动端设计要在功能完整和界面可用之间做取舍。
十、队友评价
值得学习的地方:陈霁然在项目初期主动承担了数据层的搭建,把 data.js 里数据模型、种子数据、localStorage 读写这些基础打得很扎实,为后面首页、发布页、搜索页、详情页、我的发布五个页面的开发提供了稳定的底座。他在基础功能上推进很快,单元测试初版也及时跟上,让整个项目很早就跑通了"发布—浏览—搜索—查看详情"的主流程。此外,他每次改完都会及时在仓库 fork、同步代码,保证了两人协作时版本不会太乱。
需要改进的地方:前期两人节奏不太同步,他做完基础功能后,有一段时间不太清楚我这边在私信、联系申请等扩展功能上改了什么,导致偶尔出现公共函数两边各写一份的情况。另外,data.js 里有些公共函数一开始分散在各个页面文件里,后来才统一收拢,这部分如果一开始就规划好会更省事。以后可以约定更明确的同步节奏,比如每周对一次进展,或者改公共函数前先在群里说一声,避免重复劳动。
十一、总结
这次结对作业,我们从"校园失物招领"的需求出发,完成了从数据层、页面层到单元测试的完整实现。
技术上,最大的收获是理解了"数据源统一"的价值——所有页面共享一份 localStorage 数据,一处修改处处同步,避免了多页面各自维护数据导致的不一致问题。同时,通过同义词搜索、联系申请、私信系统等扩展功能,我们体会到"从真实使用场景出发做设计"比"堆功能"更重要:比如联系方式公开方式可选、未读角标、旧数据迁移,都是站在用户角度才会想到的细节。
协作上,两人分工明确,一人负责基础页面和数据层,一人负责私信、联系申请等扩展功能,通过 GitHub 仓库进行代码同步。过程中也暴露了不足:前期节奏不太同步,公共函数重复定义导致过报错,后来通过把公共逻辑统一抽到 data.js、约定更清晰的同步节奏解决了。这让我们意识到,结对编程不只是"分头写代码",更需要持续的沟通和对公共边界的约定。
总的来说,这次作业让我们对"一个完整的小项目怎么从需求走到实现"有了更具体的理解,也让我们在工程规范、代码组织和协作方式上都有了不少收获。
浙公网安备 33010602011771号