379 lines
16 KiB
JavaScript
379 lines
16 KiB
JavaScript
/* =====================================================================
|
||
* 文章视图模块(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");
|
||
}
|
||
});
|
||
} |