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
+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();
}