2026 秋软件工程 · 第二次结对作业:校园失物招领程序实现
结对同学博客:孟维涛 | 王宇航
本作业博客:https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16744
GitHub 项目地址:https://github.com/Meng-tao123/102401404-102401418
班级群结对表已填写:是
一、具体分工
| 同学 | 负责内容 |
|---|---|
| 孟维涛(102401404) | 业务逻辑层 core.js(校验、搜索、状态变更、统计)、单元测试用例设计、博客「解题思路」与「单元测试」两节 |
| 王宇航(102401418) | 表现层 6 个页面与 ui.js、样式 base.css / pages.css、仓库层 repo.js、README 与目录说明 |
| 共同完成 | 需求梳理与功能划分、三层结构的接口约定、代码复审、浏览器端走查与回归 |
具体到每一次提交的分工,见文末的签入计划;仓库按功能点提交,每完成一个功能 commit 一次。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) | 说明 |
|---|---|---|---|---|
| Planning | 计划 | — | — | |
| Estimate | 估计这个任务需要多少时间 | 15 | 20 | 先把功能拆成 6 个页面 + 3 个模块再估 |
| Development | 开发 | — | — | |
| Analysis | 需求分析(包括学习新技术) | 60 | 90 | 重读了第一次作业的原型与老师点评,重新圈定范围 |
| Design Spec | 生成设计文档 | 40 | 35 | 三层结构与数据结构的约定 |
| Design Review | 设计复审 | 25 | 30 | 两人讨论「业务逻辑要不要碰 DOM」 |
| Coding Standard | 代码规范 | 20 | 20 | 统一 2 空格缩进、模块名与函数命名、文件头注释 |
| Design | 具体设计 | 60 | 75 | 页面结构、字段与状态机 |
| Coding | 具体编码 | 300 | 420 | 6 个页面 + 3 个 JS 模块 |
| Code Review | 代码复审 | 40 | 60 | 复审时发现首页卡片没绑跳转的 bug |
| Test | 测试(自我测试,修改代码,提交修改) | 90 | 150 | 51 个单元测试 + 浏览器端全流程走查 |
| Reporting | 报告 | — | — | |
| Test Report | 测试报告 | 40 | 45 | 见第六节 |
| Size Measurement | 计算工作量 | 15 | 10 | |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 40 | 60 | 见第七节 |
| 合计 | 745 | 1015 | 实际是预估的 1.36 倍 |
关于估偏的部分:编码超了 120 分钟,主要集中在发布页的照片压缩和详情页的操作栏状态联动;测试超了 60 分钟,原因是第一次写测试框架时断言失败的提示信息太笼统,定位问题很慢,后来给 assert 加上了「期望值 / 实际值」的输出才快起来;分析环节超了 30 分钟,是因为我们把第一次作业里画的「消息 / 留言」功能又拿出来讨论了一遍才决定砍掉。这些偏差让我们意识到:看上去最熟的编码环节反而最容易低估,因为接口联调的问题都是写的时候才冒出来的。
三、解题思路与设计实现
3.1 先定范围:从原型到可运行的程序
第一次作业的原型里有 9 个页面,其中「消息」承担的是留言沟通。老师点评里已经指出:本次不要求复杂后台、实名认证、即时聊天和地图定位。所以我们在动手前做了一次减法:
| 原型里的设计 | 本次如何处理 | 原因 |
|---|---|---|
| 首页 / 搜索 / 搜索结果 / 搜索无结果 / 详情 / 发布 / 发布成功 | 保留 | 对应作业要求的主流程 |
| 我的(我的发布) | 保留但简化 | 「更新状态」必须有入口,统计只保留三个数字 |
| 消息 / 留言 | 删除 | 属于即时聊天范畴,不在本次范围内 |
| 「已实名」标记 | 删除 | 实名认证明确不要求 |
最终实现的页面是 6 个:index(首页)、search(搜索与结果)、detail(详情)、publish(发布)、success(发布成功)、mine(我的发布)。底部导航也相应从原型里的三个 Tab 变成了 首页 / 发布 / 我的——既然不做消息,就不该留一个点进去是空的入口。
3.2 三层结构:让业务逻辑能被测试
代码实现的核心思路是把「业务规则」和「浏览器」分开:
表现层(6 个 HTML) 只负责渲染和事件绑定
↓ 调用
业务逻辑层(core.js) 校验 / 搜索 / 状态变更 / 统计 —— 全是纯函数
↓ 调用
数据仓库层(repo.js) 持有信息列表,读写 localStorage
↓
存储层(localStorage)
这么分有两个直接好处:
- 单元测试不需要浏览器。
core.js不碰 DOM、不碰localStorage,所以node test/run.js能直接把它加载进来跑。 - 仓库层的存储介质是注入的。
createRepo({ storage: null })就退化成纯内存,这样 Node 里也能测仓库的增删改查;浏览器里传window.localStorage就是持久化的。

