Files

528 lines
24 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.
/* =====================================================================
* 博主管理面板(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();
}