软件工程第四次作业:"校园失物招领"程序实现

软件工程第四次作业:"校园失物招领"程序实现

项目 内容
这个作业属于哪个课程 H202601 软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
我的博客/本作业链接 陈薇薇 102402109 / 本次作业
结对队友博客/本作业链接 李青容 102402111 / 本次作业
GitHub 仓库 102402109-102402111

一、项目简介

"校园失物招领"是一个无需后台即可运行的校园失物招领 Web 应用。我们基于第一次结对作业的原型,把"发布信息—浏览或搜索—查看详情—联系发布者—更新状态"实现为可操作的完整流程。

用户可发布寻物或招领信息,通过关键词、类型、类别筛选内容,在详情中查看并复制联系方式;发布者可在"我的发布"中把信息更新为"已找到"或"已归还"。

为保证助教下载后可以直接复现,项目使用原生 HTML、CSS、JavaScript 和浏览器 localStorage,不依赖服务器、数据库或第三方框架,Chrome 直接打开 index.html 即可运行。


二、结对分工

本次结对作业由我和李青容同学共同完成,具体分工如下:

成员 负责内容
陈薇薇 102402109 数据层 data.js、首页 index.html + app.js、发布页 publish.html + publish.js、详情页 detail.html + detail.js、单元测试
李青容 102402111 我的发布 my.html + my.js、README 编写、需求分析与功能评审、测试用例设计、fork + PR 协作演示、页面验收测试

两人共同参与需求讨论、功能规划与测试验证。代码实现由陈薇薇主要负责,李青容负责需求把控与质量验收,通过 GitHub 的 fork + PR 流程完成协作。


三、PSP

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 45
Estimate 估计这个任务需要多少时间 20 30
Development 开发 400 520
Analysis 需求分析(包括学习新技术) 40 60
Design Spec 生成设计文档 30 25
Design Review 设计复审 20 15
Coding Standard 代码规范 15 10
Design 具体设计 50 70
Coding 具体编码 180 240
Code Review 代码复审 30 40
Test 测试 40 60
Reporting 报告 60 80
Test Report 测试报告 20 30
Size Measurement 计算工作量 10 10
Postmortem & Process Improvement Plan 事后总结 30 40
合计 945 1315

PSP 分析:

预估与实际存在差距,主要原因:

  1. 学习成本被低估:Node.js、npm、Mocha 测试框架都是边学边用,环境配置和工具调试消耗了大量时间;
  2. 页面交互排查反复:卡片跳转、图片渲染、localStorage 缓存问题,经常需要清缓存重新测试;
  3. 代码语法错误排查:data.js 加 image 字段时漏写了逗号,导致整个脚本崩溃,排查花了额外时间。

以后的项目中要在预估阶段为"学习新技术"和"环境搭建"留出更多缓冲时间。


四、解题思路与设计实现

4.1 代码实现思路

项目基于第一次结对作业的 HTML 原型,采用原生 HTML + CSS + JavaScript 实现,无需后端。整体架构分三层:

① 数据层(js/data.js)

负责所有数据 CRUD,基于浏览器 localStorage 持久化。封装接口:

  • getAllItems() —— 读取全部数据,首次访问时自动写入种子数据
  • addItem(item) —— 新增一条信息
  • getItemById(id) —— 按 id 查询
  • updateItemStatus(id, status) —— 更新状态(已找到/已归还)
  • deleteItem(id) —— 删除
  • filterItems({keyword, type}) —— 关键词搜索 + 分类筛选
  • formatTime(ts) —— 时间格式化

② 页面层(HTML)

  • index.html —— 首页,只保留空的 <div class="cards">,卡片由 JS 动态渲染
  • publish.html —— 发布表单,含图片上传
  • detail.html —— 详情展示,含状态操作按钮
  • my.html —— 我的发布列表

