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 关键实现的流程图

image

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

image

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

image

数据流为: 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 实现成果展示

  1. 首页分类筛选(全部 / 校园卡 / 电子设备 / 钥匙 等)

image

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

image

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

image

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

image

  1. 私信列表页(未读角标)

image

  1. 私信会话页(消息气泡)

image

  1. 账号切换下拉框

image

六、目录说明和使用说明

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 环境。

简易教程:

  1. 在 tests/test.html 中引入 Mocha 和 Chai 的 CDN。
  2. 设置 mocha.setup('bdd'),暴露 chai.expect。
  3. 在 tests/test.js 中用 describe 分组、it 写用例。
  4. 引入项目的 data.js。
  5. 调用 mocha.run() 启动测试。

后来在补充私信、联系申请等功能时,我们又把测试运行器改成了一个极简的纯逻辑版本,不依赖真实的 localStorage,直接在浏览器里跑,速度更快,也更容易排查。

7.2 部分单元测试代码

用例一:HTML 转义防 XSS

测试 escapeHtml 函数,验证它能把 <、>、&、"、' 这些特殊字符转义成 HTML 实体。这是防止用户在标题、描述里注入脚本的第一道防线。

test("escapeHtml 会转义 HTML 标签和引号", () => {
  assertEqual(escapeHtml("<img src=x onerror=alert(1)>"),
              "&lt;img src=x onerror=alert(1)&gt;");
  assertEqual(escapeHtml(`O'Brien "同学" & 你`),
              "O&#39;Brien &quot;同学&quot; &amp; 你");
});

用例二:同义词扩展

测试 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 签入记录

image

image

九、遇到的困难及解决

问题一:多个页面重复定义公共函数,导致 search.js 漏定义后报错。尝试在每个文件里分别保留,后来发现容易漏。解决方式是统一放到 data.js 中,其他页面直接调用。收获是理解了公共代码应该放在被所有页面引入的基础文件里。

问题二:Live Server 自动刷新打断发布跳转。尝试把延迟改短、改用 window.location。解决方式是意识到这是开发工具行为,测试时不动编辑器就能正常跳转。收获是区分代码问题和工具行为。

问题三:分类栏在 375px 宽度下显示不全。尝试加横向滚动。解决方式是精简为 5 个高频分类,同时保留滚动能力。收获是移动端设计要在功能完整和界面可用之间做取舍。

十、队友评价

值得学习的地方:陈霁然在项目初期主动承担了数据层的搭建,把 data.js 里数据模型、种子数据、localStorage 读写这些基础打得很扎实,为后面首页、发布页、搜索页、详情页、我的发布五个页面的开发提供了稳定的底座。他在基础功能上推进很快,单元测试初版也及时跟上,让整个项目很早就跑通了"发布—浏览—搜索—查看详情"的主流程。此外,他每次改完都会及时在仓库 fork、同步代码,保证了两人协作时版本不会太乱。

需要改进的地方:前期两人节奏不太同步,他做完基础功能后,有一段时间不太清楚我这边在私信、联系申请等扩展功能上改了什么,导致偶尔出现公共函数两边各写一份的情况。另外,data.js 里有些公共函数一开始分散在各个页面文件里,后来才统一收拢,这部分如果一开始就规划好会更省事。以后可以约定更明确的同步节奏,比如每周对一次进展,或者改公共函数前先在群里说一声,避免重复劳动。

十一、总结

这次结对作业,我们从"校园失物招领"的需求出发,完成了从数据层、页面层到单元测试的完整实现。

技术上,最大的收获是理解了"数据源统一"的价值——所有页面共享一份 localStorage 数据,一处修改处处同步,避免了多页面各自维护数据导致的不一致问题。同时,通过同义词搜索、联系申请、私信系统等扩展功能,我们体会到"从真实使用场景出发做设计"比"堆功能"更重要:比如联系方式公开方式可选、未读角标、旧数据迁移,都是站在用户角度才会想到的细节。

协作上,两人分工明确,一人负责基础页面和数据层,一人负责私信、联系申请等扩展功能,通过 GitHub 仓库进行代码同步。过程中也暴露了不足:前期节奏不太同步,公共函数重复定义导致过报错,后来通过把公共逻辑统一抽到 data.js、约定更清晰的同步节奏解决了。这让我们意识到,结对编程不只是"分头写代码",更需要持续的沟通和对公共边界的约定。

总的来说,这次作业让我们对"一个完整的小项目怎么从需求走到实现"有了更具体的理解,也让我们在工程规范、代码组织和协作方式上都有了不少收获。

posted on 2026-10-09 22:31  大鹅zxt  阅读(12)  评论(0)    收藏  举报