3.4 关键流程
作业要求的闭环是「发布信息—浏览或搜索—查看详情—联系发布者—更新状态」。其中有两个地方最容易出错,我们单独画了流程:
发布信息:重点是校验失败的分支——不是弹一句「请填写完整」,而是把错误逐项定位到具体输入框。

更新状态:重点是「谁来改、从哪改、改完别人看到什么」。

关于「更新状态」这条链路的闭环:发布者在详情页或「我的发布」里点「标记为已找到 / 已归还」;入口有两个,详情页底部操作栏和我的发布列表的操作行;改完之后,这条信息在详情页的标签会从「未完成」变成「已归还」,首页和搜索结果里其他同学按「进行中」筛选时就不再看到它,我的发布的统计数字同时 +1。
3.5 关键代码片段
(1)表单校验:返回结构化的错误,而不是一个布尔值
function validateDraft(draft) {
var d = draft || {};
var errors = [];
var title = trim(d.title);
if (!title) errors.push({ field: 'title', message: '请填写物品名称' });
else if (title.length < 2) errors.push({ field: 'title', message: '物品名称至少 2 个字' });
else if (title.length > CONFIG.TITLE_MAX) {
errors.push({ field: 'title', message: '物品名称不能超过 ' + CONFIG.TITLE_MAX + ' 个字' });
}
// …(类别、地点、日期、联系方式、照片数量同理)
return { ok: errors.length === 0, errors: errors };
}
这里刻意不抛异常、不返回 true/false,而是返回 { ok, errors },每个错误带上 field。页面拿到之后就能把 data-err="title" 的提示文字填上、给对应的输入框加 .invalid 红框、并把焦点移到第一个出错的输入框。校验规则本身也因此变成了可以单独测试的纯数据。
(2)搜索筛选:一次遍历完成多维过滤
function searchItems(items, query) {
var q = query || {};
var kw = normalize(q.keyword); // 小写 + 去掉所有空白
var list = (items || []).filter(function (it) {
if (q.type && q.type !== 'all' && it.type !== q.type) return false;
if (q.category && q.category !== 'all' && it.category !== q.category) return false;
if (q.place && q.place !== 'all' && it.place !== q.place) return false;
if (q.status && q.status !== 'all' && it.status !== q.status) return false;
if (kw) {
var hay = normalize([it.title, it.desc, it.place,
categoryLabel(it.category), typeLabel(it.type)].join(' '));
if (hay.indexOf(kw) === -1) return false;
}
return true;
});
return list.sort(function (a, b) {
return q.sort === 'old' ? a.createdAt - b.createdAt : b.createdAt - a.createdAt;
});
}
关键词会同时匹配物品名称、详细说明、地点、类别名、类型名五个字段,所以搜「图书馆」能找到地点在图书馆的信息,搜「耳机」也能命中描述里提到耳机的信息。normalize 去掉了所有空白,因此「校园 卡」也能搜到「校园卡」。
(3)状态变更:纯函数,不改动入参
function setItemStatus(items, id, status, now) {
if (status !== 'active' && status !== 'done') throw new Error('非法状态:' + status);
var found = false;
var next = (items || []).map(function (it) {
if (it.id !== id) return it;
found = true;
return Object.assign({}, it, { status: status, updatedAt: now });
});
return { ok: found, items: next };
}
这个函数没有副作用:找不到 id 时返回 ok:false 而不是静默失败,页面据此提示;正常时返回新数组,原数组不动。这样做的好处是调用方可以放心地在渲染前后对比,也让「改完之后其他人看到什么」这件事变得可测——我们专门写了一个用例验证「标记为已完成后再按进行中筛选,这条信息不再出现」。
(4)仓库层:把存储介质做成可替换的
function createRepo(options) {
var storage = options.storage || null; // 传 null 就是用内存
var key = options.key || CONFIG.ITEM_KEY;
function writeStore() {
if (!storage) return false;
try { storage.setItem(key, JSON.stringify(items)); return true; }
catch (e) { return false; } // 配额写满、隐私模式等,不崩
}
return {
add: function (draft, now) {
var result = core.createItem(draft, now);
if (result.ok) { items = [result.item].concat(items); writeStore(); }
return result;
},
// …find / setStatus / remove / search / stats
};
}
这一层只有十几行,但它是整个项目「能测」的关键:测试里传 storage: null 就是内存仓库,浏览器里传 window.localStorage 就是持久化仓库,同一份业务代码在两种环境下都能跑。
四、附加特点设计与展示
4.1 创意点与意义
| 附加特点 | 它解决了什么 | 意义 |
|---|---|---|
| 搜不到时给「换个说法」 | 失物描述天然不统一:丢的人写「保温杯」,捡的人写「水杯」,双方永远搜不到对方 | 把「搜索失败」从一个死胡同变成一次引导:既给同义词,也给「自己发一条」的出口 |
| 搜索关键词高亮 | 结果一多,用户不知道这条为什么被搜出来 | 命中位置直接标黄,判断成本从「读完整条」降到「看一眼」 |
| 一键复制联系方式 | 手抄微信号、手机号容易抄错 | 详情页弹层里直接复制,减少一次失败的联系 |
| 照片自动压缩后再存 | 手机原图动辄 3~5MB,三张就能把 localStorage 写满,写满之后整个页面都存不进数据 | 上传前先压到最长边 720px,从根上避免「用着用着就存不进去了」 |
4.2 实现思路与代码
搜不到时的同义词推荐:先维护一张同义词表,关键词命中任意一组就返回该组的所有说法,再用热词补齐,最后去重并剔除与关键词完全相同的项;如果这个关键词其实能搜到结果,就返回空数组,页面不会显示这个模块。
function suggestKeywords(keyword, items, limit) {
var kw = normalize(keyword);
if (!kw) return [];
if (items && searchItems(items, { keyword: kw }).length > 0) return [];
var out = [];
SUGGEST_MAP.forEach(function (group) {
var hit = group.keys.some(function (k) {
return kw.indexOf(normalize(k)) !== -1 || normalize(k).indexOf(kw) !== -1;
});
if (hit) out = out.concat(group.words);
});
out = out.concat(HOT_WORDS);
// …去重、剔除与关键词相同的项、截取前 limit 个
}
关键词高亮:先转义 HTML 再包 <mark>,顺序不能反,否则用户输入的 <script> 会被当成标签渲染出来。
function highlight(text, keyword) {
var safe = escapeHtml(text); // 先转义
var kw = trim(keyword);
if (!kw) return safe;
var escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return safe.replace(new RegExp(escaped, 'gi'), function (m) { return '<mark>' + m + '</mark>'; });
}
照片压缩:FileReader 读成 dataURL → 画到 canvas 上按最长边 720px 缩放 → toDataURL('image/jpeg', 0.75)。一个 3MB 的相机原图压完大约 80KB,三张也还占不到 0.3MB。
4.3 成果展示

