Initial commit: MyBlog full stack blog

This commit is contained in:
2026-08-22 22:28:41 +08:00
commit c61193f2af
64 changed files with 7290 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
/* =====================================================================
* 路由与页面壳(router.js
* ---------------------------------------------------------------------
* 【这个文件是干什么的?】
* 博客是"单页面应用"SPA):全程只有一个 index.html
* 切换页面不会刷新整个网页,而是由 JavaScript 根据网址决定显示哪块内容。
*
* 【核心概念:History API】
* - history.pushState(url, "", path):只修改地址栏网址,不刷新页面
* - window 的 popstate 事件:用户点浏览器"后退 / 前进"按钮时触发
* 两者配合,就能实现"地址变了 → 内容跟着变",而且前进后退都好用。
* 相关代码在 main.js 里:window.addEventListener("popstate", render)
*
* 【本文件的四个主要职责】
* 1. renderNav / renderAuthArea:渲染顶栏的导航链接和登录 / 用户菜单
* 2. parsePath:把网址字符串"翻译"成路由对象(/article/3 -> {name:"article", id:3}
* 3. navigate:点击链接时更新地址栏并重新渲染(SPA 的跳转核心)
* 4. render:根据当前地址分发任务,调用对应的页面渲染函数
* 其他模块(main.js / auth.js / manage.js 等)都会 import 这里的函数。
* ===================================================================== */
import { FRIEND_STATUS_TEXT, PARTITIONS, ROLE_NAMES, state } from "./state.js";
import { $, escapeHtml, showToast } from "./utils.js";
import { logout, openLoginModal, openProfileModal, openRegisterModal, refreshUser } from "./auth.js";
import { applyFriend, openFriendListModal, openFriendManageModal } from "./friend.js";
import { renderArticle, renderHome, renderNotFound, renderPartition, renderProjectDetail } from "./article.js";
import { renderManage } from "./manage.js";
/* ---------------- 导航与顶栏 ---------------- */
/** 渲染顶部导航栏
* 把导航链接拼成 HTML 字符串,塞进 <nav id="nav"> 里。
* PARTITIONS 来自 state.js,例如 [{id:1, name:"我的生活"}, ...]
* .map() 就是"数组里的每一项都生成一段链接 HTML",最后 join("") 拼成字符串。
*
* 关键属性:
* - data-link:给 main.js 的全局点击监听器用的标记,
* 点击带 data-link 的链接不会真的跳页,而是调用 navigate() 走 SPA 路由
* - data-path:记录这个链接对应的路径,供 renderNavActive 判断"当前在哪页"
*/
export function renderNav() {
const nav = $("#nav");
const home = `<a class="nav-link" data-link href="/" data-path="/">首页</a>`;
const partitionLinks = PARTITIONS.map(
(p) => `<a class="nav-link" data-link href="/${p.slug}" data-path="/${p.slug}">${escapeHtml(p.name)}</a>`
);
nav.innerHTML = home + partitionLinks.join("");
}
/** 高亮当前页面的导航链接
* 遍历导航栏里所有 .nav-link,对比它的 data-path 和当前地址 location.pathname
* 匹配的那个加上 .active 样式类(classList.toggle 第二个参数为 true 就加、false 就删)。
* 首页特殊处理:只有地址正好是 "/" 才算首页高亮。
*/
export function renderNavActive() {
const path = location.pathname;
$("#nav").querySelectorAll(".nav-link").forEach((link) => {
const target = link.dataset.path;
const active = target === "/" ? path === "/" : path.startsWith(target);
link.classList.toggle("active", active);
});
}
/** 渲染右上角登录区
* 根据全局状态 state.user 做"条件渲染"
* - 没登录:显示"登录 / 注册"两个按钮
* - 已登录:显示用户名 + 角色徽章 + 下拉菜单(菜单项按角色区分)
* visitor 游客 -> 申请好友;blogger 博主 -> 发文管理 / 好友申请管理;都有"退出登录"
*/
export function renderAuthArea() {
const area = $("#auth-area");
// 未登录:渲染两个按钮,并绑定点击事件打开登录 / 注册弹窗
if (!state.user) {
area.innerHTML = `
<button class="btn btn-text" id="btn-login">登录</button>
<button class="btn btn-primary" id="btn-register">注册</button>`;
$("#btn-login").addEventListener("click", openLoginModal);
$("#btn-register").addEventListener("click", openRegisterModal);
return;
}
// 已登录:从全局状态解构出用户名和角色
const { username, role } = state.user;
const roleName = ROLE_NAMES[role] || role; // 角色中文名,查不到就用原始值
area.innerHTML = `
<div class="user-menu">
<button class="btn btn-text" id="btn-user">👤 ${escapeHtml(username)}<span class="role-badge">${roleName}</span></button>
<div class="user-dropdown hidden" id="user-dropdown"></div>
</div>`;
const dropdown = $("#user-dropdown");
// items 数组存放"菜单项",每一项 = { label: 显示文字, action: 点击后执行的函数 }
const items = [];
if (role === "visitor") {
const label = FRIEND_STATUS_TEXT[state.friendStatus] || "申请好友";
items.push({
label,
action: state.friendStatus === "pending"
? () => showToast("申请已提交,请等待博主审批", "info") // 已申请过:只提示不再重复申请
: applyFriend,
});
}
if (role === "friend") {
items.push({ label: "个人设置", action: openProfileModal }); // 好友可上传自己的头像
items.push({ label: "好友列表", action: openFriendListModal });
}
if (role === "blogger") {
items.push({ label: "发文管理", action: () => navigate("/manage") }); // 跳转到管理页
items.push({ label: "个人设置", action: openProfileModal }); // 博主也可更换头像
items.push({ label: "好友列表", action: openFriendListModal });
items.push({ label: "好友申请管理", action: openFriendManageModal });
}
items.push({ label: "退出登录", action: logout });
// 把 items 数组渲染成下拉菜单的 <a> 列表
dropdown.innerHTML = items.map((item) => `<a class="dropdown-item" href="#">${item.label}</a>`).join("");
// 给每个菜单项绑定点击:先收起下拉,再执行对应的 action 函数
dropdown.querySelectorAll(".dropdown-item").forEach((el, index) => {
el.addEventListener("click", (e) => {
e.preventDefault(); // 阻止 <a href="#"> 的默认跳转(跳到页顶)
dropdown.classList.add("hidden");
items[index].action();
});
});
// 点用户名按钮:切换下拉菜单显示 / 隐藏
// stopPropagation 阻止事件冒泡,避免触发 main.js 里"点空白处收起菜单"的逻辑
$("#btn-user").addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("hidden");
});
}
/** 渲染页面"外壳":导航栏 + 高亮 + 登录区
* 页面切换时,顶栏需要保持最新状态(比如登录前后、所在分区变化),
* 所以 render() 里每次都会调用这个函数刷新顶栏。
*/
export function renderShell() {
renderNav();
renderNavActive();
renderAuthArea();
}
/* ---------------- 路由(History API ---------------- */
/** 网址 -> 路由对象
* 把浏览器地址翻译成一个"路由对象",让 render 知道该渲染什么:
* "/" -> { name: "home" } 首页(简介 + 全部文章)
* "/manage" -> { name: "manage" } 博主管理页
* "/life" -> { name: "partition", slug } 生活区分区页
* "/study" -> { name: "partition", slug } 学习区分区页
* "/projects" -> { name: "partition", slug } 项目专区
* "/life/12" -> { name: "article", id } 生活区文章详情
* "/study/12" -> { name: "article", id } 学习区文章详情
* "/projects/1" -> { name: "project", id } 项目详情
* 其他任何路径 -> { name: "notfound" } 404 页
*/
export function parsePath(path) {
if (path === "/") return { name: "home" };
if (path === "/manage") return { name: "manage" };
// 分区页:/life /study /projects(不带文章 id
let match = path.match(/^\/(life|study|projects)\/?$/);
if (match) return { name: "partition", slug: match[1] };
// 文章详情:/life/1 /study/1(前缀=分区,数字=文章 id)
match = path.match(/^\/(life|study)\/(\d+)\/?$/);
if (match) return { name: "article", slug: match[1], id: Number(match[2]) };
// 项目详情:/projects/1
match = path.match(/^\/projects\/(\d+)\/?$/);
if (match) return { name: "project", id: Number(match[1]) };
return { name: "notfound" };
}
/** SPA 跳转函数
* 流程:
* 1. 如果目标路径就是当前路径,直接重新渲染(相当于"刷新"当前页)
* 2. 否则用 history.pushState 更新地址栏(不刷新页面!)
* 3. 调用 render() 根据新地址渲染对应内容
*
* 为什么不直接 location.href = path
* 那样会让浏览器整页刷新(重新下载 index.html 和所有 JS),
* SPA 的意义就是"只换内容、不重新加载"。
*/
export function navigate(path) {
if (location.pathname === path) { render(); return; }
history.pushState({}, "", path);
render();
}
/** 总渲染入口:根据当前地址决定渲染哪个页面
* 流程:
* 1. parsePath 解析当前地址 -> 得到路由对象
* 2. 先显示"加载中…"占位,避免切换页面时看到旧内容
* 3. 按路由名称调用对应的页面渲染函数(这些函数都在 article.js / manage.js 里)
* 4. 出错时显示错误页(401 登录失效不弹提示,其他错误弹 toast)
* 5. 最后把滚动条滚回顶部(新页面从顶部开始看)
*
* 注意 async/await:页面渲染函数需要向服务器请求数据(fetch),
* 所以这里是异步的——先 await 数据回来,再往 #app 里填内容。
*/
export async function render() {
const route = parsePath(location.pathname);
const app = $("#app");
app.innerHTML = '<div class="loading">加载中…</div>';
renderNavActive(); // 切换页面时同步更新导航高亮
try {
if (route.name === "home") await renderHome(app);
else if (route.name === "partition") await renderPartition(app, route.slug);
else if (route.name === "article") await renderArticle(app, route.id);
else if (route.name === "project") await renderProjectDetail(app, route.id);
else if (route.name === "manage") await renderManage(app);
else renderNotFound(app);
} catch (err) {
// 兜底错误处理:显示错误信息 + 返回首页按钮
document.title = "出错了 - 孤竹居士的博客";
app.innerHTML = `
<div class="empty-block">
<p>!</p>
<p class="muted">${escapeHtml(err.message)}</p>
<a class="btn btn-primary" data-link href="/">返回首页</a>
</div>`;
if (err.status !== 401) showToast(err.message, "error");
}
window.scrollTo(0, 0);
}