本项目采用路线二:AI Agent 开发,主要使用 HTML5、CSS3、JavaScript 和 Vite 完成前端
开发时,我尤其在意完整博客功能与视觉效果的呈现。因此除了实现了博客的基本功能外,还接入自定义 AI 接口、本机恢复,并反复调整了视觉方面的内容,如:刷新区域、页面切换的平滑动画等
可维护性方面,我采用了模块化的代码结构,将不同的功能和组件分离开来,方便后续的维护和扩展
因此,您可以重点关注以上方面的内容
我并不打算做和商店交易方面相关的内容,因为产品定位是一个博客发布网站
我将前两次作业中的设计的“印小灵”AI助手又迁移到了小B书(当然,经过了SFT微调),其命名为 Octorange。宠物的回复支持 Markdown 显示,且列表、代码块和表格等都能流畅阅读
登录后显示宠物,有待机、悬停和思考三种状态,且支持随意拖动,未登录时隐藏
猫耳朵上方会轮换显示白底问候气泡
用户可以点击导航栏右上角的齿轮打开设置,关闭动态问候或隐藏宠物
我给所有页面的切换和刷新增加了平滑过渡效果,尽量避免出现闪屏等影响视觉美观的情况
每篇文章的编辑页可以选择“公开发布”或“不公开(存为草稿)”,点击保存后生效。已公开文章改为不公开后移入草稿箱,并取消置顶;这是后端的草稿状态,已上传图片的链接仍然公开
编辑器默认使用分栏模式(左边 Markdown,右边预览)(这是我认为最方便的编辑方式)
草稿在停止输入约 2 秒后会自动保存,开关统一放在右上角设置的“写作”分区中
如果两个页面同时修改同一篇文章,则会保留本地输入,并排展示本地和服务器内容,方便核对后决定采用哪一份
我的文章和本人主页可以置顶文章(最多两篇)
个人主页可以修改头像、封面、短句、Markdown 个人介绍和外部链接,也能按月份查看文章
关注页则将同一位作者的文章放在一起,方便检索
截图已按当前页面重新生成。桌面视口为 1440×960,手机视口为 440×956,表格中的预览统一高度,点击图片可以查看原图。首页和阅读画面使用本机模拟接口返回的示例文章,写作画面使用未提交的示例内容;异常画面由浏览器内的模拟接口返回错误,不会向校园服务提交数据
| 画面 | 网页端 | 手机端 |
|---|---|---|
| 首页文章排列(PNG 原图) | ![]() |
![]() |
| 文章阅读:点赞、收藏、分享 | ![]() |
![]() |
| 收藏后选择收藏夹 | ![]() |
![]() |
| Markdown 写作与预览(示例内容) | ![]() |
![]() |
| 统一设置 | ![]() |
![]() |
阅读区保留“点赞、收藏、分享”三个按钮。点击收藏成功后自动打开收藏夹选择窗口,选好后保存;取消选择时保留在默认收藏夹。取消收藏不会弹窗,之后可以在“我的收藏”的文章卡片中重新整理分组
下面通过模拟接口分别触发列表读取失败、文章不存在和登录失败,截图保留页面实际显示的提示与操作入口,演示账号和写作示例均只用于本机截图
| 触发情况 | 网页端 | 手机端 |
|---|---|---|
| 列表接口返回 503,显示读取失败与刷新入口 | ![]() |
![]() |
| 文章详情返回 404,显示错误与重试入口 | ![]() |
![]() |
| 登录接口返回 401,显示账号或密码错误 | ![]() |
![]() |
| 模块 | 具体内容 |
|---|---|
| 账号 | 注册、登录、退出、当前用户展示、刷新后确认登录状态、登录失效引导 |
| 首页 | 图文列表、关键词搜索、话题筛选、作者搜索、分页、文章发布公告 |
| 阅读 | 文章详情、图片、作者信息、文章链接直接访问与刷新、本机进度恢复、复制文章链接 |
| 创作 | 新建文章、编辑已发布文章、删除本人文章,编辑时保留图片和商品关联 |
| 草稿 | 独立草稿箱、搜索、分页、继续编辑、确认删除、自动保存、本机备份恢复 |
| 编辑保护 | 按 version 保存、串行写入、版本冲突对比、保存结果未知时回读核对、离开提醒 |
| Markdown | 编辑、预览和分栏视图,解析后清理 HTML,代码块与表格可独立滚动 |
| 图片 | 本地预览、逐张上传、素材库分页、复用已上传图片、移出文章、确认删除未使用素材 |
| 互动 | 点赞与取消、收藏与取消、主评论和一级回复、分页、删除自己的评论 |
| 阅读整理 | 最多两篇置顶、收藏成功后选择收藏夹、收藏夹增删改与筛选、移动收藏文章、本人点赞历史 |
| 个人主页 | 公开资料与文章、头像、封面、短句、个人介绍、外部链接、月份归档 |
| 关注 | 关注与取消、粉丝数同步、按作者分组的关注文章、关注和粉丝分页名单 |
| 通知 | 未读角标、类型与阅读状态筛选、分页、单条及全部已读、本机标记未读、跳转文章或作者 |
| 宠物 AI | Octorange 文字对话、Markdown 回复、引用展示、三种角色状态、拖动、问候与显示设置 |
| 设置 | 支持设置是否自动保存草稿、宠物的显示状态等 |
未筛选的首页最新文章,第一页最多显示 22 篇,其余每页最多 20 篇;搜索、话题和私人列表每页最多 20 篇。首页会随屏幕宽度调整为 1~5 列
| 用途 | 选择 |
|---|---|
| 页面和交互 | HTML5 + CSS3 + JavaScript |
| 开发与构建 | Node.js24、Vite8 |
| 后端通信 | Fetch、REST API |
| AI | 阿里云百炼应用 API,通过 Vite 服务端插件转发 |
在项目根目录按 .env.example 创建 .env.local,填写以下两项:
DASHSCOPE_API_KEY=请填写百炼API密钥
DASHSCOPE_APP_ID=08f487dd5e10446287bec082430afe7f // 这个应用 ID 对应本项目使用的新百炼应用开发和预览服务均通过 tools/ai-plugin.mjs 提供 /api/ai/chat 与 /api/ai/status
目录树和主要文件用途放在 项目结构说明 中
安装 Node.js 24 后,在项目目录运行 npm ci,再双击 start.cmd,或运行 npm run dev,浏览器访问 http://127.0.0.1:5173。学校接口需要接入校园网络,AI 配置按上面的环境变量说明填写
提交前双击根目录的 package.cmd,也可以运行 npm run package。程序会自动执行风格与项目检查、生产构建,再把源码、素材、截图、文档、依赖清单和配置示例打包到 submissions/,完成后显示压缩包的完整路径,每次生成新文件
压缩包内统一放在 BiliBlog/ 目录中,排除 node_modules/、dist/、Git 记录、本机环境变量和旧压缩包。根目录新增的 Markdown 文档,以及 docs/ 中的代码理解材料会一同收录;打包后逐个核对文件内容,失败时自动清理临时 ZIP
路线二附加材料采用自己撰写的代码理解与开发说明,提交前应确认已完成并放入根目录或 docs/。在作业平台的提交说明中注明“路线二:AI Agent 开发”,并粘贴 README 的主要说明
注册页和登录页都是用文件 account.js,只是通过页面的 data-mode 来区分是登录还是注册
// 检查注册页的昵称和两次密码,把错误交给浏览器表单提示
function validateRegistration() {
if (!isRegister) {
return;
}
// 每次先清除旧错误,改正输入后才能继续提交
displayName.setCustomValidity("");
confirmPassword.setCustomValidity("");
if (displayName.value.trim().length === 0) {
displayName.setCustomValidity("昵称不能只包含空格");
}
if (password.value !== confirmPassword.value) {
confirmPassword.setCustomValidity("两次输入的密码不一致");
}
}const credentials = {
username: username.value,
password: password.value
};
let path = "/auth/login";
if (isRegister) {
path = "/auth/register";
credentials.displayName = displayName.value.trim();
}
// 等待服务器时禁用表单,避免连续点击产生重复请求
submitting = true;
fields.disabled = true;
try {
const session = await requestApi(path, "POST", credentials, null);
saveSession(session);
window.location.replace("/index.html");
} catch (error) {
showMessage(message, error.message, true);
} finally {
submitting = false;
fields.disabled = false;
}我让注册和登录只在请求路径与昵称字段上区分,其他步骤共用(毕竟这块几乎一模一样)
这里的 requestApi 使用 Fetch,把 credentials 转成 JSON 发送到学校后端后等待响应,之后检查 HTTP 状态后返回响应中的 data
保存前,auth.js 会检查 token 是否存在,以及 expiresAt 是否为有效的未来时间
核心保存语句如下:
const savedSession = {
token: session.token,
expiresAt: session.expiresAt
};
localStorage.setItem(SESSION_KEY, JSON.stringify(savedSession));这里的 SESSION_KEY 来自 config.js,保存的内容是服务器签发的 token 和到期时间
刷新后,readSession() 读取这份 JSON 并检查有效期,再通过携带 Authorization: Bearer <token> 的请求让后端确认身份
首页布局主要在 home.js 和 home.css 中实现
宽屏首页分成五列,左侧大图占两列、两行,右侧三列各放两篇普通文章,后面的文章继续按五列排列
考虑到第一页有一张大图文章,因此第一页文章数上限设置成 22,而后面所有页的文章数上限为 20
下面是我设计的网格和大图占位的核心样式:
/* 普通桌面默认四列,每列可以随容器缩小 */
.article-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 32px 20px;
align-items: start;
}
/* 宽屏改为五列 */
@media (min-width: 1400px) {
.article-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
/* 满足大图条件时,第一张卡片占两列和两行 */
@media (min-width: 1200px) {
.article-grid.has-feature .article-card:first-child {
grid-column: span 2;
grid-row: span 2;
align-self: stretch;
}
}目前,只有未搜索、未筛选话题的最新文章栏目的第一页才使用大图(且当前页至少有 5 篇文章)
带图文章的选择标准是:最新的一篇带图文章。只有当当前页都是纯文字文章时,则使用最新一篇的文字封面
下面摘出 loadFeed() 中的核心代码片段:
let featureIndex = 0;
if (useFeature) {
// 优先选本页第一篇带图文章,找不到时使用第一篇
for (let index = 0; index < result.items.length; index = index + 1) {
if (result.items[index].images.length > 0) {
featureIndex = index;
break;
}
}
grid.appendChild(makeCard(result.items[featureIndex], featureIndex, true));
}
// 大图已经显示过的文章跳过,其余文章保留原来的相对顺序
for (let index = 0; index < result.items.length; index = index + 1) {
if (useFeature && index === featureIndex) {
continue; // 跳过已经展示的大图文章,避免同一篇出现两次
}
grid.appendChild(makeCard(result.items[index], index));
}
grid.classList.toggle("has-feature", useFeature);这里用了 has-feature 把脚本与样式连接起来。当脚本添加这个类后,CSS 把第一张卡片放大,其他卡片继续使用普通样式
我通过 F12 工具查看不同手机屏幕上画面的呈现效果,从而调整列数(当然,不是一个一个调,只是查看效果)。根据研究,一些手机上取消大图跨行、让文章按普通卡片排列的视觉效果最优
以下为实际实验数据(由 agent 总结):
| 屏幕宽度 | 列数 | 大图排列 |
|---|---|---|
| 1400px 及以上 | 5 列 | 大图占两列、两行,右侧六篇普通文章 |
| 1200~1399px | 4 列 | 大图占两列、两行,右侧四篇普通文章 |
| 768~1199px | 3 列 | 全部使用普通卡片 |
| 360~767px | 2 列 | 全部使用普通卡片 |
| 359px 及以下 | 1 列 | 全部使用普通卡片 |
首页“刷新”按钮绑定的是 home.js 中的 loadFeed()。
当用户点击刷新时,只需要重新请求当前列表,再替换文章网格里的节点,而导航、横幅和话题区继续使用原来的 DOM,从而实现了仅在部分区域刷新
refreshButton.addEventListener("click", loadFeed);具体而言,feedKey 由当前列表的接口路径和登录凭证组成,路径中包含搜索、话题和页码等条件。同一个列表刷新时保留卡片,切换条件时再按原流程显示占位,核心代码如下:
const feedKey = feedPath() + "|" + token;
const keepCards = keepFavorites || (displayedFeedKey === feedKey && grid.hasChildNodes());
grid.setAttribute("aria-busy", "true");
grid.inert = true;
if (!keepCards) {
pagination.hidden = true;
displayedFeedKey = "";
displayedFavoritesToken = null;
grid.replaceChildren();
}由于网络请求可能交错返回,因此 agent 选择在每次读取开始时先增加请求编号,返回后再核对编号和账号。下面三段分别摘自请求开始、收到结果和结束清理的位置,列表排版继续使用上一节的大图选择与卡片生成逻辑
feedRequest = feedRequest + 1;
const requestNumber = feedRequest;const result = await requestFeed();
const activeToken = currentToken();
const becameGuest = feed === "latest" && !activeToken;
if (requestNumber !== feedRequest || (activeToken !== token && !becameGuest)) {
return;
}
// 新响应属于当前列表时,再清空旧卡片并按本页数据生成新卡片
grid.replaceChildren();// 这段位于 finally 中,最新请求结束后恢复列表和分页交互
if (requestNumber === feedRequest) {
refreshButton.disabled = false;
setBusy(refreshButton, false);
grid.setAttribute("aria-busy", "false");
grid.inert = false;
pagination.inert = false;
}标签展开由 home.js 改变可见项,动画则由 motion.js 的 resizePanel() 实现
// 切换展开状态,再由共用函数播放容器高度的过渡
function toggleTopics() {
const expanded = moreTopics.getAttribute("aria-expanded") !== "true";
// 具名回调只负责改变标签状态,测量与动画交给 resizePanel
resizePanel(topicList, function changeTopicVisibility() {
moreTopics.setAttribute("aria-expanded", String(expanded));
updateTopicVisibility();
});
}updateTopicVisibility() 根据屏幕宽度、展开状态隐藏或显示标签,小屏幕手机默认保留三个标签和“更多”。这里保留原来的按钮和箭头节点,改文字时只更新文字节点,所以箭头可以从当前角度继续旋转
直接隐藏或显示标签会让容器高度瞬间变化。resizePanel() 先量出当前高度,更新内容后再量出目标高度,然后把这两个数作为动画的起点和终点,下面摘出它的测量和播放步骤
const startHeight = element.getBoundingClientRect().height;
const previous = panelChanges.get(element);
if (previous) {
// 连续点击时取消上一轮结束处理,新的动画从当前高度继续
element.removeEventListener("transitionend", previous.finish);
window.clearTimeout(previous.timer);
}
element.classList.remove("is-resizing");
element.style.removeProperty("height");
updateContent();
const endHeight = element.getBoundingClientRect().height;// 起点先写入像素高度,让浏览器记录这一帧的布局
element.style.height = startHeight + "px";
void element.offsetHeight;
element.classList.add("is-resizing");
element.addEventListener("transitionend", finishResize);
element.style.height = endHeight + "px";void element.offsetHeight 会先读取一次布局,让浏览器先确认起点,再处理目标高度
动画结束后,finishResize() 移除固定高度和过渡类,容器恢复自然排版,并清理监听器
下面的参数来自 tokens.css,高度过渡维护在 feedback.css,箭头旋转维护在 home.css
:root {
/* 标签展开时长为 200ms,整个过程先快后慢 */
--duration-panel: 200ms;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
/* 只在过渡期间固定高度并裁掉溢出的内容 */
.is-resizing {
overflow: clip;
transition: height var(--duration-panel) var(--ease-out);
}
/* 保留同一个箭头节点,展开时旋转,收起时转回 */
.more-topics svg {
transition: transform var(--duration-panel) var(--ease-out);
}
.more-topics[aria-expanded="true"] svg {
transform: rotate(180deg);
}这段代码调整了标签容器的高度和箭头角度,使文章区随容器的实际高度连续移动
结束后恢复自然高度,后续切换手机、桌面宽度时仍能重新排版
| 项目 | 当前情况与后续处理 |
|---|---|
| 校园网络 | 学校接口依赖校园网络 |
| AI 部署 | 静态页面配套部署 AI 转发接口,密钥保存在服务端环境中 |
| 模拟商铺 | 未来暂时不考虑接入 |
| 其他扩展 | 后续可能考虑设计深色模式(由于担心太丑就先没做) |
我使用 AI Agent 协助拆解需求、编写部分代码、分析报错和检查页面,并根据实际操作提出修改
阅读窗口的加载尺寸、评论刷新范围、平滑动画设计等是我在开发中逐步核对和调整的内容
