③ 逻辑层(js/*.js)

每个页面有一个对应的 JS 文件,负责调用数据层 API、渲染界面、绑定事件。

核心设计原则:所有页面通过数据层交互,数据只有一份,不存在同步问题。

4.2 关键流程图

graph TD A[用户打开首页] --> B{要做什么?} B -->|浏览| C[首页卡片列表] B -->|搜索| D[输入关键词] B -->|筛选| E[点击分类] D --> C E --> C C --> F[点击卡片] F --> G[详情页] G --> H{用户操作} H -->|复制联系方式| I[剪贴板] H -->|标记状态| J[更新为已找到/已归还] H -->|删除| K[删除信息] J --> C K --> C A --> L[点击发布] L --> M[填写表单] M --> N{上传图片?} N -->|是| O[Base64 编码] N -->|否| P[提交] O --> P P --> Q[addItem 存入 localStorage] Q --> C A --> R[点击我的发布] R --> S[读取 localStorage] S --> T[展示全部]

4.3 有价值的代码片段

① 数据层统一封装(js/data.js)

function filterItems({ keyword = "", category = "全部", type = "全部" } = {}) {
    const kw = keyword.trim().toLowerCase();
    return getAllItems().filter(item => {
        if (type !== "全部" && item.type !== type) return false;
        if (kw) {
            const haystack = [
                item.name, item.description, item.location, item.category
            ].join(" ").toLowerCase();
            if (!haystack.includes(kw)) return false;
        }
        return true;
    });
}

解释: 搜索覆盖物品名称、描述、地点、类别四个字段,用 toLowerCase() 实现大小写不敏感匹配,用 trim() 处理前后空格。这是整个应用最核心的"搜索 + 筛选"逻辑。

② 首页动态渲染(js/app.js)

cardsContainer.innerHTML = items.map(item => `
    <div class="card" data-id="${item.id}">
        ${item.image ? `
            <div class="card-image">
                <img src="${item.image}" alt="${escapeHtml(item.name)}"
                     onerror="this.parentElement.style.display='none'">
            </div>
        ` : ""}
        <div class="card-top">
            <span class="tag ${item.type === "寻物" ? "lost" : "found"}">${item.type}</span>
            <span class="status">${formatTime(item.createdAt)}</span>
        </div>
        <h3>${escapeHtml(item.name)}</h3>
        <p>${escapeHtml(item.description)}</p>
        <div class="card-bottom">
            <span class="location">📍 ${escapeHtml(item.location)}</span>
            <span class="detail-btn">查看详情 →</span>
        </div>
    </div>
`).join("");

解释: 用 map + join 生成 HTML 字符串,escapeHtml 防 XSS 攻击,图片容错处理(onerror 隐藏加载失败的图片块,不影响卡片布局)。


五、附加特点设计与展示

5.1 设计的创意独到之处与意义

除基础功能外,额外实现了以下人性化设计:

① 一键复制联系方式

用户在详情页不需要手动长按复制,点击按钮即可将 QQ / 微信 / 手机号复制到剪贴板,减少手动操作,提高联系效率。

② 物品图片上传与展示

发布时允许上传图片(Base64 编码存入 localStorage),首页卡片和详情页都能看到物品真实照片,避免"文字描述不清晰"的沟通障碍。

③ 状态标记与视觉反馈

信息发布后可标记为"已找到"或"已归还",卡片上显示状态徽章,其他用户一眼看出信息是否有效,减少无效联系。

④ 分类筛选 + 关键词搜索

顶部支持"全部 / 寻物 / 招领"分类切换,搜索框支持物品名称、描述、地点、类别多字段模糊匹配。

5.2 实现思路

  • 一键复制:使用 navigator.clipboard.writeText() API 复制到剪贴板
  • 图片上传:使用 FileReader.readAsDataURL() 转 Base64 字符串,再作为 <img src> 渲染
  • 状态标记:在数据对象的 status 字段上标记,渲染时根据状态显示徽章
  • 分类筛选:filterItems 函数同时接收 keyword 和 type 参数,联合过滤

5.3 关键代码片段(图片上传)

imageInput.addEventListener("change", function (event) {
    const file = event.target.files[0];
    if (!file) return;

    if (file.size > 500 * 1024) {
        alert("图片太大啦,请选择 500KB 以内的图片!");
        return;
    }

    const reader = new FileReader();
    reader.onload = function (e) {
        imageBase64 = e.target.result;
        imagePreview.innerHTML = `
            <img src="${imageBase64}" alt="预览">
            <button type="button" class="remove-img" id="removeImgBtn">×</button>
        `;
    };
    reader.readAsDataURL(file);
});

解释: 用 FileReader 读图片文件,转成 Base64 字符串。因为 localStorage 只能存文本,Base64 是最简单的方案。加了 500KB 大小限制,避免 localStorage 溢出。

5.4 实现成果

首页(含图片卡片、搜索框、分类筛选):

QQ20261008-151926
QQ20261008-152022

详情页(含大图 + 复制按钮 + 状态标记):

QQ20261008-152152

发布页(含图片上传):
QQ20261008-152857
QQ20261008-153056

我的发布页:

QQ20261008-152623


六、目录与使用说明

6.1 目录组织

校园失物招领/
├── index.html              首页(浏览、搜索、分类)
├── publish.html            发布信息页
├── detail.html             详情页(查看、复制、标记状态、删除)
├── my.html                 我的发布
├── css/
│   └── style.css           全局样式
├── js/
│   ├── data.js             数据层(localStorage 封装)
│   ├── app.js              首页逻辑
│   ├── publish.js          发布逻辑
│   ├── detail.js           详情逻辑
│   └── my.js               我的发布逻辑
├── images/                 图片素材
├── tests/
│   └── data.test.js        数据层单元测试
├── package.json            npm 项目配置
└── README.md               项目说明文档

6.2 如何运行网页

  1. 下载/克隆本项目所有文件到本地
  2. 使用谷歌浏览器(Chrome),推荐配合 VS Code 的 Live Server 插件运行
  3. 在 VS Code 里右键 index.html → Open with Live Server
  4. 浏览器自动打开 http://127.0.0.1:5500/index.html

⚠️ 不建议直接双击 index.html,因为浏览器对 file:// 协议下的 localStorage 有安全限制,可能导致卡片不显示。

6.3 数据存储说明

所有数据保存在浏览器 localStorage 的 lostFoundItems 字段下。首次打开自动写入 6 条示例数据。

清空数据方法: 按 F12 → Application → Local Storage → 删除 lostFoundItems 字段。

6.4 运行单元测试

环境要求: Node.js(v18 以上)

# 1. 安装依赖
npm install

# 2. 运行测试
npm test

预期输出: 12 passing


七、单元测试

7.1 测试工具介绍

我们选用 Mocha + Chai + JSDOM 组合:

  • Mocha —— 测试运行框架,负责组织测试用例
  • Chai —— 断言库,提供 expect(...).to.equal(...) 语法
  • JSDOM —— 在 Node.js 中模拟浏览器 DOM 和 localStorage 环境

学习过程:

我们之前没有单元测试经验,通过查阅资料(廖雪峰的 JS 教程、Mocha 官方文档)逐步上手。核心难点是 localStorage 在 Node.js 中不存在,需要用 JSDOM 模拟。

简易教程:

# 第 1 步:初始化项目
npm init -y

# 第 2 步:安装测试工具
npm install --save-dev mocha chai jsdom

# 第 3 步:修改 package.json 的 scripts
# "test": "mocha tests/**/*.test.js"

# 第 4 步:编写测试文件 tests/data.test.js
# 第 5 步:运行
npm test

7.2 部分单元测试代码

const { expect } = require("chai");
const { JSDOM } = require("jsdom");

const dom = new JSDOM("", { url: "http://localhost" });
global.window = dom.window;
global.localStorage = dom.window.localStorage;

const data = require("../js/data.js");

describe("校园失物招领 - 数据层单元测试", () => {

    beforeEach(() => {
        localStorage.clear();
    });

    it("首次调用 getAllItems 应返回 6 条种子数据", () => {
        const items = data.getAllItems();
        expect(items).to.be.an("array");
        expect(items.length).to.equal(6);
    });

    it("addItem 应新增一条并返回带 id 的对象", () => {
        data.getAllItems();
        const newItem = data.addItem({
            type: "寻物", name: "测试物品", category: "其他",
            location: "测试地点", time: "2026-10-06T10:00",
            description: "测试描述", contact: "test-contact"
        });
        expect(newItem).to.have.property("id");
        expect(newItem.status).to.equal("进行中");
        expect(data.getAllItems().length).to.equal(7);
    });

    it("updateItemStatus 应将信息状态改为已找到", () => {
        const ok = data.updateItemStatus(1, "已找到");
        expect(ok).to.be.true;
        expect(data.getItemById(1).status).to.equal("已找到");
    });

    it("filterItems 按类型'寻物'应只返回 type=寻物 的数据", () => {
        const res = data.filterItems({ type: "寻物" });
        expect(res.length).to.be.greaterThan(0);
        res.forEach(item => expect(item.type).to.equal("寻物"));
    });

    // ... 共 12 个测试用例
});

测试的函数:

  • getAllItems —— 初始化种子数据
  • addItem —— 新增并生成 id
  • getItemById —— 按 id 查找
  • updateItemStatus —— 状态更新
  • filterItems —— 搜索与筛选
  • deleteItem —— 删除
  • formatTime —— 时间格式化

7.3 测试数据构造思路

考虑正常、边界、异常三类情况:

正常情况:

  • 数据初始化为 6 条
  • 新增 1 条后变 7 条
  • 搜索"校园卡"能匹配
  • 状态更新成功

边界情况:

  • 搜索不存在的关键词 → 返回空数组
  • 查找不存在的 id → 返回 null
  • 更新不存在的 id → 返回 false

异常情况:

  • formatTime(null) → 返回空字符串
  • formatTime(undefined) → 返回空字符串

面向未来测试人员的"刁难",我们的考虑:

  1. 每个用例独立 —— beforeEach 里清空 localStorage,避免数据污染
  2. 不依赖执行顺序 —— 每个测试自己构造前置条件
  3. 覆盖异常输入 —— 空值、越界 id、不存在的关键词都已测试
  4. 断言具体 —— 不只看真假,还断言具体值(如 id 存在、status 正确)

测试运行结果:

8788c5d438a303bfca593725e270d1cf


八、GitHub 签入记录

开发过程遵循"每完成一个功能就 commit 一次"的原则。

image

commit 记录说明:

Commit 说明
chore: 初始化项目结构 建 index.html / publish.html / style.css
feat(data): 新增数据层 data.js 封装 localStorage
feat(index): 首页动态渲染 app.js 渲染卡片 + 搜索 + 分类
feat(publish): 发布表单对接数据层 表单校验 + addItem
feat(detail): 新增详情页 detail.html + detail.js
feat(my): 新增「我的发布」 my.html + my.js
test: 添加数据层单元测试 12 个用例
docs: 编写 README 目录说明 + 使用说明
style: 首页与详情页添加物品配图 图片渲染
feat(publish): 支持上传物品图片 Base64 图片上传

fork + PR 协作记录:

按照结对编程的要求,我们采用了 fork + PR 的协作流程:一人创建主仓库,另一人 fork 后提交 PR,最后合并。

bd9ad7d8c38d5018d9a8d63a91668990

image


九、遇到的代码模块异常或结对困难及解决方法

问题 1:`file://` 协议下 localStorage 无法使用

问题描述:

最初直接双击 index.html 打开,浏览器 Console 报错:

Unsafe attempt to load URL ... 'file:' URLs are treated as unique security origins.

导致 localStorage 无法使用,卡片无法渲染。

做过哪些尝试:

  1. 尝试换浏览器(Edge、Chrome)—— 都报错
  2. 尝试加 localStorage.clear() —— 没用

是否解决:

解决了。改用 VS Code 的 Live Server 插件(或 python -m http.server)通过 HTTP 协议访问,问题消失。

有何收获:

理解了浏览器对 file:// 协议的安全限制,以及 Web 开发应该走 HTTP 协议。

问题 2:`data.js` 加 `image` 字段后语法错误

问题描述:

给种子数据加 image 字段后,页面全白,Console 报:

Uncaught SyntaxError: Unexpected identifier 'image'    data.js:20

做过哪些尝试:

  1. 检查引号是否配对
  2. 检查字段名拼写
  3. 最后发现:createdAt: 1759721520000 后面漏了逗号,导致 image 字段语法错误

是否解决:

解决了。补齐 6 条记录末尾的逗号。

有何收获:

JS 对象末尾的逗号很容易漏,尤其是多字段对象。以后加字段要仔细检查。

问题 3:浏览器缓存导致图片显示不出来

问题描述:

加了图片字段后,刷新首页还是没图。

做过哪些尝试:

  1. 检查图片路径 —— 正确
  2. 检查 data.js 有没有保存 —— 已保存

是否解决:

解决了。原因是浏览器 localStorage 里存着旧数据(没有 image 字段),页面按旧数据渲染。清掉 localStorage 后恢复。

有何收获:

使用 localStorage 时,改了数据结构必须清缓存才能看到效果。开发阶段应该养成"清缓存 + 强刷"的习惯。

结对困难:两人如何高效协作

问题描述:

两人无法时刻在同一台电脑前,代码合并容易冲突。

做过哪些尝试:

  1. 用 GitHub 的 fork + PR 流程 —— 队友改完提交 PR,我合并
  2. 用 commit message 规范 让双方都能看懂对方改了什么

是否解决:

解决了。通过 fork + PR 流程,两人可以并行开发,主仓库始终保持可用状态。

有何收获:

掌握了 GitHub 协作的核心流程,理解了 PR 在代码审查和团队协作中的价值。


十、评价队友

值得学习的地方:

队友在需求评审和测试验收环节非常认真。尤其是:

  • 在需求评审阶段,对"标记状态后其他用户如何看到结果"这一流程提出了明确的改进建议,避免了我们只做"成功路径"而忽略异常情况
  • 主动学习了 GitHub 的 fork + PR 协作流程,配合我完成了协作演示
  • 在页面验收时,找出了几个边界情况(比如搜索无结果、图片上传超限)的提示缺失问题

需要改进的地方:

  • 代码参与度可以更高一些
  • commit 记录可以更细化,方便回溯

整体评价:

队友是个可靠的搭档。沟通顺畅、进度同步、分工清晰,配合过程没有出现明显扯皮或返工。虽然本次代码主要由我实现,但队友在需求评审与测试验收上的投入,帮助项目在细节上更完善,期待下次继续合作。

posted @ 2026-10-09 08:37  Vvvicky-  阅读(13)  评论(0)    收藏  举报