Files

379 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 文章视图模块(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");
}
});
}