Files
guzhujushiBlog/frontend/router.js
T

224 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 路由与页面壳(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);
}