blog / src /components /cms /AdminApp.svelte
cacode's picture
Upload 434 files
96dd062 verified
<script lang="ts">
import { onMount } from "svelte";
import { mergeConfig } from "@/admin/override-utils";
import Icon from "@/components/common/Icon.svelte";
import { renderMarkdownPreview } from "./markdown-preview";
type BuildState = {
status: string;
startedAt: string | null;
finishedAt: string | null;
lastBuiltAt: string | null;
lastError: string;
logs: string[];
};
type DashboardState = {
posts: number;
drafts: number;
pages: number;
assets: number;
tags: number;
categories: number;
build: BuildState;
};
type PostSummary = {
slug: string;
title: string;
description: string;
draft: boolean;
pinned: boolean;
};
type PageRecord = {
slug: string;
content: string;
filePath: string;
};
type AssetRecord = {
path: string;
url: string;
name: string;
size: number;
modifiedAt: string;
};
type SectionKey =
| "site"
| "navbar"
| "sidebar"
| "profile"
| "background"
| "announcement"
| "footer"
| "comment"
| "font"
| "friends"
| "sponsor"
| "ads"
| "music"
| "pio"
| "license"
| "cover"
| "sakura";
let { initialConfig } = $props<{ initialConfig: Record<string, any> }>();
const baseConfig = structuredClone(initialConfig);
let session = $state({
authenticated: false,
configured: true,
username: "",
});
let activeTab = $state("dashboard");
let notice = $state({ text: "", tone: "info" });
let loading = $state(false);
let saving = $state(false);
let dashboard = $state<DashboardState>({
posts: 0,
drafts: 0,
pages: 0,
assets: 0,
tags: 0,
categories: 0,
build: {
status: "idle",
startedAt: null,
finishedAt: null,
lastBuiltAt: null,
lastError: "",
logs: [],
},
});
let buildState = $state<BuildState>(dashboard.build);
let editors = $state<Record<SectionKey, string>>({
site: "{}",
navbar: "{}",
sidebar: "{}",
profile: "{}",
background: "{}",
announcement: "{}",
footer: "{}",
comment: "{}",
font: "{}",
friends: "{}",
sponsor: "{}",
ads: "{}",
music: "{}",
pio: "{}",
license: "{}",
cover: "{}",
sakura: "{}",
});
let footerHtml = $state(initialConfig.footerHtml || "");
let loginForm = $state({ username: "", password: "" });
let pages = $state<PageRecord[]>([]);
let selectedPageSlug = $state("");
let pageContent = $state("");
let posts = $state<PostSummary[]>([]);
let selectedPostSlug = $state<string | null>(null);
let postSearch = $state("");
let postForm = $state(createBlankPost());
let postTagsText = $state("");
let assets = $state<AssetRecord[]>([]);
let uploadFolder = $state("uploads");
let uploadName = $state("");
let uploadDataUrl = $state("");
function createBlankPost() {
return {
slug: "",
title: "",
published: new Date().toISOString().slice(0, 10),
updated: "",
description: "",
image: "",
tags: [],
category: "",
draft: false,
lang: "",
pinned: false,
author: "",
sourceLink: "",
licenseName: "",
licenseUrl: "",
comment: true,
content: "",
extension: "md",
};
}
function setNotice(text: string, tone = "info") {
notice = { text, tone };
}
function prettyJson(value: unknown) {
return JSON.stringify(value, null, 2);
}
function slugify(value: string) {
return value
.normalize("NFKD")
.toLowerCase()
.trim()
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/^-+|-+$/g, "");
}
function parseJson(text: string, label: string) {
try {
return JSON.parse(text || "{}");
} catch (error) {
throw new Error(`${label} JSON 格式错误: ${error instanceof Error ? error.message : "unknown error"}`);
}
}
function fillEditors(config: Record<string, any>) {
for (const key of Object.keys(editors) as SectionKey[]) {
editors[key] = prettyJson(config[key] || {});
}
}
function hydratePost(post: Record<string, any>) {
postForm = { ...createBlankPost(), ...post };
postTagsText = Array.isArray(post.tags) ? post.tags.join(", ") : "";
}
function selectPage(page: PageRecord) {
selectedPageSlug = page.slug;
pageContent = page.content;
}
const filteredPosts = $derived.by(() => {
const keyword = postSearch.trim().toLowerCase();
if (!keyword) {
return posts;
}
return posts.filter((post) =>
`${post.title} ${post.slug} ${post.description}`.toLowerCase().includes(keyword),
);
});
const pagePreview = $derived(renderMarkdownPreview(pageContent));
const postPreview = $derived(renderMarkdownPreview(postForm.content));
async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || "Request failed");
}
return data as T;
}
async function refreshSession() {
session = await api("/api/admin/session");
}
async function loadData() {
loading = true;
try {
const [overrideData, dashboardData, pagesData, postsData, assetsData, currentBuild] =
await Promise.all([
api<{ overrides: Record<string, any>; footerHtml: string }>("/api/admin/overrides"),
api<DashboardState>("/api/admin/dashboard"),
api<PageRecord[]>("/api/admin/pages"),
api<PostSummary[]>("/api/admin/posts"),
api<AssetRecord[]>("/api/admin/assets"),
api<BuildState>("/api/admin/build"),
]);
const mergedConfig = mergeConfig(structuredClone(baseConfig), overrideData.overrides || {});
fillEditors(mergedConfig);
footerHtml = overrideData.footerHtml || baseConfig.footerHtml || "";
dashboard = dashboardData;
buildState = currentBuild;
pages = pagesData;
posts = postsData;
assets = assetsData;
if (!selectedPageSlug && pages.length > 0) {
selectPage(pages[0]);
}
} finally {
loading = false;
}
}
async function login() {
try {
loading = true;
await api("/api/admin/login", {
method: "POST",
body: JSON.stringify(loginForm),
});
await refreshSession();
await loadData();
setNotice("登录成功", "success");
} catch (error) {
setNotice(error instanceof Error ? error.message : "登录失败", "error");
} finally {
loading = false;
loginForm.password = "";
}
}
async function logout() {
await api("/api/admin/logout", { method: "POST" });
session = { authenticated: false, configured: session.configured, username: "" };
setNotice("已退出登录", "info");
}
async function saveSection(key: SectionKey, label: string) {
try {
saving = true;
await api(`/api/admin/config/${key}`, {
method: "PUT",
body: JSON.stringify(parseJson(editors[key], label)),
});
setNotice(`${label}已保存,重建后生效`, "success");
} catch (error) {
setNotice(error instanceof Error ? error.message : "保存失败", "error");
} finally {
saving = false;
}
}
async function saveFooter() {
try {
saving = true;
await Promise.all([
api("/api/admin/config/footer", {
method: "PUT",
body: editors.footer,
}),
api("/api/admin/footer-html", {
method: "PUT",
body: JSON.stringify({ content: footerHtml }),
}),
]);
setNotice("页脚已保存,重建后生效", "success");
} catch (error) {
setNotice(error instanceof Error ? error.message : "页脚保存失败", "error");
} finally {
saving = false;
}
}
async function savePage(publish = false) {
if (!selectedPageSlug) {
setNotice("请先选择页面", "error");
return;
}
try {
saving = true;
const response = await api<{ build: BuildState }>(
`/api/admin/pages/${encodeURIComponent(selectedPageSlug)}`,
{
method: "PUT",
body: JSON.stringify({ content: pageContent, publish }),
},
);
pages = pages.map((item) =>
item.slug === selectedPageSlug ? { ...item, content: pageContent } : item,
);
if (publish) {
buildState = response.build;
}
await api("/api/admin/dashboard").then((data) => {
dashboard = data as DashboardState;
});
setNotice(publish ? "页面已保存并发布" : "页面已保存", "success");
} catch (error) {
setNotice(error instanceof Error ? error.message : "页面保存失败", "error");
} finally {
saving = false;
}
}
async function openPost(slug: string) {
const post = await api<Record<string, any>>(`/api/admin/posts/${encodeURIComponent(slug)}`);
selectedPostSlug = slug;
hydratePost(post);
}
async function savePost(publish = false) {
try {
saving = true;
postForm.tags = postTagsText.split(",").map((item) => item.trim()).filter(Boolean);
const route = selectedPostSlug
? `/api/admin/posts/${encodeURIComponent(selectedPostSlug)}`
: "/api/admin/posts";
const method = selectedPostSlug ? "PUT" : "POST";
const response = await api<{ post: Record<string, any>; build: BuildState }>(route, {
method,
body: JSON.stringify({ ...postForm, publish }),
});
selectedPostSlug = response.post.slug;
hydratePost(response.post);
posts = await api("/api/admin/posts");
if (publish) {
buildState = response.build;
}
dashboard = await api("/api/admin/dashboard");
setNotice(publish ? "文章已保存并发布" : "文章已保存", "success");
} catch (error) {
setNotice(error instanceof Error ? error.message : "文章保存失败", "error");
} finally {
saving = false;
}
}
async function deletePost() {
if (!selectedPostSlug || !window.confirm(`确认删除 ${selectedPostSlug} 吗?`)) {
return;
}
try {
saving = true;
await api(`/api/admin/posts/${encodeURIComponent(selectedPostSlug)}`, { method: "DELETE" });
selectedPostSlug = null;
hydratePost(createBlankPost());
posts = await api("/api/admin/posts");
dashboard = await api("/api/admin/dashboard");
setNotice("文章已删除", "success");
} catch (error) {
setNotice(error instanceof Error ? error.message : "文章删除失败", "error");
} finally {
saving = false;
}
}
function chooseFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
uploadName = file?.name || "";
if (!file) {
uploadDataUrl = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
uploadDataUrl = String(reader.result || "");
};
reader.readAsDataURL(file);
}
async function uploadAsset() {
if (!uploadName || !uploadDataUrl) {
setNotice("请先选择文件", "error");
return;
}
try {
saving = true;
await api("/api/admin/assets", {
method: "POST",
body: JSON.stringify({ name: uploadName, folder: uploadFolder, dataUrl: uploadDataUrl }),
});
assets = await api("/api/admin/assets");
dashboard = await api("/api/admin/dashboard");
setNotice("资源上传成功", "success");
uploadName = "";
uploadDataUrl = "";
} catch (error) {
setNotice(error instanceof Error ? error.message : "资源上传失败", "error");
} finally {
saving = false;
}
}
async function rebuildSite() {
const response = await api<{ state: BuildState }>("/api/admin/rebuild", { method: "POST" });
buildState = response.state;
setNotice("已触发站点重建", "success");
}
onMount(() => {
const timer = window.setInterval(async () => {
if (!session.authenticated) {
return;
}
try {
buildState = await api("/api/admin/build");
} catch {}
}, 3000);
(async () => {
await refreshSession();
if (session.authenticated) {
await loadData();
}
})();
return () => window.clearInterval(timer);
});
</script>
<div class="admin-shell">
{#if notice.text}
<div class={`admin-notice ${notice.tone}`}>{notice.text}</div>
{/if}
{#if !session.configured}
<div class="card-base admin-card">
<h2 class="admin-title">请先设置 Space 环境变量</h2>
<p class="text-75">需要配置 `ADMIN` 和 `PASSWORD` 才能启用后台登录。</p>
</div>
{:else if !session.authenticated}
<div class="card-base admin-card admin-login">
<h2 class="admin-title">后台登录</h2>
<input class="admin-input" placeholder="管理员账号" bind:value={loginForm.username} />
<input class="admin-input" type="password" placeholder="密码" bind:value={loginForm.password} />
<button class="btn-regular admin-button" onclick={login} disabled={loading}>登录</button>
</div>
{:else}
<div class="admin-toolbar card-base">
<div class="admin-tabs">
{#each [
["dashboard", "控制台"],
["config", "配置"],
["content", "内容"],
["media", "资源"],
["advanced", "高级"],
] as [id, label]}
<button class:active={activeTab === id} class="btn-card admin-tab" onclick={() => (activeTab = id)}>{label}</button>
{/each}
</div>
<div class="admin-actions">
<button class="btn-card admin-button secondary" onclick={loadData}>刷新</button>
<button class="btn-regular admin-button" onclick={rebuildSite}>重建站点</button>
<button class="btn-card admin-button secondary" onclick={logout}>退出</button>
</div>
</div>
{#if activeTab === "dashboard"}
<div class="admin-grid">
{#each [
["文章", dashboard.posts],
["草稿", dashboard.drafts],
["页面", dashboard.pages],
["图片", dashboard.assets],
["标签", dashboard.tags],
["分类", dashboard.categories],
] as [label, value]}
<div class="card-base admin-card metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
{/each}
</div>
<div class="card-base admin-card">
<h3 class="admin-title">构建状态:{buildState.status}</h3>
<p class="text-75">最近成功构建:{buildState.lastBuiltAt || "尚未构建"}</p>
<p class="text-75">最近错误:{buildState.lastError || "无"}</p>
<pre class="admin-code build-log">{buildState.logs.join("\n") || "这里会显示构建日志。"}</pre>
</div>
{/if}
{#if activeTab === "config"}
<div class="admin-stack">
{#each [
["site", "站点配置"],
["profile", "个人资料"],
["announcement", "公告配置"],
["navbar", "导航配置"],
["sidebar", "侧栏配置"],
["background", "壁纸配置"],
] as [key, label]}
<div class="card-base admin-card">
<div class="row">
<h3 class="admin-title">{label}</h3>
<button class="btn-regular admin-button" onclick={() => saveSection(key as SectionKey, label)} disabled={saving}>保存</button>
</div>
<textarea class="admin-code section-editor" rows="14" bind:value={editors[key as SectionKey]}></textarea>
</div>
{/each}
<div class="card-base admin-card">
<div class="row">
<h3 class="admin-title">页脚 HTML</h3>
<button class="btn-regular admin-button" onclick={saveFooter} disabled={saving}>保存页脚</button>
</div>
<textarea class="admin-code section-editor" rows="10" bind:value={footerHtml}></textarea>
</div>
</div>
{/if}
{#if activeTab === "content"}
<div class="card-base admin-card">
<div class="row">
<h3 class="admin-title">独立页面</h3>
<div class="admin-actions">
<button class="btn-card admin-button secondary" onclick={() => savePage(false)} disabled={saving}>保存</button>
<button class="btn-regular admin-button" onclick={() => savePage(true)} disabled={saving}>保存并发布</button>
</div>
</div>
<div class="editor-layout">
<div class="editor-list">
{#each pages as page}
<button class:active={selectedPageSlug === page.slug} class="btn-card editor-item" onclick={() => selectPage(page)}>{page.slug}</button>
{/each}
</div>
<textarea class="admin-code editor-input" bind:value={pageContent}></textarea>
<div class="editor-preview custom-md prose dark:prose-invert max-w-none">
{@html pagePreview}
</div>
</div>
</div>
<div class="card-base admin-card">
<div class="row">
<h3 class="admin-title">文章编辑器</h3>
<div class="admin-actions">
<button class="btn-card admin-button secondary" onclick={() => { selectedPostSlug = null; hydratePost(createBlankPost()); }}>新建</button>
<button class="btn-card admin-button secondary" onclick={() => savePost(false)} disabled={saving}>保存</button>
<button class="btn-regular admin-button" onclick={() => savePost(true)} disabled={saving}>保存并发布</button>
<button class="btn-card admin-button secondary" onclick={deletePost} disabled={!selectedPostSlug || saving}>删除</button>
</div>
</div>
<div class="editor-layout posts">
<div class="editor-list">
<input class="admin-input" placeholder="搜索文章" bind:value={postSearch} />
{#each filteredPosts as post}
<button class:active={selectedPostSlug === post.slug} class="btn-card editor-item post-item" onclick={() => openPost(post.slug)}>
<span>{post.title || post.slug}</span>
<small>{post.slug}</small>
</button>
{/each}
</div>
<div class="post-form">
<div class="post-meta-grid">
<input class="admin-input" placeholder="标题" bind:value={postForm.title} />
<div class="slug-row">
<input class="admin-input" placeholder="slug" bind:value={postForm.slug} />
<button class="btn-card admin-button secondary" onclick={() => (postForm.slug = slugify(postForm.title || postForm.slug))}>生成</button>
</div>
<input class="admin-input" placeholder="发布日期 YYYY-MM-DD" bind:value={postForm.published} />
<input class="admin-input" placeholder="更新日期 YYYY-MM-DD" bind:value={postForm.updated} />
<input class="admin-input" placeholder="封面地址" bind:value={postForm.image} />
<input class="admin-input" placeholder="分类" bind:value={postForm.category} />
<input class="admin-input" placeholder="标签,逗号分隔" bind:value={postTagsText} />
<input class="admin-input" placeholder="语言" bind:value={postForm.lang} />
<textarea class="admin-code" rows="3" placeholder="描述" bind:value={postForm.description}></textarea>
</div>
<div class="check-row">
<label><input type="checkbox" bind:checked={postForm.draft} /> 草稿</label>
<label><input type="checkbox" bind:checked={postForm.pinned} /> 置顶</label>
<label><input type="checkbox" bind:checked={postForm.comment} /> 评论</label>
</div>
<textarea class="admin-code editor-input" bind:value={postForm.content}></textarea>
</div>
<div class="editor-preview custom-md prose dark:prose-invert max-w-none">
{@html postPreview}
</div>
</div>
</div>
{/if}
{#if activeTab === "media"}
<div class="card-base admin-card">
<div class="row">
<h3 class="admin-title">图片与静态资源</h3>
<button class="btn-regular admin-button" onclick={uploadAsset} disabled={saving}>上传</button>
</div>
<div class="upload-row">
<input class="admin-input" placeholder="上传目录,如 uploads/posts" bind:value={uploadFolder} />
<input class="admin-input" type="file" onchange={chooseFile} />
</div>
<div class="asset-grid">
{#each assets as asset}
<div class="btn-card asset-item">
{#if asset.url.match(/\.(png|jpe?g|webp|gif|svg)$/i)}
<img src={asset.url} alt={asset.name} />
{/if}
<strong>{asset.name}</strong>
<small>{asset.url}</small>
</div>
{/each}
</div>
</div>
{/if}
{#if activeTab === "advanced"}
<div class="admin-stack">
{#each [
["comment", "评论系统"],
["font", "字体"],
["friends", "友链"],
["sponsor", "赞助"],
["ads", "广告"],
["music", "音乐播放器"],
["pio", "看板娘"],
["license", "许可证"],
["cover", "封面图配置"],
["sakura", "樱花特效"],
] as [key, label]}
<div class="card-base admin-card">
<div class="row">
<h3 class="admin-title">{label}</h3>
<button class="btn-regular admin-button" onclick={() => saveSection(key as SectionKey, label)} disabled={saving}>保存</button>
</div>
<textarea class="admin-code section-editor" rows="14" bind:value={editors[key as SectionKey]}></textarea>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.admin-shell, .admin-stack { display:flex; flex-direction:column; gap:1rem; }
.admin-card { padding:1.25rem; }
.admin-title { margin:0; font-size:1.2rem; font-weight:700; }
.admin-login { max-width:28rem; margin:0 auto; }
.admin-toolbar, .row, .admin-actions, .admin-tabs, .upload-row, .slug-row { display:flex; gap:.75rem; align-items:center; }
.admin-toolbar, .row { justify-content:space-between; }
.admin-tabs { flex-wrap:wrap; }
.admin-button { padding:.7rem 1rem; border-radius:.9rem; }
.admin-button.secondary { background:var(--btn-card-bg-hover); }
.admin-tab.active { background:var(--btn-regular-bg-hover); color:var(--primary); }
.admin-grid { display:grid; gap:.75rem; grid-template-columns:repeat(6,minmax(0,1fr)); }
.metric { display:flex; flex-direction:column; gap:.4rem; }
.metric strong { font-size:1.8rem; }
.admin-input, .admin-code { width:100%; border:1px solid var(--line-divider); background:color-mix(in oklch, var(--card-bg) 90%, var(--btn-card-bg-hover) 10%); border-radius:.9rem; padding:.8rem .95rem; color:inherit; }
.admin-code { resize:vertical; font-family:"JetBrains Mono Variable",ui-monospace,Consolas,monospace; font-size:.85rem; line-height:1.6; }
.section-editor { min-height:18rem; }
.admin-notice { padding:.85rem 1rem; border-radius:1rem; border:1px solid var(--line-divider); }
.admin-notice.success { background:color-mix(in oklch, var(--primary) 14%, var(--card-bg) 86%); }
.admin-notice.error { background:color-mix(in oklch, oklch(0.68 0.18 25) 14%, var(--card-bg) 86%); }
.build-log { max-height:18rem; overflow:auto; }
.editor-layout { display:grid; gap:1rem; grid-template-columns:14rem minmax(0,1fr) minmax(0,1fr); }
.editor-layout.posts { grid-template-columns:16rem minmax(0,1.1fr) minmax(0,.9fr); }
.editor-list { display:flex; flex-direction:column; gap:.6rem; }
.editor-item { justify-content:flex-start; text-align:left; padding:.8rem .95rem; border-radius:.9rem; }
.editor-item.active { background:var(--btn-regular-bg-hover); }
.post-item { display:flex; flex-direction:column; align-items:flex-start; }
.editor-input { min-height:26rem; }
.editor-preview { min-height:26rem; overflow:auto; padding:1rem; border-radius:1rem; border:1px solid var(--line-divider); background:color-mix(in oklch, var(--card-bg) 92%, var(--btn-card-bg-hover) 8%); }
.post-form { display:flex; flex-direction:column; gap:.75rem; }
.post-meta-grid { display:grid; gap:.75rem; grid-template-columns:repeat(2,minmax(0,1fr)); }
.check-row { display:flex; gap:1rem; flex-wrap:wrap; }
.asset-grid { display:grid; gap:.75rem; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:1rem; }
.asset-item { flex-direction:column; align-items:flex-start; padding:.8rem; border-radius:1rem; gap:.45rem; }
.asset-item img { width:100%; aspect-ratio:16/10; object-fit:cover; border-radius:.8rem; }
.text-75 { color:rgba(0,0,0,.75); }
.dark .text-75 { color:rgba(255,255,255,.75); }
@media (max-width: 1100px) {
.admin-grid, .asset-grid, .post-meta-grid, .editor-layout, .editor-layout.posts { grid-template-columns:1fr 1fr; }
.editor-layout, .editor-layout.posts { grid-template-columns:1fr; }
}
@media (max-width: 720px) {
.admin-grid, .asset-grid, .post-meta-grid { grid-template-columns:1fr; }
.admin-toolbar, .row, .admin-actions, .upload-row, .slug-row { flex-direction:column; align-items:stretch; }
}
</style>