左起依次为:首页(分类快捷入口 + 信息流)、搜索结果、搜索无结果(同义词引导)、信息详情、联系发布者弹层、发布成功、发布信息、我的发布。
五、目录说明与使用说明
以下内容同样写进了仓库根目录的
README.md,助教可以直接照着复现。
5.1 目录结构
campus-lost-found/
├── index.html 首页:搜索入口、分类快捷入口、信息流、悬浮发布按钮
├── search.html 搜索页:搜索历史、大家都在找、结果筛选、无结果引导
├── detail.html 详情页:物品信息、发布者联系方式、状态标记入口
├── publish.html 发布页:选择寻物 / 招领,填写表单并校验
├── success.html 发布成功页
├── mine.html 我的发布:统计、筛选、标记完成、删除
├── css/
│ ├── base.css 设计变量、重置样式、通用组件(按钮 / 卡片 / 标签 / 弹层)
│ └── pages.css 各页面专属样式
├── js/
│ ├── core.js 纯逻辑层:配置、工具函数、校验、搜索、状态变更、统计
│ ├── repo.js 数据仓库层:增删改查 + localStorage 读写(可降级为内存)
│ └── ui.js 浏览器 UI 层:图标、页面外壳、物品卡片、弹层、图片压缩
├── test/
│ ├── framework.js 极简测试框架(describe / it / assert),浏览器与 Node 通用
│ ├── tests.js 51 个单元测试用例
│ ├── test.html 在浏览器里跑测试
│ └── run.js 在命令行里跑测试
└── README.md
5.2 如何运行
- 把整个文件夹下载到本地,保持目录结构不变。
- 用 Chrome 直接打开
index.html(双击即可,不需要本地服务器、不需要安装任何依赖)。 - 首次打开会自动写入 5 条示例数据,可以直接试用发布、搜索、详情、标记归还的完整流程。
数据保存在浏览器的 localStorage 里,关掉浏览器再打开依然在。想恢复到初始的示例数据,在 Chrome 的控制台执行 localStorage.clear() 后刷新页面即可。
六、单元测试
6.1 选用的工具,以及一份简易教程
我们没有引入 Mocha、Jest 这类第三方框架,而是自己写了一个约 90 行的极简测试框架(test/framework.js)。原因有两点:一是作业要求「下载所有文件后用 Chrome 打开就能跑」,引入 npm 依赖会破坏这一点;二是自己实现一遍 describe / it / assert,比直接调用 API 更能理解单元测试到底在做什么。
框架的用法和 Mocha 完全一致,日后想换成 Mocha,测试文件一行都不用改:
describe('分组名', function () {
it('用例名:能描述清楚期望的行为', function () {
assert.equal(实际值, 期望值, '失败时显示的说明');
});
});
怎么自学单元测试:我们看的材料是廖雪峰的《测试框架 Mocha 实例教程》和邹欣老师《关于单元测试和回归测试》那篇博客。看下来最有用的一句话是「一个测试只验证一件事」——所以我们的用例名都写成一句可判断真假的陈述句,例如「物品名称只有 1 个字也要拦下来」。
自己动手写一个最小可用的断言,其实就是三步:把期望值和实际值比一下,不相等就抛异常,再写个循环把抛异常记为失败、正常返回记为通过。
// 断言:相等
equal: function (actual, expected, message) {
if (actual !== expected) {
fail((message || '两个值不相等') + ':期望 ' + JSON.stringify(expected) + ',实际 ' + JSON.stringify(actual));
}
},
// 断言:期望抛出异常
throws: function (fn, contains, message) {
var thrown = null;
try { fn(); } catch (e) { thrown = e; }
if (!thrown) fail(message || '期望抛出异常,但函数正常返回了');
if (contains && String(thrown.message).indexOf(contains) === -1) fail('异常信息不符合预期:' + thrown.message);
}
失败信息里一定要带上「期望值 vs 实际值」。我们一开始只写「断言失败」,结果一个用例挂了要逐个打日志才知道差在哪,这也是 PSP 里测试环节超时 60 分钟的直接原因。
运行方式(两种任选):
node test/run.js # 命令行运行
或者直接用浏览器打开 test/test.html,页面会自动跑完并把每个用例的结果列出来。
6.2 单元测试代码与它测试的函数
测试文件按被测对象分成 7 组,共 51 个用例:
| 分组 | 被测函数 | 用例数 | 覆盖内容 |
|---|---|---|---|
| 1. 表单校验 | validateDraft |
12 | 必填项缺失、长度上下限、非法枚举、选填项留空、多字段同时出错 |
| 2. 信息构造 | createItem |
3 | 默认状态、错误时不产生对象、首尾空格清理 |
| 3. 搜索与筛选 | searchItems |
12 | 命中名称/描述/地点、忽略空格、无结果、空关键词、按类型/类别/状态/组合筛选、两种排序 |
| 4. 状态变更 | setItemStatus |
5 | 正常标记、id 不存在、非法状态抛错、纯函数不改入参、标记后的可见性 |
| 5. 统计 | calcStats |
2 | 各项计数、空列表 |
| 6. 工具函数 | timeAgo / isExpired / daysLeft / highlight / escapeHtml / suggestKeywords |
9 | 相对时间、30 天有效期、XSS 转义、同义词推荐 |
| 7. 数据仓库 | createRepo |
8 | 新增、ownerId、校验失败不写入、改状态、删除、空仓库、reset、存储不可用时降级 |
举两个例子:
it('分类校验:类别不在可选范围时报错', function () {
assert.includes(errorsOf(draft({ category: '飞船' })), 'category');
});
it('是纯函数:不会修改传进来的原数组', function () {
core.setItemStatus(items, 'a', 'done', NOW);
assert.equal(items[0].status, 'active'); // 原数组里应该还是 active
});
6.3 构造测试数据的思路
第一层:正常的输入。 先准备一份「一定合法」的草稿作为基准:
function draft(overrides) {
var base = { type: 'lost', title: '白色蓝牙耳机', category: 'earphone',
place: '教学楼', date: '2026-09-28',
desc: '充电盒背面贴了蓝色贴纸', contact: '微信 earphone_lost', photos: [] };
return Object.assign(base, overrides || {});
}
每个用例只覆盖自己关心的字段(draft({ title: '' })),其余字段保持合法。这样用例之间互不干扰,也没有一份到处复制的测试数据。
第二层:边界值。 有长度限制的字段,分别取「刚好合法」和「刚好越界」两个值。例如名称上限 30 字,我们测的是 1 个字(下限越界)和 31 个字(上限越界),而不是随手写一个 100 字。
第三层:异常与防御性输入。 类别传 '飞船'、类型传 'unknown'、日期传 '2026/09/28'、状态传 '完成啦',验证程序是明确报错而不是默默接受。
第四层:设想测试人员的刁难。 我们按「如果你是助教,会怎么把它弄坏」来补用例:
- 「我把 localStorage 禁掉 / 塞满,你是不是整个页面就废了?」→ 所以有了「存储不可用时降级为内存」这条用例,
storage对象被我们故意写成每次都抛异常的桩。 - 「我在物品名称里输入
<script>alert(1)</script>会怎样?」→ 所以有了escapeHtml和高亮的 XSS 用例。 - 「我搜索的时候多打了一个空格呢?」→ 所以
normalize会去掉所有空白,并且有对应用例。 - 「已经标记成归还了,还能不能搜出来?搜索结果会不会自相矛盾?」→ 所以有「标记完成后再按进行中筛选不会出现」这条。
- 「时间算得对不对?信息会不会永远不下架?」→ 所以把
now作为参数注入,timeAgo和isExpired都能在固定时间点上断言,不受运行时刻影响。
这些用例够不够? 我们认为对本次范围是够的:作业要求的 5 个功能(发布、浏览、搜索、详情、更新状态)对应的核心函数全部被覆盖,且每个函数的正常路径、边界、异常路径都有用例。不足之处在于表现层的渲染逻辑(DOM 操作)没有自动化测试,这部分我们靠浏览器端的手动走查覆盖,见第七节。
6.4 GitHub 签入记录
我们按「一个功能一次提交」来划分,提交信息统一用 feat / style / test / docs / chore 前缀,共 14 次提交:
| 顺序 | commit 信息 | 涉及文件 |
|---|---|---|
| 1 | chore: 初始化项目结构与说明文档 |
README.md |
| 2 | feat(core): 业务逻辑层——配置、校验、搜索、状态变更与统计 |
js/core.js |
| 3 | feat(repo): 数据仓库层与本地存储读写 |
js/repo.js |
| 4 | feat(ui): 通用 UI 工具层——图标、外壳、卡片与弹层 |
js/ui.js |
| 5 | style: 设计变量与基础组件样式 |
css/base.css |
| 6 | feat(page): 首页信息流与分类快捷入口 |
index.html、css/pages.css |
| 7 | feat(page): 搜索页与搜索结果筛选 |
search.html |
| 8 | feat(page): 信息详情与联系发布者 |
detail.html |
| 9 | feat(page): 发布信息表单与必填校验 |
publish.html |
| 10 | feat(page): 发布成功页 |
success.html |
| 11 | feat(page): 我的发布与状态管理 |
mine.html |
| 12 | test: 自研极简单元测试框架 |
test/framework.js |
| 13 | test: 51 个单元测试用例与两种运行方式 |
test/tests.js、test/test.html、test/run.js |
| 14 | docs: 补充目录说明与使用说明 |
README.md |

