Files

379 lines
16 KiB
JavaScript
Raw Permalink Normal View History

2026-08-22 22:28:41 +08:00
/* =====================================================================
* 文章视图模块(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");
}
});
}