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
+63
View File
@@ -0,0 +1,63 @@
/* =====================================================================
* 网络层(api.js
* 统一调用 /api/* 接口;localStorage 只保存 token 与 username(绝不保存密码)。
* ===================================================================== */
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
export async function api(path, { method = "GET", body } = {}) {
const headers = { "Content-Type": "application/json" };
const token = localStorage.getItem("token");
if (token) headers["Authorization"] = `Bearer ${token}`;
let res;
try {
res = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
} catch (err) {
throw new ApiError("网络请求失败,请稍后再试", 0);
}
let json = null;
try { json = await res.json(); } catch (err) { /* 忽略非 JSON 响应 */ }
if (!json || typeof json.success === "undefined") {
throw new ApiError(`服务器响应异常(${res.status}`, res.status);
}
if (!json.success) throw new ApiError(json.message || "请求失败", res.status);
return json.data;
}
// multipart 表单提交(普通字段 + 单个文件),用于项目管理等
export async function uploadForm(path, fields = {}, file = null, method = "POST") {
const form = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined && value !== null && value !== "") form.append(key, String(value));
}
if (file) form.append("file", file);
const headers = {};
const token = localStorage.getItem("token");
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(path, { method, headers, body: form });
let json = null;
try { json = await res.json(); } catch (err) { /* 忽略非 JSON 响应 */ }
if (!json || !json.success) throw new ApiError((json && json.message) || "操作失败", res.status);
return json.data;
}
// multipart 上传(头像/图片/项目文件),返回 {url, filename, size}
export async function uploadFile(path, file, extra = {}) {
const form = new FormData();
form.append("file", file);
// extra:附加字段(如上传文档时携带文章分区 category,供后端校验)
for (const [key, value] of Object.entries(extra)) form.append(key, value);
const headers = {};
const token = localStorage.getItem("token");
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(path, { method: "POST", headers, body: form });
let json = null;
try { json = await res.json(); } catch (err) { /* 忽略非 JSON 响应 */ }
if (!json || !json.success) throw new ApiError((json && json.message) || "上传失败", res.status);
return json.data;
}
+379
View File
@@ -0,0 +1,379 @@
/* =====================================================================
* 文章视图模块(article.js
* 首页文章列表(分页)/ 分区 / 项目 / 简介 / 文章详情 / 点赞。
* ===================================================================== */
import { ABOUT_DEFAULT, ARTICLE_CATEGORIES, DONATE_IMAGE, PARTITIONS, SOCIAL_LINKS, state } from "./state.js";
import { $, escapeHtml, formatDate, openModal, showToast } from "./utils.js";
import { api } from "./api.js";
import { renderMarkdown } from "./markdown.js";
import { openLoginModal } from "./auth.js";
import { applyFriend } from "./friend.js";
import { renderComments } from "./comment.js";
/* ---------------- 首页(简介 + 社交链接 + 全部文章,分页加载) ---------------- */
export async function renderHome(app) {
document.title = "首页 - 孤竹居士的博客";
app.innerHTML = `
<section class="about-card home-about" id="about-card"><p class="muted">加载中…</p></section>
<h2 class="section-title">最新文章</h2>
<div id="article-feed"></div>`;
await renderHomeAbout($("#about-card", app));
await renderArticleList($("#article-feed", app), 6);
}
/** 主页简介卡片:头像、名字、简介(线上可改)、社交账号链接 */
async function renderHomeAbout(el) {
let profile = null;
try {
profile = await api("/api/user/blogger");
} catch (err) { /* 简介加载失败不阻塞页面 */ }
const name = (profile && profile.username) || ABOUT_DEFAULT.name;
const avatar = (profile && profile.avatar) || "";
const bio = (profile && profile.bio)
? profile.bio.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
: ABOUT_DEFAULT.bio;
const avatarHtml = avatar
? `<img class="about-avatar-img" src="${escapeHtml(avatar)}" alt="头像">`
: `<div class="about-avatar">${escapeHtml(name.slice(0, 1))}</div>`;
el.innerHTML = `
${avatarHtml}
<div class="about-main">
<h2>${escapeHtml(name)}</h2>
<p class="muted">${escapeHtml(ABOUT_DEFAULT.tagline)}</p>
<div class="about-bio">${bio.map((p) => `<p>${escapeHtml(p)}</p>`).join("")}</div>
<div class="social-links">
${SOCIAL_LINKS.map((s) => `<a class="social-link" href="${escapeHtml(s.url)}" target="_blank" rel="noopener"><img class="social-icon" src="${escapeHtml(s.icon)}" alt="${escapeHtml(s.name)}" loading="lazy">${escapeHtml(s.name)}</a>`).join("")}
</div>
<div class="about-actions">
${state.user && state.user.role === "blogger" ? '<button type="button" class="btn btn-outline btn-sm" id="btn-edit-bio">✏️ 编辑简介</button>' : ""}
<button type="button" class="btn btn-outline btn-sm" id="btn-donate">☕ 打赏支持</button>
</div>
<div class="tags">${(ABOUT_DEFAULT.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</div>
</div>`;
// 绑定按钮事件:打赏弹窗(所有人可见);编辑简介(仅博主,展示在主页简介处)
$("#btn-donate", el).addEventListener("click", openDonateModal);
const bioBtn = $("#btn-edit-bio", el);
if (bioBtn) bioBtn.addEventListener("click", () => openBioEditor(el, profile && profile.bio));
}
/** 打赏弹窗:展示收款二维码;点击图片外任意位置关闭(按 Esc 也可关闭) */
function openDonateModal() {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="donate-modal">
<img src="${DONATE_IMAGE}" alt="打赏二维码">
<p class="donate-thanks">感谢打赏</p>
</div>`;
const close = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", onKey);
// 点击图片本身不关闭(方便长按识别),点击其他任意位置关闭
overlay.addEventListener("click", (e) => {
if (e.target.closest("img")) return;
close();
});
document.body.appendChild(overlay);
}
/** 简介编辑弹窗(仅博主):入口在主页简介处,保存后立即刷新简介卡片 */
async function openBioEditor(aboutEl, currentBio) {
const form = document.createElement("div");
form.className = "form";
form.innerHTML = `
<label for="bio-edit-input">个人简介</label>
<textarea id="bio-edit-input" rows="6" maxlength="2000" placeholder="介绍一下自己…(支持换行)">${escapeHtml(currentBio || "")}</textarea>
<button type="button" class="btn btn-primary btn-block" id="btn-bio-save">保存简介</button>`;
const modal = openModal({ title: "编辑简介", content: form });
$("#btn-bio-save", form).addEventListener("click", async () => {
const btn = $("#btn-bio-save", form);
const bio = $("#bio-edit-input", form).value.trim();
btn.disabled = true; btn.textContent = "保存中…";
try {
await api("/api/user/profile", { method: "PUT", body: { bio } });
modal.close();
showToast("简介已保存", "success");
await renderHomeAbout(aboutEl); // 重新拉取最新简介并重绘
} catch (err) {
showToast(err.message, "error");
btn.disabled = false; btn.textContent = "保存简介";
}
});
}
export function articleCard(article, compact = false) {
const card = document.createElement("a");
card.className = "article-card";
// 文章详情地址按分区决定前缀:/life/1 或 /study/1
const category = article.category || "life";
card.href = `/${category}/${article.id}`;
card.setAttribute("data-link", "");
const isFriendOnly = article.visibility === "friend";
// 游客视角的好友文章:后端仅返回 id/title/cover/visibility(无 author_username 字段)
const locked = isFriendOnly && typeof article.author_username === "undefined";
const coverHtml = article.cover
? `<div class="card-cover"><img src="${escapeHtml(article.cover)}" alt="${escapeHtml(article.title)}" loading="lazy">${isFriendOnly ? '<span class="cover-badge">好友专属</span>' : ""}</div>`
: `<div class="card-cover card-cover-empty">${isFriendOnly ? "🔒" : ""}</div>`;
// 分区页(compact)只展示封面 + 标题;首页展示完整元信息
const metaHtml = compact ? "" : `
<div class="card-meta">
${isFriendOnly ? '<span class="badge badge-friend">好友专属</span>' : '<span class="badge badge-public">公开</span>'}
${locked
? '<span class="muted">仅好友可见全文</span>'
: `<span>${escapeHtml(article.author_username || "")}</span><span class="muted">${formatDate(article.created_time)}</span>`}
</div>`;
card.innerHTML = `
${coverHtml}
<div class="card-body">
<h3 class="card-title">${escapeHtml(article.title)}</h3>
${metaHtml}
</div>`;
return card;
}
/* ---------------- 文章列表分页加载(首页 / 分区复用) ---------------- */
export async function renderArticleList(container, pageSize = 6, category = "") {
container.innerHTML = "";
const list = document.createElement("div");
list.className = "article-list";
container.appendChild(list);
const moreBtn = document.createElement("button");
moreBtn.className = "btn btn-outline btn-block load-more-btn";
moreBtn.textContent = "加载更多";
moreBtn.hidden = true;
moreBtn.addEventListener("click", () => loadPage());
container.appendChild(moreBtn);
let page = 1;
let loaded = 0;
let total = 0;
async function loadPage() {
moreBtn.disabled = true;
moreBtn.textContent = "加载中…";
try {
// category 为空 = 首页全部文章;传入分区则按分区过滤
const categoryQuery = category ? `&category=${encodeURIComponent(category)}` : "";
const data = await api(`/api/article/list?page=${page}&page_size=${pageSize}${categoryQuery}`);
total = data.total;
data.items.forEach((a) => list.appendChild(articleCard(a, Boolean(category))));
loaded += data.items.length;
page += 1;
if (loaded === 0 && total === 0) {
list.innerHTML = '<p class="empty">暂无文章</p>';
moreBtn.hidden = true;
moreBtn.textContent = "加载更多";
return;
}
moreBtn.hidden = loaded >= total;
moreBtn.textContent = "加载更多";
} catch (err) {
moreBtn.textContent = "加载更多";
showToast(err.message, "error");
} finally {
moreBtn.disabled = false;
}
}
await loadPage();
}
/* ---------------- 分区 ---------------- */
export async function renderPartition(app, slug) {
if (slug === "projects") return renderProjects(app);
const partition = PARTITIONS.find((p) => p.slug === slug);
if (!partition) return renderNotFound(app);
document.title = `${partition.name} - 孤竹居士的博客`;
const sub = slug === "life" ? "生活点滴记录" : "学习笔记与总结";
app.innerHTML = `
<h1 class="page-title">${escapeHtml(partition.name)}</h1>
<p class="muted page-sub">${sub}</p>
<div id="article-feed"></div>`;
// 分区页只展示本分区的文章卡片(封面 + 标题)
await renderArticleList($("#article-feed", app), 6, slug);
}
export async function renderProjects(app) {
document.title = "我的项目 - 孤竹居士的博客";
app.innerHTML = `
<h1 class="page-title">我的项目</h1>
<p class="muted page-sub">在线体验与源码下载</p>
<div class="project-grid"></div>`;
const grid = $(".project-grid", app);
let items = [];
try { items = await api("/api/project/list"); }
catch (err) { showToast(err.message, "error"); }
if (!items.length) { grid.innerHTML = '<p class="empty">暂无项目</p>'; return; }
items.forEach((project) => {
const card = document.createElement("a");
card.className = "project-card";
card.href = `/projects/${project.id}`;
card.setAttribute("data-link", "");
const badge = project.project_type === "static"
? '<span class="badge badge-public">在线演示</span>'
: '<span class="badge badge-friend">GitHub 链接</span>';
const visBadge = project.visibility === "friend"
? '<span class="badge badge-friend">🔒 好友专属</span>'
: '<span class="badge badge-public">公开</span>';
const lockedIcon = project.visibility === "friend" ? " 🔒" : "";
card.innerHTML = `
<h3>${escapeHtml(project.name)}${lockedIcon}</h3>
<p class="muted">${escapeHtml(project.description || "")}</p>
<div class="tags">${(project.tech || "").split(",").map((t) => t.trim()).filter(Boolean).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</div>
${badge}
${visBadge}
<span class="btn btn-outline btn-sm">查看详情</span>`;
grid.appendChild(card);
});
}
/* ---------------- 项目详情 ---------------- */
export async function renderProjectDetail(app, id) {
let project = null;
try { project = await api(`/api/project/${id}`); }
catch (err) { return renderNotFound(app); }
if (!project) return renderNotFound(app);
document.title = `${project.name} - 孤竹居士的博客`;
const demoBtn = project.demo_url
? `<a class="btn btn-primary" href="${escapeHtml(project.demo_url)}" target="_blank" rel="noopener">🚀 在线运行</a>`
: "";
const downloadBtn = project.download_url
? `<a class="btn btn-outline" href="${escapeHtml(project.download_url)}" download>⬇ 下载项目</a>`
: "";
app.innerHTML = `
<nav class="breadcrumb">
<a data-link href="/">首页</a><span>/</span>
<a data-link href="/projects">我的项目</a><span>/</span>
<span>${escapeHtml(project.name)}</span>
</nav>
<article class="article-detail">
<h1 class="article-title">${escapeHtml(project.name)}</h1>
<p class="muted">${escapeHtml(project.description || "")}</p>
<div class="tags project-detail-tags">${(project.tech || "").split(",").map((t) => t.trim()).filter(Boolean).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</div>
<div class="project-detail-actions">
${demoBtn}
${downloadBtn}
</div>
</article>`;
}
/* 简介已并入首页(renderHomeAbout),不再单独成页 */
export function renderNotFound(app) {
document.title = "页面不存在 - 孤竹居士的博客";
app.innerHTML = `
<div class="empty-block">
<p>404</p>
<p class="muted">页面不存在或已被移除</p>
<a class="btn btn-primary" data-link href="/">返回首页</a>
</div>`;
}
/* ---------------- 文章详情(含点赞) ---------------- */
export async function renderArticle(app, articleId) {
const article = await api(`/api/article/${articleId}`);
const isFriendOnly = article.visibility === "friend";
const locked = isFriendOnly && article.content == null; // 游客视角:正文为 null
const category = article.category || "life";
document.title = `${article.title} - 孤竹居士的博客`;
app.innerHTML = `
<nav class="breadcrumb">
<a data-link href="/">首页</a><span>/</span>
<a data-link href="/${category}">${escapeHtml(ARTICLE_CATEGORIES[category] || "分区")}</a><span>/</span>
<span>${escapeHtml(article.title)}</span>
</nav>
<article class="article-detail">
<h1 class="article-title">${escapeHtml(article.title)}</h1>
<div class="article-meta">
<span>${escapeHtml(article.author_username || "")}</span>
<span class="muted">${formatDate(article.created_time)}</span>
${isFriendOnly ? '<span class="badge badge-friend">好友专属</span>' : ""}
</div>
${article.cover ? `<div class="article-cover"><img src="${escapeHtml(article.cover)}" alt="${escapeHtml(article.title)}"></div>` : ""}
<div class="article-content">${locked ? lockedBox() : renderMarkdown(article.content)}</div>
<div id="like-bar"></div>
<section class="comments"><h2 class="section-title">评论</h2><div id="comments"></div></section>
</article>`;
if (locked) bindLockedApply();
await renderLikeBar($("#like-bar", app), articleId);
await renderComments($("#comments", app), articleId);
}
function lockedBox() {
let actionHtml = "";
if (!state.user) {
actionHtml = '<button class="btn btn-primary" id="btn-locked-apply">登录后申请好友</button>';
} else if (state.user.role === "visitor") {
if (state.friendStatus === "pending") actionHtml = '<p class="muted">申请已提交,等待博主审批</p>';
else if (state.friendStatus === "rejected") actionHtml = '<button class="btn btn-primary" id="btn-locked-apply">重新申请好友</button>';
else actionHtml = '<button class="btn btn-primary" id="btn-locked-apply">申请成为好友</button>';
} else {
actionHtml = '<p class="muted">登录好友账号后即可查看全文</p>';
}
return `
<div class="locked-box">
<p>🔒 该文章仅好友可见</p>
${actionHtml}
</div>`;
}
function bindLockedApply() {
const btn = $("#btn-locked-apply");
if (btn) {
btn.addEventListener("click", () => {
if (!state.user) openLoginModal();
else applyFriend();
});
}
}
export async function renderLikeBar(el, articleId) {
// 后端只返回数量与“当前用户是否已点赞”,不返回点赞名单(防用户名枚举)
let likes = { count: 0, includes_me: false };
try {
likes = await api(`/api/like/list?article_id=${articleId}`);
} catch (err) { /* 好友文章对游客不可见点赞,忽略 */ }
const liked = !!likes.includes_me;
el.innerHTML = `
<div class="like-bar">
<button class="btn btn-outline like-btn${liked ? " liked" : ""}" id="btn-like">${liked ? "👍 已点赞" : "👍 点赞"} <span class="like-count">${likes.count}</span></button>
<span class="muted">${likes.count ? `${likes.count} 人点赞` : "还没有人点赞"}</span>
</div>`;
const btn = $("#btn-like", el);
if (!btn) return;
btn.addEventListener("click", async () => {
if (!state.user) {
openLoginModal();
showToast("请先登录后点赞", "info");
return;
}
if (state.user.role === "visitor") {
if (state.friendStatus === "pending") {
showToast("好友申请审批通过后即可点赞", "info");
return;
}
showToast("申请成为好友,审批通过后即可点赞", "info");
applyFriend();
return;
}
if (liked) {
showToast("您已经点过赞了", "info");
return;
}
btn.disabled = true;
try {
await api("/api/like/add", { method: "POST", body: { article_id: articleId } });
showToast("点赞成功", "success");
await renderLikeBar(el, articleId);
} catch (err) {
showToast(err.message, "error");
}
});
}
+210
View File
@@ -0,0 +1,210 @@
/* =====================================================================
* 认证模块(auth.js
* 登录 / 注册 / 会话保存 / 当前用户刷新。
* localStorage 只保存 token 与 username(绝不保存密码)。
* ===================================================================== */
import { state } from "./state.js";
import { $, escapeHtml, openModal, showToast } from "./utils.js";
import { api, uploadFile } from "./api.js";
import { render, renderShell } from "./router.js";
export function isLoggedIn() { return !!localStorage.getItem("token"); }
export function saveSession(token, username) {
localStorage.setItem("token", token);
localStorage.setItem("username", username);
}
export function logout() {
localStorage.removeItem("token");
localStorage.removeItem("username");
state.user = null;
state.friendStatus = "none";
renderShell();
render();
showToast("已退出登录");
}
export async function refreshUser() {
if (!isLoggedIn()) { state.user = null; state.friendStatus = "none"; return; }
try {
const data = await api("/api/user/level");
state.user = {
id: data.user_id,
username: localStorage.getItem("username") || "",
role: data.role,
};
if (data.role === "blogger") state.friendStatus = "blogger";
else if (data.role === "friend") state.friendStatus = "accepted";
else {
try {
const fs = await api("/api/friend/status");
state.friendStatus = fs.status;
} catch (err) { state.friendStatus = "none"; }
}
} catch (err) {
if (err.status === 401) logout();
else showToast(err.message, "error");
}
}
export async function afterAuthSuccess(username) {
await refreshUser(); // 拉取真实角色与好友状态
renderShell();
render();
}
export function openLoginModal() {
const form = document.createElement("form");
form.className = "form";
form.innerHTML = `
<label for="login-email">邮箱</label>
<input id="login-email" name="email" type="email" required placeholder="you@example.com">
<label for="login-password">密码</label>
<input id="login-password" name="password" type="password" required placeholder="请输入密码">
<button class="btn btn-primary btn-block" type="submit">登录</button>
<p class="form-hint">还没有账号?<a href="#" id="goto-register">立即注册</a></p>`;
const modal = openModal({ title: "登录", content: form });
$("#goto-register", form).addEventListener("click", (e) => {
e.preventDefault();
modal.close();
openRegisterModal();
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = form.email.value.trim();
const password = form.password.value;
if (!email || !password) { showToast("请填写邮箱和密码", "error"); return; }
const btn = $('button[type="submit"]', form);
btn.disabled = true; btn.textContent = "登录中…";
try {
const data = await api("/api/login", { method: "POST", body: { email, password } });
saveSession(data.token, data.username);
modal.close();
await afterAuthSuccess(data.username);
showToast("登录成功", "success");
} catch (err) {
showToast(err.message, "error");
} finally {
btn.disabled = false; btn.textContent = "登录";
}
});
}
/** 好友个人设置:上传自己的头像(访客禁止上传,后端有角色校验) */
export function openProfileModal() {
const content = document.createElement("div");
content.innerHTML = `
<div class="profile-avatar-row">
<div class="profile-avatar" id="profile-avatar"><span class="muted">…</span></div>
<div>
<button type="button" class="btn btn-outline" id="btn-avatar-upload">📷 上传头像</button>
<input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp" hidden>
<p class="muted form-hint">支持 jpg / png / webp,最大 4MB</p>
</div>
</div>`;
const modal = openModal({ title: "个人设置", content });
const avatarBox = $("#profile-avatar", content);
const renderPreview = (url) => {
avatarBox.innerHTML = url
? `<img src="${escapeHtml(url)}" alt="头像预览">`
: '<span class="muted">…</span>';
};
api("/api/user/me").then((me) => renderPreview(me.avatar || "")).catch(() => {});
$("#btn-avatar-upload", content).addEventListener("click", () => $("#avatar-file", content).click());
$("#avatar-file", content).addEventListener("change", async () => {
const file = $("#avatar-file", content).files && $("#avatar-file", content).files[0];
if (!file) return;
try {
const data = await uploadFile("/api/upload/avatar", file);
renderPreview(data.url);
showToast("头像上传成功", "success");
} catch (err) { showToast(err.message, "error"); }
$("#avatar-file", content).value = "";
});
}
export function openRegisterModal() {
const form = document.createElement("form");
form.className = "form";
form.innerHTML = `
<label for="reg-email">邮箱</label>
<input id="reg-email" name="email" type="email" required placeholder="you@example.com">
<label for="reg-code">邮箱验证码</label>
<div class="code-row">
<input id="reg-code" name="code" type="text" inputmode="numeric" maxlength="6" placeholder="6 位验证码" required>
<button type="button" class="btn btn-outline" id="btn-send-code">发送验证码</button>
</div>
<label for="reg-username">用户名</label>
<input id="reg-username" name="username" type="text" required minlength="2" maxlength="50" placeholder="用户名(2-50 字符)">
<label for="reg-password">密码</label>
<input id="reg-password" name="password" type="password" required minlength="6" maxlength="128" placeholder="至少 6 位">
<button class="btn btn-primary btn-block" type="submit">注册</button>
<p class="form-hint">已有账号?<a href="#" id="goto-login">直接登录</a></p>`;
const modal = openModal({ title: "注册", content: form });
$("#goto-login", form).addEventListener("click", (e) => {
e.preventDefault();
modal.close();
openLoginModal();
});
// 发送邮箱验证码(60 秒倒计时防重复点击;后端另有按邮箱/IP 的小时限流)
const sendCodeBtn = $("#btn-send-code", form);
sendCodeBtn.addEventListener("click", async () => {
const email = form.email.value.trim();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showToast("请先填写正确的邮箱", "error");
return;
}
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = "发送中…";
try {
await api("/api/email/send-code", { method: "POST", body: { email, purpose: "register" } });
showToast("验证码已发送,请查收邮件", "success");
let countdown = 60;
const timer = setInterval(() => {
countdown -= 1;
if (countdown <= 0) {
clearInterval(timer);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = "发送验证码";
} else {
sendCodeBtn.textContent = `${countdown}s 后重发`;
}
}, 1000);
} catch (err) {
showToast(err.message, "error");
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = "发送验证码";
}
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = form.email.value.trim();
const username = form.username.value.trim();
const password = form.password.value;
const code = form.code.value.trim();
if (!email || !username || !password || !code) { showToast("请填写完整信息", "error"); return; }
if (password.length < 6) { showToast("密码至少 6 位", "error"); return; }
const btn = $('button[type="submit"]', form);
btn.disabled = true; btn.textContent = "注册中…";
try {
await api("/api/register", { method: "POST", body: { email, username, password, code } });
// 注册成功后自动登录
const loginData = await api("/api/login", { method: "POST", body: { email, password } });
saveSession(loginData.token, username);
modal.close();
await afterAuthSuccess(username);
showToast("注册成功", "success");
} catch (err) {
showToast(err.message, "error");
} finally {
btn.disabled = false; btn.textContent = "注册";
}
});
}
+126
View File
@@ -0,0 +1,126 @@
/* =====================================================================
* 评论模块(comment.js
* 评论列表(按 parent_id 分组展示回复)+ 发表评论 / 回复表单。
* 仅好友或博主可评论。
* ===================================================================== */
import { state } from "./state.js";
import { $, escapeHtml, formatDate, showToast } from "./utils.js";
import { api } from "./api.js";
import { openLoginModal } from "./auth.js";
import { applyFriend } from "./friend.js";
export async function renderComments(el, articleId) {
let comments = [];
try {
comments = await api(`/api/comment/list?article_id=${articleId}`);
} catch (err) { /* 好友文章对游客不可见评论,忽略 */ }
el.innerHTML = `<p class="muted">共 ${comments.length} 条评论</p>`;
const list = document.createElement("div");
list.className = "comment-list";
if (!comments.length) list.innerHTML = '<p class="empty">暂无评论,来抢沙发吧</p>';
// 按 parent_id 分组:顶层评论在前,回复跟随其父评论
const byId = new Map(comments.map((c) => [c.id, c]));
const topLevel = comments.filter((c) => c.parent_id == null);
const repliesByParent = {};
comments.filter((c) => c.parent_id != null).forEach((r) => {
(repliesByParent[r.parent_id] = repliesByParent[r.parent_id] || []).push(r);
});
topLevel.forEach((c) => {
list.appendChild(buildCommentItem(el, articleId, c, byId, false));
(repliesByParent[c.id] || []).forEach((r) => {
list.appendChild(buildCommentItem(el, articleId, r, byId, true));
});
});
el.appendChild(list);
// 评论 / 回复表单(仅好友或博主)
const canComment = state.user && (state.user.role === "friend" || state.user.role === "blogger");
if (canComment) {
el.appendChild(buildCommentForm(el, articleId, null));
} else {
const hint = document.createElement("p");
hint.className = "muted comment-hint";
if (state.user) {
hint.innerHTML = `仅好友或博主可以评论 <button class="btn btn-outline btn-sm" id="hint-apply">申请好友</button>`;
$("#hint-apply", hint).addEventListener("click", applyFriend);
} else {
hint.innerHTML = `登录并成为好友后可以评论 <button class="btn btn-outline btn-sm" id="hint-login">去登录</button>`;
$("#hint-login", hint).addEventListener("click", openLoginModal);
}
el.appendChild(hint);
}
}
function buildCommentItem(el, articleId, comment, byId, isReply) {
const item = document.createElement("div");
item.className = isReply ? "comment-item comment-reply" : "comment-item";
const parent = comment.parent_id && byId.has(comment.parent_id) ? byId.get(comment.parent_id) : null;
const parentLabel = parent ? `<span class="muted">回复 @${escapeHtml(parent.username || "匿名")}</span>` : "";
// 评论者头像:有头像显示图片,没有则显示空占位
const avatarHtml = comment.avatar
? `<img class="comment-avatar" src="${escapeHtml(comment.avatar)}" alt="">`
: '<span class="comment-avatar comment-avatar-empty"></span>';
item.innerHTML = `
<div class="comment-head">
<div class="comment-author">
${avatarHtml}
<strong>${escapeHtml(comment.username || "匿名")}</strong>
${parentLabel}
</div>
<span class="muted">${formatDate(comment.created_time)}</span>
</div>
<p>${escapeHtml(comment.content)}</p>`;
// 顶层评论显示“回复”按钮(仅好友/博主)
const canReply = state.user && (state.user.role === "friend" || state.user.role === "blogger");
if (canReply && !isReply) {
const replyBtn = document.createElement("button");
replyBtn.className = "btn btn-text btn-sm comment-reply-btn";
replyBtn.textContent = "回复";
replyBtn.addEventListener("click", () => {
let box = item.querySelector(".comment-reply-box");
if (box) { box.remove(); return; }
box = document.createElement("div");
box.className = "comment-reply-box";
box.appendChild(buildCommentForm(el, articleId, comment));
item.appendChild(box);
});
item.appendChild(replyBtn);
}
return item;
}
function buildCommentForm(el, articleId, parent) {
const form = document.createElement("div");
form.className = "comment-form";
const placeholder = parent
? `回复 @${parent.username || "匿名"}`
: "写下你的评论…";
const btnText = parent ? "回复" : "发表评论";
form.innerHTML = `
<textarea class="comment-input" rows="2" maxlength="2000" placeholder="${escapeHtml(placeholder)}"></textarea>
<button class="btn btn-primary" type="button">${btnText}</button>`;
const textarea = $(".comment-input", form);
const btn = $("button", form);
btn.addEventListener("click", async () => {
const content = textarea.value.trim();
if (!content) { showToast("评论内容不能为空", "error"); return; }
if (content.length > 2000) { showToast("评论内容不能超过 2000 字", "error"); return; }
btn.disabled = true; btn.textContent = "发送中…";
try {
const body = parent
? { article_id: articleId, content, parent_id: parent.id }
: { article_id: articleId, content };
await api("/api/comment/add", { method: "POST", body });
showToast("评论成功", "success");
await renderComments(el, articleId);
} catch (err) {
showToast(err.message, "error");
}
});
return form;
}
+100
View File
@@ -0,0 +1,100 @@
/* =====================================================================
* 好友模块(friend.js
* 好友申请 / 状态展示 / 博主审批弹窗。
* ===================================================================== */
import { state } from "./state.js";
import { escapeHtml, formatDate, openModal, showToast } from "./utils.js";
import { api } from "./api.js";
import { openLoginModal } from "./auth.js";
import { renderAuthArea } from "./router.js";
export async function applyFriend() {
if (!state.user) { openLoginModal(); showToast("请先登录后再申请好友", "info"); return; }
try {
await api("/api/friend/apply", { method: "POST" });
state.friendStatus = "pending";
renderAuthArea();
showToast("好友申请已提交,等待博主审批", "success");
} catch (err) {
showToast(err.message, "error");
}
}
// 记录当前打开的审批弹窗引用,避免重复打开时弹窗叠加
let manageModal = null;
export function openFriendManageModal() {
// 先关闭上一次打开的审批弹窗
if (manageModal) { try { manageModal.close(); } catch (e) { /* 忽略 */ } }
const body = document.createElement("div");
body.innerHTML = '<p class="muted">加载中…</p>';
manageModal = openModal({ title: "好友申请管理", content: body });
renderApplications(body);
}
export async function openFriendListModal() {
const body = document.createElement("div");
body.innerHTML = '<p class="muted">加载中…</p>';
const modal = openModal({ title: "好友列表", content: body });
try {
const friends = await api("/api/friend/list");
if (!friends.length) { body.innerHTML = '<p class="empty">还没有好友</p>'; return; }
body.innerHTML = friends.map((f) => `
<div class="friend-row">
${f.avatar ? `<img class="friend-avatar" src="${escapeHtml(f.avatar)}" alt="">` : '<span class="friend-avatar friend-avatar-empty"></span>'}
<div class="friend-info">
<strong>${escapeHtml(f.username)}</strong>
<span class="muted">${escapeHtml(f.bio || `成为好友于 ${formatDate(f.friend_since)}`)}</span>
</div>
</div>`).join("");
} catch (err) {
body.innerHTML = "";
showToast(err.message, "error");
}
}
export async function renderApplications(body) {
try {
const records = await api("/api/friend/applications");
if (!records.length) { body.innerHTML = '<p class="empty">暂无申请记录</p>'; return; }
body.innerHTML = "";
const STATUS_TEXT = { pending: "待审批", accepted: "已通过", rejected: "已拒绝" };
for (const record of records) {
const row = document.createElement("div");
row.className = "app-row";
const statusText = STATUS_TEXT[record.status] || record.status;
row.innerHTML = `
<span class="app-user">${escapeHtml(record.username || "未知用户")}</span>
<span class="app-email muted">${escapeHtml(record.email || "")}</span>
<span class="app-time muted">${formatDate(record.created_time)}</span>
<span class="app-status">${statusText}</span>`;
if (record.status === "pending") {
const approveBtn = document.createElement("button");
approveBtn.className = "btn btn-primary btn-sm"; approveBtn.textContent = "通过";
approveBtn.addEventListener("click", async () => {
try {
await api(`/api/friend/${record.id}/approve`, { method: "POST" });
showToast("已通过好友申请", "success");
await renderApplications(body); // 原地刷新当前弹窗内容,不再叠加新弹窗
} catch (err) { showToast(err.message, "error"); }
});
const rejectBtn = document.createElement("button");
rejectBtn.className = "btn btn-outline btn-sm"; rejectBtn.textContent = "拒绝";
rejectBtn.addEventListener("click", async () => {
try {
await api(`/api/friend/${record.id}/reject`, { method: "POST" });
showToast("已拒绝好友申请");
await renderApplications(body); // 原地刷新当前弹窗内容,不再叠加新弹窗
} catch (err) { showToast(err.message, "error"); }
});
row.appendChild(approveBtn);
row.appendChild(rejectBtn);
}
body.appendChild(row);
}
} catch (err) {
body.innerHTML = "";
showToast(err.message, "error");
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="#00A1D6" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#181717" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 837 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#FF0000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="孤竹居士的个人博客:生活、项目、学习与简介">
<!-- CSP 安全策略:仅允许同源脚本/样式/接口;图片允许同源、data: 与 http(s) -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: http: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
<title>孤竹居士的博客</title>
<link rel="icon" type="image/png" href="/uploads/avatar/favicon.png">
<link rel="stylesheet" href="/style.css?v=__VERSION__">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="/" data-link>孤竹居士的博客</a>
<nav class="nav" id="nav" aria-label="主导航"></nav>
<div class="auth-area" id="auth-area"></div>
</div>
</header>
<main id="app" class="container"></main>
<!-- 自定义弹窗与通知(不使用 alert) -->
<div id="modal-root"></div>
<div id="toast-root" aria-live="polite"></div>
<script type="module" src="/main.js?v=__VERSION__"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
/* =====================================================================
* 入口(main.js
* 挂载全局事件并完成首次渲染。
* 各业务模块:api / auth / router / article / comment / manage / friend /
* markdown / state / utils
* ===================================================================== */
import { $ } from "./utils.js";
import { refreshUser } from "./auth.js";
import { navigate, render, renderAuthArea, renderNav } from "./router.js";
// 拦截站内 SPA 链接(带 data-link 的 <a>),并收起用户下拉菜单
document.addEventListener("click", (e) => {
const link = e.target.closest("a[data-link]");
if (link) {
e.preventDefault();
navigate(link.getAttribute("href"));
return;
}
const dropdown = $("#user-dropdown");
if (dropdown && !e.target.closest(".user-menu")) dropdown.classList.add("hidden");
});
window.addEventListener("popstate", render);
(async function init() {
renderNav();
await refreshUser();
renderAuthArea();
render();
})();
+528
View File
@@ -0,0 +1,528 @@
/* =====================================================================
* 博主管理面板(manage.js
* 发文 / 编辑 / 删除文章(简介编辑已移至主页简介处)。
* ===================================================================== */
import { state } from "./state.js";
import { $, escapeHtml, formatDate, openModal, showToast } from "./utils.js";
import { api, uploadFile, uploadForm } from "./api.js";
import { renderMarkdown } from "./markdown.js";
import { navigate } from "./router.js";
import { openLoginModal } from "./auth.js";
function insertAtCursor(input, text) {
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
input.value = input.value.slice(0, start) + text + input.value.slice(end);
input.focus();
const pos = start + text.length;
input.setSelectionRange(pos, pos);
}
export async function renderManage(app) {
document.title = "博主管理 - 孤竹居士的博客";
if (!state.user) {
app.innerHTML = `
<div class="empty-block">
<p>!</p>
<p class="muted">请先登录</p>
<button class="btn btn-primary" id="manage-login">去登录</button>
</div>`;
$("#manage-login").addEventListener("click", openLoginModal);
return;
}
if (state.user.role !== "blogger") {
app.innerHTML = `
<div class="empty-block">
<p>!</p>
<p class="muted">只有博主可以访问发文管理</p>
<a class="btn btn-primary" data-link href="/">返回首页</a>
</div>`;
return;
}
app.innerHTML = `
<h1 class="page-title">博主管理</h1>
<div class="manage-grid">
<section class="manage-card">
<h2 class="section-title" id="form-title">发布新文章</h2>
<form class="form" id="article-form">
<label for="art-title">标题</label>
<input id="art-title" type="text" maxlength="200" required placeholder="文章标题">
<label>封面</label>
<div class="cover-field">
<div class="cover-preview hidden" id="cover-preview">
<img id="cover-img" alt="封面预览">
<button type="button" class="btn btn-outline btn-sm" id="cover-remove">移除封面</button>
</div>
<div class="cover-actions">
<button type="button" class="btn btn-outline" id="btn-cover-upload">📷 上传封面</button>
<input type="text" id="cover-url" placeholder="或直接填写图片路径,如 /uploads/article/xxx.jpg">
</div>
<input type="file" id="cover-file" accept="image/jpeg,image/png,image/webp" hidden>
</div>
<label for="art-content">正文(Markdown</label>
<div class="editor-toolbar">
<button type="button" class="btn btn-outline btn-sm" id="btn-insert-image">🖼 插入图片</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-insert-video">🎬 插入视频</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-insert-doc">📄 插入文档</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-preview-toggle">预览</button>
</div>
<textarea id="art-content" rows="14" placeholder="支持 Markdown:标题、列表、代码块、图片等"></textarea>
<input type="file" id="insert-image-file" accept="image/jpeg,image/png,image/webp" hidden>
<input type="file" id="insert-video-file" accept="video/mp4,video/webm" hidden>
<input type="file" id="insert-doc-file" accept=".doc,.docx" hidden>
<div class="preview-box hidden" id="preview-box">
<div class="article-content" id="preview-content"></div>
</div>
<button type="button" class="btn btn-primary btn-block" id="btn-publish">发布文章</button>
<button class="btn btn-outline btn-block hidden" id="btn-cancel-edit" type="button">取消编辑</button>
</form>
</section>
<section class="manage-card">
<h2 class="section-title">我的文章</h2>
<div id="my-articles"><p class="muted">加载中…</p></div>
</section>
<section class="manage-card manage-card-wide">
<h2 class="section-title" id="proj-form-title">项目管理(在线演示 / GitHub 链接)</h2>
<form class="form" id="project-form">
<label for="proj-name">项目名称</label>
<input id="proj-name" type="text" maxlength="100" required placeholder="项目名称">
<label for="proj-desc">项目简介</label>
<textarea id="proj-desc" rows="3" maxlength="2000" placeholder="介绍一下这个项目…"></textarea>
<label for="proj-tech">技术标签(逗号分隔)</label>
<input id="proj-tech" type="text" placeholder="如 HTML, CSS, JavaScript">
<label for="proj-github">GitHub 链接(非纯静态项目必填)</label>
<input id="proj-github" type="url" placeholder="https://github.com/你的用户名/仓库名">
<label>上传代码(zip</label>
<div class="cover-field">
<button type="button" class="btn btn-outline" id="btn-proj-upload">📦 选择 zip 文件</button>
<span class="muted" id="proj-file-name"></span>
</div>
<input type="file" id="proj-file" accept=".zip" hidden>
<p class="muted form-hint">纯 HTML/CSS/JS(含 index.html)→ 自动开通在线演示;含后端代码 → 请填写 GitHub 链接</p>
<button type="button" class="btn btn-primary btn-block" id="btn-proj-save">添加项目</button>
<button type="button" class="btn btn-outline btn-block hidden" id="btn-proj-cancel">取消编辑</button>
</form>
<h3 class="section-title proj-list-title">项目列表</h3>
<div id="proj-list"><p class="muted">加载中…</p></div>
</section>
</div>`;
// ---------- 封面 / 正文图片 / 预览 ----------
const coverState = { url: "" };
const coverFile = $("#cover-file", app);
const coverImg = $("#cover-img", app);
const coverPreview = $("#cover-preview", app);
const coverUrlInput = $("#cover-url", app);
$("#btn-cover-upload", app).addEventListener("click", () => coverFile.click());
coverFile.addEventListener("change", async () => {
const file = coverFile.files && coverFile.files[0];
if (!file) return;
try {
const data = await uploadFile("/api/upload/article", file);
coverState.url = data.url;
coverPreview.classList.remove("hidden");
coverImg.src = data.url;
coverUrlInput.value = data.url;
showToast("封面上传成功", "success");
} catch (err) { showToast(err.message, "error"); }
coverFile.value = "";
});
$("#cover-remove", app).addEventListener("click", () => {
coverState.url = "";
coverUrlInput.value = "";
coverPreview.classList.add("hidden");
coverImg.src = "";
});
coverUrlInput.addEventListener("input", () => { coverState.url = coverUrlInput.value.trim(); });
const contentInput = $("#art-content", app);
const insertFile = $("#insert-image-file", app);
$("#btn-insert-image", app).addEventListener("click", () => insertFile.click());
insertFile.addEventListener("change", async () => {
const file = insertFile.files && insertFile.files[0];
if (!file) return;
try {
const data = await uploadFile("/api/upload/article", file);
insertAtCursor(contentInput, `![](${data.url})`);
showToast("图片已插入正文", "success");
} catch (err) { showToast(err.message, "error"); }
insertFile.value = "";
});
// ---------- 正文视频插入(仅博主,mp4/webm ----------
const insertVideoFile = $("#insert-video-file", app);
$("#btn-insert-video", app).addEventListener("click", () => insertVideoFile.click());
insertVideoFile.addEventListener("change", async () => {
const file = insertVideoFile.files && insertVideoFile.files[0];
if (!file) return;
try {
const data = await uploadFile("/api/upload/video", file);
insertAtCursor(contentInput, `@[视频](${data.url})`);
showToast("视频已插入正文", "success");
} catch (err) { showToast(err.message, "error"); }
insertVideoFile.value = "";
});
// ---------- 正文文档插入(Word doc/docx----------
// 只能用于“我的生活 / 我的学习”分区的文章:上传时携带分区给后端校验(项目区不使用此功能)
const insertDocFile = $("#insert-doc-file", app);
$("#btn-insert-doc", app).addEventListener("click", () => insertDocFile.click());
insertDocFile.addEventListener("change", async () => {
const file = insertDocFile.files && insertDocFile.files[0];
if (!file) return;
try {
// 上传文档时携带文章分区:编辑已有文章用其分区,新文章默认 life(发布时仍可调整)
const category = editingCategory || "life";
const data = await uploadFile("/api/upload/doc", file, { category });
insertAtCursor(contentInput, `[📄 ${escapeHtml(data.filename)}](${data.url})`);
showToast("文档已插入正文", "success");
} catch (err) { showToast(err.message, "error"); }
insertDocFile.value = "";
});
const previewBox = $("#preview-box", app);
$("#btn-preview-toggle", app).addEventListener("click", () => {
previewBox.classList.toggle("hidden");
if (!previewBox.classList.contains("hidden")) {
$("#preview-content", app).innerHTML = renderMarkdown(contentInput.value);
}
});
// ---------- 发布 / 编辑 ----------
let editingId = null;
let editingCategory = "life";
let editingVisibility = "public";
const setEditing = (article) => {
editingId = article ? article.id : null;
editingCategory = article ? (article.category || "life") : "life";
editingVisibility = article ? (article.visibility || "public") : "public";
$("#form-title", app).textContent = article ? "编辑文章" : "发布新文章";
$("#btn-publish", app).textContent = article ? "保存修改" : "发布文章";
$("#btn-cancel-edit", app).classList.toggle("hidden", !article);
if (article) {
$("#art-title", app).value = article.title;
contentInput.value = article.content;
coverState.url = article.cover || "";
coverUrlInput.value = article.cover || "";
if (article.cover) {
coverPreview.classList.remove("hidden");
coverImg.src = article.cover;
} else {
coverPreview.classList.add("hidden");
coverImg.src = "";
}
}
};
$("#btn-cancel-edit", app).addEventListener("click", () => {
$("#article-form", app).reset();
setEditing(null);
coverState.url = "";
coverUrlInput.value = "";
coverPreview.classList.add("hidden");
coverImg.src = "";
});
$("#btn-publish", app).addEventListener("click", () => {
const title = $("#art-title", app).value.trim();
const content = contentInput.value.trim();
if (!title) { showToast("请填写文章标题", "error"); return; }
if (!content) { showToast("请填写文章正文", "error"); return; }
// 发布管理弹窗:选择发布分区与可见范围后再确认
const modalContent = document.createElement("div");
modalContent.innerHTML = `
<p class="muted">选择文章发布到的分区,以及谁可以查看。</p>
<div class="form">
<label>发布分区</label>
<div class="radio-row">
<label class="radio"><input type="radio" name="pub-category" value="life" ${editingCategory === "study" ? "" : "checked"}> 我的生活</label>
<label class="radio"><input type="radio" name="pub-category" value="study" ${editingCategory === "study" ? "checked" : ""}> 我的学习</label>
</div>
<label>可见范围</label>
<div class="radio-row">
<label class="radio"><input type="radio" name="pub-visibility" value="public" ${editingVisibility === "friend" ? "" : "checked"}> 公开(所有人可见)</label>
<label class="radio"><input type="radio" name="pub-visibility" value="friend" ${editingVisibility === "friend" ? "checked" : ""}> 好友专属(仅好友可见)</label>
</div>
</div>`;
const modal = openModal({ title: editingId ? "保存修改" : "发布管理", content: modalContent });
const actions = document.createElement("div");
actions.className = "modal-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消";
cancelBtn.addEventListener("click", () => modal.close());
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.id = "pub-confirm";
confirmBtn.className = "btn btn-primary"; confirmBtn.textContent = editingId ? "保存修改" : "确认发布";
confirmBtn.addEventListener("click", async () => {
const category = modalContent.querySelector('input[name="pub-category"]:checked').value;
const visibility = modalContent.querySelector('input[name="pub-visibility"]:checked').value;
confirmBtn.disabled = true; confirmBtn.textContent = "发布中…";
try {
const body = { title, content, cover: coverState.url || null, visibility, category };
let newId = editingId;
if (editingId) {
await api(`/api/article/${editingId}`, { method: "PUT", body });
showToast("修改已保存", "success");
} else {
const created = await api("/api/article/add", { method: "POST", body });
newId = created.id;
showToast("发布成功", "success");
}
modal.close();
$("#article-form", app).reset();
setEditing(null);
coverState.url = "";
coverUrlInput.value = "";
coverPreview.classList.add("hidden");
coverImg.src = "";
previewBox.classList.add("hidden");
if (newId) navigate(`/${category}/${newId}`);
else await loadMyArticles();
} catch (err) { showToast(err.message, "error"); }
confirmBtn.disabled = false; confirmBtn.textContent = editingId ? "保存修改" : "确认发布";
});
actions.appendChild(cancelBtn);
actions.appendChild(confirmBtn);
modalContent.appendChild(actions);
});
// ---------- 我的文章:列表 + 编辑 / 删除 ----------
const myArticlesEl = $("#my-articles", app);
async function loadMyArticles() {
try {
const data = await api("/api/article/list?page=1&page_size=100");
const mine = data.items.filter((a) => a.author_id === state.user.id);
if (!mine.length) { myArticlesEl.innerHTML = '<p class="empty">还没有发布过文章</p>'; return; }
myArticlesEl.innerHTML = "";
mine.forEach((a) => {
const row = document.createElement("div");
row.className = "my-article-row";
row.innerHTML = `
${a.cover ? `<img class="my-article-cover" src="${escapeHtml(a.cover)}" alt="">` : '<span class="my-article-cover-empty"></span>'}
<div class="my-article-info">
<a class="my-article-title" data-link href="/${a.category || "life"}/${a.id}">${escapeHtml(a.title)}</a>
<span class="muted">${formatDate(a.created_time)}</span>
</div>
${a.visibility === "friend" ? '<span class="badge badge-friend">好友专属</span>' : '<span class="badge badge-public">公开</span>'}
<div class="my-article-actions">
<button class="btn btn-outline btn-sm my-article-edit">编辑</button>
<button class="btn btn-outline btn-sm btn-danger-text my-article-delete">删除</button>
</div>`;
$(".my-article-edit", row).addEventListener("click", async () => {
try {
const detail = await api(`/api/article/${a.id}`);
setEditing(detail);
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (err) { showToast(err.message, "error"); }
});
$(".my-article-delete", row).addEventListener("click", () => {
confirmDeleteArticle(a.id, a.title);
});
myArticlesEl.appendChild(row);
});
} catch (err) {
myArticlesEl.innerHTML = '<p class="muted">加载失败</p>';
showToast(err.message, "error");
}
}
function confirmDeleteArticle(id, title) {
const content = document.createElement("div");
content.innerHTML = `<p>确定删除文章「${escapeHtml(title)}」吗?删除后评论与点赞将一并删除,且不可恢复。</p>`;
const modal = openModal({ title: "删除确认", content });
const actions = document.createElement("div");
actions.className = "modal-actions";
const cancelBtn = document.createElement("button");
cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消";
cancelBtn.addEventListener("click", () => modal.close());
const okBtn = document.createElement("button");
okBtn.className = "btn btn-danger"; okBtn.textContent = "确认删除";
okBtn.addEventListener("click", async () => {
okBtn.disabled = true; okBtn.textContent = "删除中…";
try {
await api(`/api/article/${id}`, { method: "DELETE" });
modal.close();
showToast("删除成功", "success");
await loadMyArticles();
} catch (err) {
showToast(err.message, "error");
okBtn.disabled = false; okBtn.textContent = "确认删除";
}
});
actions.appendChild(cancelBtn);
actions.appendChild(okBtn);
content.appendChild(actions);
}
// ---------- 项目管理(L0 静态托管) ----------
let editingProjectId = null;
let editingProjVisibility = "public";
let selectedZip = null;
const projName = $("#proj-name", app);
const projDesc = $("#proj-desc", app);
const projTech = $("#proj-tech", app);
const projGithub = $("#proj-github", app);
const projFileInput = $("#proj-file", app);
const projFileNameEl = $("#proj-file-name", app);
$("#btn-proj-upload", app).addEventListener("click", () => projFileInput.click());
projFileInput.addEventListener("change", () => {
selectedZip = projFileInput.files && projFileInput.files[0];
projFileNameEl.textContent = selectedZip ? selectedZip.name : "";
});
function resetProjectForm() {
editingProjectId = null;
editingProjVisibility = "public";
selectedZip = null;
$("#project-form", app).reset();
projFileNameEl.textContent = "";
$("#btn-proj-save", app).textContent = "添加项目";
$("#btn-proj-cancel", app).classList.add("hidden");
$("#proj-form-title", app).textContent = "项目管理(在线演示 / GitHub 链接)";
}
$("#btn-proj-cancel", app).addEventListener("click", resetProjectForm);
$("#btn-proj-save", app).addEventListener("click", async () => {
const name = projName.value.trim();
if (!name) { showToast("请填写项目名称", "error"); return; }
// 保存前弹窗选择可见范围(对齐文章发布流程)
const modalContent = document.createElement("div");
modalContent.innerHTML = `
<p class="muted">选择谁可以查看这个项目。</p>
<div class="form">
<label>可见范围</label>
<div class="radio-row">
<label class="radio"><input type="radio" name="proj-visibility" value="public" ${editingProjVisibility === "friend" ? "" : "checked"}> 公开(所有人可见)</label>
<label class="radio"><input type="radio" name="proj-visibility" value="friend" ${editingProjVisibility === "friend" ? "checked" : ""}> 好友专属(仅好友可见)</label>
</div>
</div>`;
const modal = openModal({ title: editingProjectId ? "保存修改" : "发布管理", content: modalContent });
const actions = document.createElement("div");
actions.className = "modal-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消";
cancelBtn.addEventListener("click", () => modal.close());
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = "btn btn-primary"; confirmBtn.textContent = editingProjectId ? "保存修改" : "确认添加";
confirmBtn.addEventListener("click", async () => {
const visibility = modalContent.querySelector('input[name="proj-visibility"]:checked').value;
confirmBtn.disabled = true; confirmBtn.textContent = "保存中…";
const fields = {
name,
description: projDesc.value.trim(),
tech: projTech.value.trim(),
github_url: projGithub.value.trim(),
visibility,
};
try {
if (editingProjectId) {
await uploadForm(`/api/project/${editingProjectId}`, fields, selectedZip, "PUT");
showToast("项目已更新", "success");
} else {
await uploadForm("/api/project/add", fields, selectedZip);
showToast("项目已添加", "success");
}
modal.close();
resetProjectForm();
await loadProjects();
} catch (err) { showToast(err.message, "error"); }
confirmBtn.disabled = false; confirmBtn.textContent = editingProjectId ? "保存修改" : "确认添加";
});
actions.appendChild(cancelBtn);
actions.appendChild(confirmBtn);
modalContent.appendChild(actions);
});
async function loadProjects() {
const listEl = $("#proj-list", app);
let items = [];
try { items = await api("/api/project/list"); }
catch (err) { listEl.innerHTML = '<p class="muted">加载失败</p>'; showToast(err.message, "error"); return; }
if (!items.length) { listEl.innerHTML = '<p class="muted">还没有项目,添加一个试试</p>'; return; }
listEl.innerHTML = "";
items.forEach((p) => {
const row = document.createElement("div");
row.className = "my-article-row";
const typeBadge = p.project_type === "static"
? '<span class="badge badge-public">在线演示</span>'
: '<span class="badge badge-friend">GitHub 链接</span>';
const visBadge = p.visibility === "friend"
? '<span class="badge badge-friend">好友专属</span>'
: '<span class="badge badge-public">公开</span>';
row.innerHTML = `
<div class="my-article-info">
<a class="my-article-title" data-link href="/projects/${p.id}">${escapeHtml(p.name)}</a>
<span class="muted">${escapeHtml(p.demo_url || "")}</span>
</div>
${visBadge}
${typeBadge}
<div class="my-article-actions">
<button class="btn btn-outline btn-sm proj-edit">编辑</button>
<button class="btn btn-outline btn-sm btn-danger-text proj-delete">删除</button>
</div>`;
$(".proj-edit", row).addEventListener("click", () => {
editingProjectId = p.id;
editingProjVisibility = p.visibility || "public";
projName.value = p.name;
projDesc.value = p.description || "";
projTech.value = p.tech || "";
projGithub.value = p.github_url || "";
selectedZip = null; projFileNameEl.textContent = "";
$("#btn-proj-save", app).textContent = "保存修改";
$("#btn-proj-cancel", app).classList.remove("hidden");
$("#proj-form-title", app).textContent = `编辑项目:${p.name}`;
window.scrollTo({ top: 0, behavior: "smooth" });
});
$(".proj-delete", row).addEventListener("click", () => confirmDeleteProject(p));
listEl.appendChild(row);
});
}
function confirmDeleteProject(p) {
const content = document.createElement("div");
content.innerHTML = `<p>确定删除项目「${escapeHtml(p.name)}」吗?在线演示与下载文件将一并删除。</p>`;
const modal = openModal({ title: "删除确认", content });
const actions = document.createElement("div");
actions.className = "modal-actions";
const cancelBtn = document.createElement("button");
cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消";
cancelBtn.addEventListener("click", () => modal.close());
const okBtn = document.createElement("button");
okBtn.className = "btn btn-danger"; okBtn.textContent = "确认删除";
okBtn.addEventListener("click", async () => {
okBtn.disabled = true; okBtn.textContent = "删除中…";
try {
await api(`/api/project/${p.id}`, { method: "DELETE" });
modal.close();
showToast("项目已删除", "success");
await loadProjects();
} catch (err) {
showToast(err.message, "error");
okBtn.disabled = false; okBtn.textContent = "确认删除";
}
});
actions.appendChild(cancelBtn);
actions.appendChild(okBtn);
content.appendChild(actions);
}
await loadMyArticles();
await loadProjects();
}
+181
View File
@@ -0,0 +1,181 @@
/* =====================================================================
* Markdown 轻量渲染器(markdown.js
* ---------------------------------------------------------------------
* 【这个文件是干什么的?】
* 博客文章正文是用 Markdown 语法写的(# 标题、**加粗**、```代码块```),
* 但浏览器只认 HTML。这个文件负责把 Markdown 文本"翻译"成 HTML 字符串,
* 再交给其他模块(article.js 渲染文章正文、manage.js 做编辑预览)插入页面。
*
* 【整体思路:分两层处理】
* 第 1 层(安全):先把整篇文本用 escapeHtml 转义,
* 把 < > & 等特殊字符变成 &lt; &gt; &amp; 这样的"HTML 实体"。
* 这样用户写的内容永远不会被当成 HTML 标签执行 —— 这是防 XSS 攻击的关键。
* 第 2 层(排版):把转义后的文本按"块级元素"逐行解析
* (标题 / 段落 / 列表 / 引用 / 分隔线 / 代码块),
* 每一行内部再调用 inlineMarkdown 处理"行内元素"(加粗 / 斜体 / 链接等)。
*
* 【两个函数的分工】
* - inlineMarkdown(text):只管"一行文本内部"的语法替换(行内元素)
* - renderMarkdown(md):入口函数,负责整篇的分行、分块、组装(块级元素)
* ===================================================================== */
import { escapeHtml, safeUrl } from "./utils.js";
/**
* 行内 Markdown 渲染
* 只处理"行内"语法(不跨行),比如:
* **加粗** *斜体* `行内代码` [链接文字](URL) ![图片描述](URL)
* 原理:用一串正则表达式依次"查找 - 替换",把 Markdown 写法换成 HTML 标签。
*
* 注意:传入的 text 已经过 escapeHtml 转义(在 renderMarkdown 里完成),
* 所以这里生成的内容不会携带危险标签;链接 / 图片地址还会再过一次
* safeUrl 协议白名单(只允许 http/https 等安全协议),双保险防 XSS。
*/
function inlineMarkdown(text) {
return text
// ---- 图片:![替代文字](图片地址) -> <img> ----
// 正则逐段拆解:
// !\[([^\]]*)\] 匹配 "![" + 任意个"不是 ] 的字符"(就是替代文字,存入分组1)
// \(([^)\s]+)\) 匹配 "(" + 任意个"不是 ) 和空格 的字符"(就是 URL,存入分组2)
// /g 标志 = 全局替换,把整行里所有图片语法都处理掉
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => {
// safeUrl(url, "image"):按"图片"规则校验地址是否安全
const safe = safeUrl(url, "image");
// 安全才输出 <img>;不安全直接返回空字符串(丢弃这张图)
// loading="lazy":图片滚动到视野内才加载,省流量
return safe ? `<img src="${safe}" alt="${alt}" loading="lazy">` : "";
})
// ---- 视频:@[说明文字](视频地址) -> <video> ----
// 语法用 @[ 开头,避免与图片 ![] 混淆(发布时点"插入视频"自动生成)
.replace(/@\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => {
const safe = safeUrl(url, "image");
// 安全才输出 <video controls>(带播放控制条);不安全返回空字符串
return safe ? `<video controls preload="metadata" src="${safe}">${alt}</video>` : "";
})
// ---- 链接:[显示文字](地址) -> <a> ----
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) => {
const safe = safeUrl(url, "link");
// target="_blank":新标签页打开
// rel="noopener":禁止新页面通过 window.opener 操控本页(安全措施)
return safe ? `<a href="${safe}" target="_blank" rel="noopener">${label}</a>` : label;
})
// ---- 行内代码:`代码` -> <code> ----
// `([^`]+)` 匹配一对反引号包起来的非空内容,$1 就是里面的代码
.replace(/`([^`]+)`/g, "<code>$1</code>")
// ---- 粗体:**文字** -> <strong> ----
// \*\* 转义匹配字面的两个星号;([^*]+) 捕获"不含星号"的文字
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
// ---- 斜体:*文字* -> <em>(单个星号)----
// 注意顺序:必须先处理 **(粗体)再处理 *(斜体),
// 否则 "**粗体**" 会被单星号规则拦腰拆坏
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
// ---- 斜体(下划线写法):_文字_ -> <em> ----
.replace(/_([^_]+)_/g, "<em>$1</em>");
}
/**
* Markdown 渲染入口(块级 + 行内)
* 处理步骤:
* 1. 空内容直接返回空字符串(避免后面处理报错)
* 2. escapeHtml 转义全文(安全第一),并把 Windows 的 \r\n 统一成 \n
* 3. 用"占位符"先把 ```代码块``` 整块抽出来存进数组,
* 防止代码块里的 # 或 * 被后面的规则误当成标题 / 加粗
* 4. 逐行扫描剩余文本:标题 / 分隔线 / 引用 / 列表 / 空行 / 段落
* 5. 循环结束后把占位符换回真正的 <pre><code> 代码块
*/
export function renderMarkdown(md) {
if (!md) return "";
// 统一转义 + 统一换行符(\r\n 是 Windows 换行,\n 是 Linux/Mac 换行)
const escaped = escapeHtml(md).replace(/\r\n/g, "\n");
// 代码块缓存数组:抽出来的代码块按顺序存这里
const codeBlocks = [];
// 围栏代码块正则:```语言名\n 任意内容 ```
// ```([^\n]*) 第一个 ``` 后到换行为止 = 语言名(如 js)
// \n 换行
// ([\s\S]*?) 任意字符(\s\S 合起来表示"包括换行的任意字符")
// 后面的 ? 是非贪婪模式:遇到第一个 ``` 就停,不吞后面内容
// 回调函数里 codeBlocks 存的是拼好的 HTML,返回的是占位符
const text = escaped.replace(/```([^\n]*)\n([\s\S]*?)```/g, (match, lang, code) => {
// 去掉代码末尾多余换行,包进 <pre><code>pre 会保留代码里的空格和换行)
codeBlocks.push(`<pre><code>${code.replace(/\n$/, "")}</code></pre>`);
// 返回占位符:\u0000 是"空字符",正常文章里几乎不会出现,避免误替换
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
});
let html = ""; // 最终输出的 HTML,逐行往这个字符串上追加
let list = null; // 列表缓冲:{ type: "ul"|"ol", items: [] }
// 关闭列表:把攒在缓冲里的列表项一次性输出成 <ul>/<ol>,然后清空缓冲。
// 为什么列表要"攒着"?因为列表项必须连续、外面要套同一个 <ul>
// 所以要等列表结束(遇到标题、空行等)时才知道在哪儿闭合标签。
const closeList = () => {
if (list) {
const tag = list.type === "ol" ? "ol" : "ul";
html += `<${tag}>${list.items.map((i) => `<li>${i}</li>`).join("")}</${tag}>`;
list = null;
}
};
// 逐行处理:把文本按 \n 切成一行一行的数组,依次判断每一行属于哪种块级语法
for (const rawLine of text.split("\n")) {
const line = rawLine;
// 1. 代码块占位符(形如 \u0000CODE0\u0000
// 先关闭可能开着的列表,再把占位符换成真正的代码块 HTML
const codeMatch = line.match(/^\u0000CODE(\d+)\u0000$/);
if (codeMatch) { closeList(); html += codeBlocks[Number(codeMatch[1])]; continue; }
// 2. 标题:# 一级 ## 二级 ... ###### 六级 -> <h1>~<h6>
// ^(#{1,6}):行首 1~6 个 ## 的个数 = 标题级别)
// \s+:后面至少一个空格;(.*):剩下的文字就是标题内容
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
closeList(); // 标题会打断列表,先收尾
const level = heading[1].length; // 捕获到的 # 字符串长度就是级别
html += `<h${level}>${inlineMarkdown(heading[2])}</h${level}>`;
continue;
}
// 3. 分隔线:整行由短横线组成(--、--- 等) -> <hr>
// ^\s*:行首可以有空格;-+:一个或多个短横线;$:一直到行尾
if (/^\s*---+$/.test(line)) { closeList(); html += "<hr>"; continue; }
// 4. 引用:> 文字 -> <blockquote>
// 为什么匹配 &gt; 而不是 >
// 因为整篇已经过 escapeHtml 转义,> 已经变成了 &gt;
const quote = line.match(/^&gt;\s?(.*)$/);
if (quote) { closeList(); html += `<blockquote>${inlineMarkdown(quote[1])}</blockquote>`; continue; }
// 5. 无序列表:- 项目 或 * 项目 或 + 项目 -> <ul><li>
// ^\s*[-*+]\s+:行首空格 + 符号(- * + 三选一)+ 空格;(.*) 是列表内容
const ulItem = line.match(/^\s*[-*+]\s+(.*)$/);
if (ulItem) {
// 当前没有列表、或是"有序"列表时,先关闭旧的,再开一个新的无序列表
if (!list || list.type !== "ul") { closeList(); list = { type: "ul", items: [] }; }
list.items.push(inlineMarkdown(ulItem[1])); // 先攒进缓冲,等列表结束统一输出
continue;
}
// 6. 有序列表:1. 项目 2. 项目 -> <ol><li>
// ^\s*\d+\.\s+:行首空格 + 数字(\d++ 点 + 空格
const olItem = line.match(/^\s*\d+\.\s+(.*)$/);
if (olItem) {
if (!list || list.type !== "ol") { closeList(); list = { type: "ol", items: [] }; }
list.items.push(inlineMarkdown(olItem[1]));
continue;
}
// 7. 空行:段落之间的分隔,同时也会打断列表
if (line.trim() === "") { closeList(); continue; }
// 8. 普通段落:剩下的所有内容都包进 <p> 段落标签
closeList();
html += `<p>${inlineMarkdown(line)}</p>`;
}
// 循环结束:如果还有没收尾的列表缓冲,补一次关闭
closeList();
return html;
}
+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);
}
+52
View File
@@ -0,0 +1,52 @@
/* =====================================================================
* 全局常量与状态(state.js
* 各模块通过 import 共享;不包含任何 DOM 操作。
* ===================================================================== */
// 导航分区:life 生活区 / projects 项目区 / study 学习区(简介已并入首页,不再单独成区)
// 文章分区只有 life / studyprojects 是独立的项目专区
export const PARTITIONS = [
{ slug: "life", name: "我的生活" },
{ slug: "projects", name: "我的项目" },
{ slug: "study", name: "我的学习" },
];
// 文章分区中文名(用于面包屑、发布管理等展示)
export const ARTICLE_CATEGORIES = { life: "生活", study: "学习" };
// 简介默认值:实际内容优先从 /api/user/blogger 读取(博主可在管理面板修改)
export const ABOUT_DEFAULT = {
name: "孤竹居士",
tagline: "记录生活 · 折腾代码 · 终身学习",
bio: [
"你好,我是孤竹居士,欢迎来到我的个人博客。",
"这里记录我的生活点滴、项目实践与学习笔记。",
"文章分为公开与好友可见两种,欢迎申请成为好友。",
],
tags: ["Python", "Web", "Minecraft", "阅读"],
};
// 打赏二维码图片(随前端一起部署,Nginx 静态访问)
export const DONATE_IMAGE = "/icons/donate.png";
// 主页展示的社交账号链接(点击跳转对应平台主页)
export const SOCIAL_LINKS = [
{ name: "GitHub", url: "https://github.com/guzhujushi", icon: "/icons/github.svg" },
{ name: "YouTube", url: "https://www.youtube.com/@guzhujushi", icon: "/icons/youtube.svg" },
{ name: "哔哩哔哩", url: "https://b23.tv/DoV46bV", icon: "/icons/bilibili.svg" },
];
export const ROLE_NAMES = { blogger: "博主", friend: "好友", visitor: "游客" };
export const FRIEND_STATUS_TEXT = {
none: "申请好友",
pending: "等待博主审批",
accepted: "已是好友",
rejected: "重新申请好友",
blogger: "博主",
};
// 全局状态:登录用户与好友申请状态(none / pending / accepted / rejected / blogger
export const state = {
user: null, // { id, username, role }
friendStatus: "none",
};
+464
View File
@@ -0,0 +1,464 @@
/* ============ 全局变量:黑白蓝简约风 ============ */
:root {
--primary: #1a73e8;
--primary-dark: #1765cc;
--primary-soft: rgba(26, 115, 232, 0.12);
--text: #202124;
--muted: #5f6368;
--bg: #ffffff;
--surface: #f8f9fa;
--border: #dadce0;
--danger: #d93025;
--success: #188038;
--shadow: 0 1px 2px rgba(60, 64, 67, 0.2), 0 2px 6px rgba(60, 64, 67, 0.1);
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.6;
}
a { color: var(--primary); text-decoration: none; }
img { max-width: 100%; }
.muted { color: var(--muted); }
.hidden { display: none !important; }
/* hidden 属性兜底:防止 .btn 等 display 样式覆盖 hidden 导致按钮常显(如"加载中…" */
[hidden] { display: none !important; }
.empty { color: var(--muted); text-align: center; padding: 48px 0; }
/* ============ 顶栏 ============ */
.topbar {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 960px;
margin: 0 auto;
padding: 0 16px;
height: 60px;
display: flex;
align-items: center;
gap: 20px;
}
.brand {
font-size: 18px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.nav {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
overflow-x: auto;
}
.nav-link {
color: var(--muted);
padding: 8px 12px;
border-radius: 999px;
font-size: 14px;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover { background: var(--surface); color: var(--text); }
.nav-link.active { color: var(--primary); background: var(--primary-soft); font-weight: 500; }
.auth-area { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
/* ============ 按钮 ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 18px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
text-decoration: none;
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); box-shadow: 0 1px 3px rgba(26, 115, 232, 0.4); }
.btn-outline { background: #fff; color: var(--primary); border-color: var(--border); }
.btn-outline:hover { background: var(--surface); }
.btn-text { background: transparent; color: var(--primary); }
.btn-text:hover { background: var(--primary-soft); }
.btn-sm { padding: 5px 12px; font-size: 13px; border-radius: 5px; }
.btn-block { width: 100%; }
/* ============ 布局 ============ */
.container { max-width: 960px; margin: 0 auto; padding: 28px 16px 64px; }
.page-title { font-size: 24px; font-weight: 600; margin-bottom: 6px; }
.page-sub { font-size: 14px; margin-bottom: 20px; }
.loading { text-align: center; color: var(--muted); padding: 80px 0; }
.empty-block { text-align: center; padding: 96px 16px; }
.empty-block p:first-child { font-size: 56px; font-weight: 700; color: var(--border); }
.empty-block .btn { margin-top: 20px; }
/* ============ 文章卡片 ============ */
.article-list { display: flex; flex-direction: column; gap: 18px; }
.article-card {
display: block;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
color: var(--text);
transition: box-shadow 0.2s, transform 0.2s;
}
.article-card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.card-cover { position: relative; max-height: 260px; overflow: hidden; background: var(--surface); }
.card-cover img { width: 100%; height: 260px; object-fit: cover; display: block; }
.card-cover-empty { height: 96px; display: flex; align-items: center; justify-content: center; font-size: 28px; color: var(--muted); }
.cover-badge {
position: absolute; top: 12px; right: 12px;
background: rgba(32, 33, 36, 0.75); color: #fff;
font-size: 12px; padding: 4px 10px; border-radius: 999px;
}
.card-body { padding: 16px 20px 18px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.card-meta { display: flex; align-items: center; gap: 12px; font-size: 13px; flex-wrap: wrap; }
/* 可见性徽章 */
.badge {
display: inline-block; font-size: 12px; padding: 2px 10px;
border-radius: 999px; border: 1px solid var(--border);
}
.badge-public { color: var(--success); background: rgba(24, 128, 56, 0.08); border-color: rgba(24, 128, 56, 0.3); }
.badge-friend { color: var(--primary); background: var(--primary-soft); border-color: rgba(26, 115, 232, 0.3); }
/* ============ 文章详情 ============ */
.article-detail .article-title { font-size: 30px; line-height: 1.3; margin-bottom: 10px; }
.article-meta { display: flex; align-items: center; gap: 14px; font-size: 13px; padding-bottom: 16px; border-bottom: 1px solid var(--border); margin-bottom: 20px; flex-wrap: wrap; }
.article-cover { margin-bottom: 20px; border-radius: var(--radius); overflow: hidden; }
.article-cover img { width: 100%; max-height: 420px; object-fit: cover; display: block; }
.article-content { font-size: 16px; line-height: 1.85; word-break: break-word; }
.article-content h1, .article-content h2, .article-content h3,
.article-content h4, .article-content h5, .article-content h6 { margin: 24px 0 12px; line-height: 1.4; }
.article-content p { margin: 12px 0; }
.article-content img { border-radius: var(--radius); margin: 12px 0; }
.article-content pre {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px;
overflow-x: auto; font-size: 14px; margin: 16px 0;
}
.article-content code {
background: var(--surface); border: 1px solid var(--border);
padding: 2px 6px; border-radius: 4px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
}
.article-content pre code { background: transparent; border: none; padding: 0; font-size: 14px; }
.article-content blockquote {
border-left: 4px solid var(--border); margin: 16px 0;
padding: 4px 16px; color: var(--muted); background: var(--surface);
border-radius: 0 var(--radius) var(--radius) 0;
}
.article-content ul, .article-content ol { padding-left: 26px; margin: 12px 0; }
.article-content hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.article-content a { text-decoration: underline; }
/* 好友文章锁页 */
.locked-box {
text-align: center; padding: 48px 20px; margin: 24px 0;
background: var(--surface); border: 1px dashed var(--border); border-radius: var(--radius);
}
.locked-box p { margin: 8px 0; }
.locked-box .btn { margin-top: 12px; }
/* ============ 点赞 ============ */
.like-bar {
display: flex; align-items: center; gap: 14px;
padding: 16px 0; margin: 24px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.like-btn { border-radius: 999px; }
.like-btn.liked { background: var(--primary); border-color: var(--primary); color: #fff; }
.like-count { font-weight: 600; }
/* ============ 评论 ============ */
.section-title { font-size: 18px; font-weight: 600; margin: 8px 0 14px; }
.comment-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
.comment-item { background: var(--surface); border-radius: var(--radius); padding: 12px 16px; }
.comment-head { display: flex; justify-content: space-between; align-items: center; font-size: 13px; margin-bottom: 6px; }
.comment-form { display: flex; flex-direction: column; gap: 10px; }
.comment-form textarea { resize: vertical; }
.comment-form .btn { align-self: flex-end; }
.comment-hint { display: flex; align-items: center; gap: 10px; margin: 8px 0 24px; }
/* ============ 表单 ============ */
.form label { display: block; margin: 14px 0 6px; font-size: 13px; color: var(--muted); }
.form label:first-child { margin-top: 0; }
.form input, .form textarea {
width: 100%; padding: 10px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
font-size: 14px; font-family: inherit; color: var(--text);
outline: none; background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form input:focus, .form textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
.form .btn { margin-top: 20px; }
.form-hint { margin-top: 14px; font-size: 13px; color: var(--muted); text-align: center; }
/* 注册表单:验证码输入框与“发送验证码”按钮同行 */
.code-row { display: flex; gap: 8px; margin-top: 6px; }
.code-row input { flex: 1; }
.code-row .btn { white-space: nowrap; }
.form-hint a { color: var(--primary); font-weight: 500; }
/* ============ 自定义弹窗 ============ */
.modal-overlay {
position: fixed; inset: 0; z-index: 900;
background: rgba(32, 33, 36, 0.5);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal {
background: #fff; border-radius: var(--radius);
width: min(420px, 100%); max-height: 86vh; overflow-y: auto;
box-shadow: 0 8px 30px rgba(60, 64, 67, 0.3);
animation: modal-in 0.18s ease-out;
}
@keyframes modal-in { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: none; } }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 17px; font-weight: 600; }
.modal-close {
border: none; background: transparent; color: var(--muted);
font-size: 16px; cursor: pointer; padding: 4px 8px; border-radius: 50%;
line-height: 1;
}
.modal-close:hover { background: var(--surface); color: var(--text); }
.modal-body { padding: 20px; }
/* ============ 通知(Snackbar,替代 alert ============ */
#toast-root {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
z-index: 1000; display: flex; flex-direction: column; align-items: center; gap: 8px;
pointer-events: none;
}
.toast {
background: #323232; color: #fff;
padding: 12px 22px; border-radius: 4px; font-size: 14px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
max-width: 84vw; text-align: center;
opacity: 0; transform: translateY(12px);
transition: opacity 0.25s, transform 0.25s;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast-success { background: var(--success); }
.toast-error { background: var(--danger); }
/* ============ 用户菜单 ============ */
.user-menu { position: relative; }
.role-badge {
font-size: 12px; background: var(--primary-soft); color: var(--primary);
padding: 2px 8px; border-radius: 999px; margin-left: 4px;
}
.user-dropdown {
position: absolute; right: 0; top: calc(100% + 6px); min-width: 160px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden; z-index: 200;
}
.dropdown-item {
display: block; padding: 10px 16px; font-size: 14px; color: var(--text);
cursor: pointer; white-space: nowrap;
}
.dropdown-item:hover { background: var(--surface); }
/* 好友申请管理 */
.app-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.app-row:last-child { border-bottom: none; }
.app-user { font-weight: 600; }
.app-email { flex: 1; min-width: 120px; font-size: 13px; }
.app-status { font-size: 13px; }
/* ============ 项目 / 简介 ============ */
.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; }
.project-card {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 20px; display: flex; flex-direction: column; gap: 10px;
transition: box-shadow 0.2s;
}
.project-card:hover { box-shadow: var(--shadow); }
.project-card h3 { font-size: 17px; }
.project-actions { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag {
font-size: 12px; color: var(--primary); background: var(--primary-soft);
padding: 2px 10px; border-radius: 999px;
}
.about-card {
display: flex; gap: 24px; align-items: flex-start;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px; background: var(--surface);
}
.about-avatar {
width: 88px; height: 88px; flex-shrink: 0;
border-radius: 50%; background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 34px; font-weight: 600;
}
.about-card h2 { font-size: 22px; margin-bottom: 4px; }
.about-bio { margin: 14px 0; display: flex; flex-direction: column; gap: 8px; }
/* 主页简介按钮区:编辑简介(博主) + 打赏支持(所有人) */
.about-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; }
/* 打赏弹窗:二维码卡片 + 感谢文字(点击图片外任意位置关闭) */
.donate-modal {
background: #fff; border-radius: 16px; padding: 26px 30px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
box-shadow: 0 8px 30px rgba(60, 64, 67, 0.3);
animation: modal-in 0.18s ease-out;
}
.donate-modal img { max-width: 260px; width: 100%; height: auto; border-radius: 12px; display: block; }
.donate-thanks { font-size: 15px; color: var(--primary); font-weight: 600; }
.proj-list-title { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
/* ============ 响应式 ============ */
@media (max-width: 720px) {
.topbar-inner { flex-wrap: wrap; height: auto; padding: 10px 12px; gap: 8px; }
.nav { order: 3; width: 100%; flex: none; }
.article-detail .article-title { font-size: 24px; }
.about-card { flex-direction: column; align-items: center; text-align: center; }
}
/* ============ 博主发文管理面板 ============ */
.manage-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: start;
margin-top: 18px;
}
.manage-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
background: #fff;
}
.cover-field { display: flex; flex-direction: column; gap: 10px; }
.cover-preview { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.cover-preview img {
max-height: 200px; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--surface);
}
.cover-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#cover-url { flex: 1; min-width: 200px; }
.radio-row { display: flex; gap: 20px; margin: 6px 0 4px; }
.radio { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.radio input { width: auto; }
.editor-toolbar { display: flex; gap: 8px; margin: 6px 0 10px; flex-wrap: wrap; }
.preview-box {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 18px; margin-bottom: 12px; background: var(--surface);
max-height: 420px; overflow-y: auto;
}
.my-article-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.my-article-row:last-child { border-bottom: none; }
.my-article-cover { width: 72px; height: 48px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.my-article-cover-empty { width: 72px; height: 48px; border-radius: 6px; background: var(--surface); flex-shrink: 0; }
.my-article-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.my-article-title { font-weight: 600; color: var(--text); }
.my-article-title:hover { color: var(--primary); }
@media (max-width: 720px) {
.manage-grid { grid-template-columns: 1fr; }
}
/* ============ 评论回复 / 加载更多 / 管理面板补充样式 ============ */
.comment-reply {
margin-left: 28px;
border-left: 3px solid var(--border);
border-radius: 0 var(--radius) var(--radius) 0;
}
.comment-reply-btn { padding: 2px 8px; margin-top: 2px; }
.comment-reply-box { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
.comment-input { width: 100%; }
.load-more-btn { margin-top: 18px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #c5221f; }
.btn-danger-text { color: var(--danger); border-color: rgba(217, 48, 37, 0.4); }
.btn-danger-text:hover { background: rgba(217, 48, 37, 0.08); }
.manage-grid-second { margin-top: 20px; }
.manage-card-wide { grid-column: 1 / -1; }
.profile-avatar-row { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
.profile-avatar {
width: 80px; height: 80px; flex-shrink: 0;
border-radius: 50%; overflow: hidden;
background: var(--primary-soft); color: var(--muted);
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
#profile-bio { width: 100%; resize: vertical; }
.about-avatar-img {
width: 88px; height: 88px; flex-shrink: 0;
border-radius: 50%; object-fit: cover;
border: 1px solid var(--border);
}
.my-article-actions { display: flex; gap: 8px; flex-shrink: 0; }
/* ============ 主页简介 / 社交链接 / 视频 / 评论头像 ============ */
.home-about { margin-bottom: 26px; }
.about-main { flex: 1; min-width: 0; }
.social-links { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
.social-link {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border: 1px solid var(--border); border-radius: 999px;
background: #fff; color: var(--text); font-size: 13px;
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
.social-link:hover { border-color: var(--primary); color: var(--primary); box-shadow: var(--shadow); }
.social-link .social-icon { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }
/* 好友列表弹窗 */
.friend-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.friend-row:last-child { border-bottom: none; }
.friend-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; background: var(--surface); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.friend-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.article-content video { max-width: 100%; border-radius: 8px; background: #000; display: block; }
.comment-author { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.comment-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.comment-avatar-empty { background: var(--primary-soft); display: inline-block; }
.breadcrumb { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); margin-bottom: 14px; flex-wrap: wrap; }
.breadcrumb a { color: var(--primary); }
.project-detail-tags { margin: 12px 0; }
.project-detail-actions { display: flex; gap: 12px; margin-top: 18px; flex-wrap: wrap; }
.publish-title { font-size: 14px; color: var(--text); margin-bottom: 4px; }
+83
View File
@@ -0,0 +1,83 @@
/* =====================================================================
* 通用工具(utils.js
* DOM 查询 / HTML 转义 / URL 白名单 / 日期格式化 / Toast / 自定义弹窗
* ===================================================================== */
export function $(selector, root = document) {
return root.querySelector(selector);
}
export function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
// URL 协议白名单:仅允许 http/https、站内相对路径与锚点,
// 禁止 javascript: / data: / vbscript: 等危险协议,防止 XSS。
export function safeUrl(url, type = "link") {
const value = String(url ?? "").trim();
if (!value) return "";
if (/^(https?:)?\/\//i.test(value)) return value;
if (value.startsWith("/") || value.startsWith("#")) return value;
if (type === "link" && value.startsWith("mailto:")) return value;
return "";
}
export function parseDate(value) {
if (!value) return null;
let text = String(value).replace(" ", "T");
// 后端时间为无时区 UTC,补 Z 后按 UTC 解析并转为本地时区显示
if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(text)) text += "Z";
const d = new Date(text);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatDate(value) {
const d = parseDate(value);
if (!d) return "";
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/* ---------------- 通知(自定义 Snackbar,不使用 alert ---------------- */
export function showToast(message, type = "info") {
const el = document.createElement("div");
el.className = `toast toast-${type}`;
el.textContent = message;
$("#toast-root").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 300);
}, 3200);
}
/* ---------------- 自定义弹窗 ---------------- */
export function openModal({ title, content }) {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h3></h3>
<button class="modal-close" aria-label="关闭">✕</button>
</div>
<div class="modal-body"></div>
</div>`;
$(".modal h3", overlay).textContent = title;
const bodyEl = $(".modal-body", overlay);
if (typeof content === "string") bodyEl.innerHTML = content;
else bodyEl.appendChild(content);
const close = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", onKey);
$(".modal-close", overlay).addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
$("#modal-root").appendChild(overlay);
return { overlay, close };
}