七、遇到的代码模块异常或结对困难及解决方法
问题一:首页点卡片不跳转(代码模块异常)
问题描述:首页的信息卡片看起来是能点的(鼠标悬停有位移和阴影),但点下去毫无反应,控制台也不报错。
做过哪些尝试:一开始怀疑是 CSS 的 pointer-events 或者被悬浮按钮盖住了,调了半天样式没有用。后来在 console 里手动执行 document.querySelector('.item').click(),发现点击事件确实触发了,但没有任何跳转逻辑,才意识到问题不在样式,而是首页压根没调用 UI.bindItemList()——搜索页调了,首页漏了。
是否解决:已解决。首页补上绑定后,卡片点击、回车键跳转都正常了。
有何收获:这个 bug 说明「页面看起来对」和「功能能用」是两回事。我们后来在浏览器走查清单里专门加了一条:每个能点的元素都要真的点一遍。它也是我们在代码复审环节唯一发现的严重问题——如果只跑单元测试,这个 bug 永远不会暴露,因为单元测试只覆盖了 core.js,而漏掉的绑定发生在表现层。这也反过来印证了分层的好处:core.js 没有 bug,问题范围被迅速缩小到了页面脚本。
问题二:照片存不进去(代码模块异常)
问题描述:最初直接存手机原图的 dataURL,试到第三张时新增信息就失败了,但页面上没有任何提示。
做过哪些尝试:先以为是 FileReader 的问题,后来在 try/catch 里打印异常,才看到是 QuotaExceededError——localStorage 通常只有 5MB,三张原图就写满了。尝试过用 IndexedDB,但对本次作业来说太重;最后选择在存之前先压缩。
是否解决:已解决。用 canvas 把图片压到最长边 720px、JPEG 质量 0.75 再存,三张照片加起来不到 0.3MB。同时给 repo.js 的写入加了兜底:即使以后还是写满了,也只返回 false 而不会让页面崩溃,数据仍保留在内存中。
有何收获:同一个异常,报错和正确处理是两件事。 第一版的问题不是「存不下」,而是「存不下却什么都不说」。现在我们不仅修了压缩,也让失败路径有了明确的行为。
困难三:结对节奏(结对困难)
问题描述:两个人写的代码要拼在一起,第一天就出现过「我按 repo.search() 已经返回数组来写,他以为要自己再过滤一遍」的重复劳动。
做过哪些尝试:我们约定先定接口再写实现——动手前把 core.js 和 repo.js 要暴露的函数名、参数、返回值写在一张纸上,返回值格式(比如统一用 { ok, items } 还是直接返回数组)先吵清楚再各写各的。另外,因为一个人写逻辑层、一个人写表现层,我们规定改接口必须先在群里说一声。
是否解决:基本解决。后半天没有再出现返工。剩下的问题是两边的进度不完全同步,写表现层的同学偶尔要等接口,我们就先用假数据把页面搭起来,接口好了再接上。
有何收获:结对最花时间的不是写代码,而是对齐对接口的理解。这次在《构建之法》第 3 章里看到的「驾驶员和领航员要不断沟通」这句话,是我们真正体会到的一次。
八、评价你的队友
王宇航 值得学习的地方:写代码前习惯先把函数的输入输出写清楚,{ ok, errors } 这种「不抛异常、把失败也当成返回值」的写法就是他坚持的,后来证明这个决定让校验和状态变更都变得非常好测。另外他很较真,发现测试的失败信息看不明白,会停下来先把框架改好再继续写用例。
王宇航 需要改进的地方:有时候会一口气写很多再一起提交,导致 commit 粒度偏大,助教如果按 commit 追功能会不太好对应。我们已经约定后面按功能点拆小提交。另外写页面时容易先追求视觉效果,这次是同伴提醒之后才回头补的空状态和错误提示。
同时也说一下自己的问题:我在估算工时上偏乐观,编码环节低估了 120 分钟;下次宁愿多留一点缓冲,也不要把时间排满。
posted on
浙公网安备 33010602011771号