| <script lang="ts"> |
| import { onMount } from "svelte"; |
| import type { AdminSnapshot } from "@/admin/types"; |
| import { renderAdminMarkdown } from "./markdown-preview"; |
|
|
| type NavKey = |
| | "overview" |
| | "site" |
| | "appearance" |
| | "navigation" |
| | "posts" |
| | "pages" |
| | "media" |
| | "advanced" |
| | "build"; |
|
|
| type ToastState = { |
| tone: "success" | "error" | "info"; |
| text: string; |
| }; |
|
|
| type BuildState = { |
| status: string; |
| startedAt: string | null; |
| finishedAt: string | null; |
| lastBuiltAt: string | null; |
| lastDurationMs: number | null; |
| lastError: string; |
| queueLength?: number; |
| queuedReason?: string; |
| logs: string[]; |
| }; |
|
|
| type DashboardState = { |
| posts: number; |
| drafts: number; |
| pages: number; |
| assets: number; |
| tags: number; |
| categories: number; |
| build: BuildState; |
| }; |
|
|
| type PostListItem = { |
| slug: string; |
| title: string; |
| description?: string; |
| published?: string; |
| draft?: boolean; |
| pinned?: boolean; |
| }; |
|
|
| type EditablePost = { |
| slug: string; |
| title: string; |
| published: string; |
| updated: string; |
| description: string; |
| image: string; |
| tags: string[]; |
| category: string; |
| lang: string; |
| draft: boolean; |
| pinned: boolean; |
| comment: boolean; |
| author: string; |
| sourceLink: string; |
| licenseName: string; |
| licenseUrl: string; |
| body: string; |
| extension: "md" | "mdx"; |
| __originalSlug?: string; |
| }; |
|
|
| type PageRecord = { |
| slug: string; |
| filePath?: string; |
| body: string; |
| modifiedAt?: string; |
| }; |
|
|
| type AssetRecord = { |
| root: "public" | "content"; |
| path: string; |
| name: string; |
| directory: string; |
| size: number; |
| modifiedAt: string; |
| previewUrl: string; |
| reference: string; |
| }; |
|
|
| type EditableConfig = { |
| site: Record<string, any>; |
| navbar: Record<string, any>; |
| sidebar: Record<string, any>; |
| profile: Record<string, any>; |
| wallpaper: Record<string, any>; |
| announcement: Record<string, any>; |
| footer: Record<string, any>; |
| comment: Record<string, any>; |
| friends: Record<string, any>; |
| sponsor: Record<string, any>; |
| music: Record<string, any>; |
| pio: Record<string, any>; |
| ad: Record<string, any>; |
| license: Record<string, any>; |
| coverImage: Record<string, any>; |
| font: Record<string, any>; |
| sakura: Record<string, any>; |
| }; |
|
|
| type JsonSectionKey = |
| | "site" |
| | "navbar" |
| | "sidebar" |
| | "friends" |
| | "sponsor" |
| | "ad" |
| | "comment" |
| | "music" |
| | "pio" |
| | "coverImage" |
| | "font" |
| | "sakura"; |
|
|
| export let initialSnapshot: AdminSnapshot; |
| export let entryPath = "/admin"; |
|
|
| const navItems: Array<{ key: NavKey; label: string; hint: string }> = [ |
| { key: "overview", label: "总览", hint: "状态、统计与快捷操作" }, |
| { key: "site", label: "站点", hint: "标题、描述、分页与主题" }, |
| { key: "appearance", label: "外观", hint: "资料、公告、页脚与壁纸" }, |
| { key: "navigation", label: "导航", hint: "导航栏与侧栏布局" }, |
| { key: "posts", label: "文章", hint: "Markdown 写作与预览" }, |
| { key: "pages", label: "页面", hint: "关于页、友链页等固定页" }, |
| { key: "media", label: "图片", hint: "上传、预览、复制引用路径" }, |
| { key: "advanced", label: "扩展", hint: "评论、音乐、赞助等高级配置" }, |
| { key: "build", label: "构建", hint: "构建日志与发布状态" }, |
| ]; |
|
|
| function cloneData<T>(value: T): T { |
| if (value === null || value === undefined || typeof value !== "object") { |
| return value; |
| } |
| try { |
| return structuredClone(value); |
| } catch { |
| return JSON.parse(JSON.stringify(value)) as T; |
| } |
| } |
|
|
| function prettyJson(value: unknown) { |
| return JSON.stringify(value, null, 2); |
| } |
|
|
| function buildInitialConfig(snapshot: AdminSnapshot): EditableConfig { |
| return normalizeConfig({ |
| site: cloneData(snapshot.configs.siteConfig), |
| navbar: { links: cloneData(snapshot.configs.navBarConfig.links) }, |
| sidebar: cloneData(snapshot.configs.sidebarConfig), |
| profile: cloneData(snapshot.configs.profileConfig), |
| wallpaper: cloneData(snapshot.configs.backgroundWallpaper), |
| announcement: cloneData(snapshot.configs.announcementConfig), |
| footer: cloneData(snapshot.configs.footerConfig), |
| comment: cloneData(snapshot.configs.commentConfig), |
| friends: { |
| page: cloneData(snapshot.configs.friendsConfig.friendsPageConfig), |
| items: cloneData(snapshot.configs.friendsConfig.friendsConfig), |
| }, |
| sponsor: cloneData(snapshot.configs.sponsorConfig), |
| music: cloneData(snapshot.configs.musicConfig), |
| pio: { |
| spine: cloneData(snapshot.configs.pioConfig.spineModelConfig), |
| live2d: cloneData(snapshot.configs.pioConfig.live2dModelConfig), |
| }, |
| ad: { |
| ad1: cloneData(snapshot.configs.adConfig.adConfig1), |
| ad2: cloneData(snapshot.configs.adConfig.adConfig2), |
| }, |
| license: cloneData(snapshot.configs.licenseConfig), |
| coverImage: cloneData(snapshot.configs.coverImageConfig), |
| font: cloneData(snapshot.configs.fontConfig), |
| sakura: cloneData(snapshot.configs.sakuraConfig), |
| }); |
| } |
|
|
| function normalizeConfig(input: EditableConfig): EditableConfig { |
| const next = cloneData(input); |
| next.site ||= {}; |
| next.site.themeColor ||= { hue: 165, fixed: false, defaultMode: "system" }; |
| next.site.card ||= { border: true }; |
| next.site.pages ||= { sponsor: false, guestbook: false, bangumi: false }; |
| next.site.postListLayout ||= { defaultMode: "list", allowSwitch: true, grid: { masonry: false, columns: 2 } }; |
| next.site.postListLayout.grid ||= { masonry: false, columns: 2 }; |
| next.site.pagination ||= { postsPerPage: 8 }; |
| next.site.analytics ||= { googleAnalyticsId: "", microsoftClarityId: "" }; |
| next.font ||= cloneData(next.site.font || {}); |
| next.site.font = cloneData(next.font); |
| next.navbar ||= { links: [] }; |
| next.sidebar ||= { enable: true, leftComponents: [], rightComponents: [], mobileBottomComponents: [] }; |
| next.profile ||= { links: [] }; |
| next.profile.links ||= []; |
| next.wallpaper ||= { mode: "banner", switchable: true, src: { desktop: "", mobile: "" } }; |
| if (typeof next.wallpaper.src !== "object" || next.wallpaper.src === null || Array.isArray(next.wallpaper.src)) { |
| next.wallpaper.src = { desktop: typeof next.wallpaper.src === "string" ? next.wallpaper.src : "", mobile: "" }; |
| } |
| next.wallpaper.banner ||= {}; |
| next.wallpaper.banner.homeText ||= { enable: true, title: "", subtitle: [], titleSize: "", subtitleSize: "", typewriter: { enable: true, speed: 100, deleteSpeed: 50, pauseTime: 2000 } }; |
| next.wallpaper.banner.homeText.typewriter ||= { enable: true, speed: 100, deleteSpeed: 50, pauseTime: 2000 }; |
| next.wallpaper.banner.credit ||= { enable: false, text: "", url: "" }; |
| next.wallpaper.banner.waves ||= { enable: true }; |
| next.wallpaper.banner.navbar ||= { transparentMode: "semifull", enableBlur: true, blur: 3 }; |
| next.announcement ||= { link: { enable: false, text: "", url: "", external: false } }; |
| next.announcement.link ||= { enable: false, text: "", url: "", external: false }; |
| next.footer ||= { enable: false, customHtml: "" }; |
| next.comment ||= {}; |
| next.friends ||= { page: { columns: 2 }, items: [] }; |
| next.friends.page ||= { columns: 2 }; |
| next.friends.items ||= []; |
| next.sponsor ||= { methods: [], sponsors: [] }; |
| next.sponsor.methods ||= []; |
| next.sponsor.sponsors ||= []; |
| next.music ||= {}; |
| next.pio ||= { spine: {}, live2d: {} }; |
| next.pio.spine ||= {}; |
| next.pio.live2d ||= {}; |
| next.ad ||= { ad1: {}, ad2: {} }; |
| next.ad.ad1 ||= {}; |
| next.ad.ad2 ||= {}; |
| next.license ||= {}; |
| next.coverImage ||= { randomCoverImage: { enable: false, apis: [] } }; |
| next.coverImage.randomCoverImage ||= { enable: false, apis: [] }; |
| next.sakura ||= {}; |
| return next; |
| } |
|
|
| function createEmptyPost(): EditablePost { |
| return { |
| slug: "", |
| title: "", |
| published: new Date().toISOString().slice(0, 10), |
| updated: "", |
| description: "", |
| image: "", |
| tags: [], |
| category: "", |
| lang: "", |
| draft: true, |
| pinned: false, |
| comment: true, |
| author: "", |
| sourceLink: "", |
| licenseName: "", |
| licenseUrl: "", |
| body: "# 新文章\n\n从这里开始写作。", |
| extension: "md", |
| }; |
| } |
|
|
| function mapPostDraft(value: Record<string, any>): EditablePost { |
| return { |
| ...createEmptyPost(), |
| ...cloneData(value), |
| body: String(value.body ?? value.content ?? ""), |
| extension: value.extension === "mdx" ? "mdx" : "md", |
| __originalSlug: String(value.slug ?? value.__originalSlug ?? "") || undefined, |
| }; |
| }
|
|
|
| function mapSnapshotAsset(image: any): AssetRecord { |
| const reference = |
| image.sitePath || |
| (image.origin === "public" |
| ? `/${String(image.path || "").replace(/^public\ |
| : String(image.path || "").replace(/^src\/content\/posts\ |
| return { |
| root: image.origin, |
| path: |
| image.origin === "public" |
| ? String(image.path || "").replace(/^public\ |
| : String(image.path || "").replace(/^src\/content\/posts\ |
| name: image.name, |
| directory: image.directory || "", |
| size: Number(image.size || 0), |
| modifiedAt: image.updatedAt || new Date().toISOString(), |
| previewUrl: image.url, |
| reference, |
| }; |
| } |
|
|
| let sessionLoading = true; |
| let sessionRefreshing = false; |
| let sessionResolved = false; |
| let authenticated = false; |
| let configured = true; |
| let username = ""; |
| let loginSubmitting = false; |
| let activeNav: NavKey = "overview"; |
| let toast: ToastState | null = null; |
| let loginForm = { username: "", password: "" }; |
| let configs: EditableConfig = buildInitialConfig(initialSnapshot); |
| let sectionEditors: Record<JsonSectionKey, string> = { |
| site: "{}", |
| navbar: "{}", |
| sidebar: "{}", |
| friends: "{}", |
| sponsor: "{}", |
| ad: "{}", |
| comment: "{}", |
| music: "{}", |
| pio: "{}", |
| coverImage: "{}", |
| font: "{}", |
| sakura: "{}", |
| }; |
| let profileLinksJson = "[]"; |
| let postsList: PostListItem[] = initialSnapshot.posts.map((post) => ({ |
| slug: post.slug, |
| title: post.title, |
| description: post.description, |
| published: post.published, |
| draft: post.draft, |
| pinned: post.pinned, |
| })); |
| let postDraft: EditablePost = initialSnapshot.posts.length > 0 ? mapPostDraft(initialSnapshot.posts[0]) : createEmptyPost(); |
| let selectedPostSlug: string | null = initialSnapshot.posts[0]?.slug ?? null; |
| let postSearch = ""; |
| let specPages: PageRecord[] = initialSnapshot.specPages.map((page) => cloneData(page)); |
| let selectedPageSlug = initialSnapshot.specPages[0]?.slug ?? ""; |
| let pageDraftBody = initialSnapshot.specPages[0]?.body ?? ""; |
| let mediaItems: AssetRecord[] = initialSnapshot.images.map((image) => mapSnapshotAsset(image)); |
| let mediaSearch = ""; |
| let uploadRoot: "public" | "content" = "public"; |
| let uploadFolder = "uploads/posts"; |
| let uploadName = ""; |
| let uploadDataUrl = ""; |
| let buildState: BuildState = { |
| status: "idle", |
| startedAt: null, |
| finishedAt: null, |
| lastBuiltAt: null, |
| lastDurationMs: null, |
| lastError: "", |
| queueLength: 0, |
| queuedReason: "", |
| logs: [], |
| }; |
| let dashboard: DashboardState = { |
| posts: postsList.length, |
| drafts: postsList.filter((post) => post.draft).length, |
| pages: specPages.length, |
| assets: mediaItems.length, |
| tags: 0, |
| categories: 0, |
| build: buildState, |
| }; |
| let filteredPosts: PostListItem[] = postsList; |
| let filteredAssets: AssetRecord[] = mediaItems; |
| let postPreview = renderAdminMarkdown(postDraft.body || ""); |
| let pagePreview = renderAdminMarkdown(pageDraftBody || ""); |
|
|
| function syncEditors() { |
| configs = normalizeConfig(configs); |
| sectionEditors.site = prettyJson(configs.site); |
| sectionEditors.navbar = prettyJson(configs.navbar); |
| sectionEditors.sidebar = prettyJson(configs.sidebar); |
| sectionEditors.friends = prettyJson(configs.friends); |
| sectionEditors.sponsor = prettyJson(configs.sponsor); |
| sectionEditors.ad = prettyJson(configs.ad); |
| sectionEditors.comment = prettyJson(configs.comment); |
| sectionEditors.music = prettyJson(configs.music); |
| sectionEditors.pio = prettyJson(configs.pio); |
| sectionEditors.coverImage = prettyJson(configs.coverImage); |
| sectionEditors.font = prettyJson(configs.font); |
| sectionEditors.sakura = prettyJson(configs.sakura); |
| profileLinksJson = prettyJson(configs.profile.links || []); |
| } |
|
|
| syncEditors(); |
|
|
| function showToast(tone: ToastState["tone"], text: string) { |
| toast = { tone, text }; |
| window.setTimeout(() => { |
| if (toast?.text === text) toast = null; |
| }, 2600); |
| } |
|
|
| function formatDateTime(value?: string | null) { |
| if (!value) return "-"; |
| const date = new Date(value); |
| if (Number.isNaN(date.getTime())) return value; |
| return date.toLocaleString("zh-CN", { hour12: false }); |
| } |
|
|
| function formatBytes(bytes: number) { |
| if (bytes < 1024) return `${bytes} B`; |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; |
| return `${(bytes / 1024 / 1024).toFixed(2)} MB`; |
| } |
| |
| function buildLabel(status: string) { |
| switch (status) { |
| case "building": |
| return "构建中"; |
| case "success": |
| return "最近成功"; |
| case "error": |
| return "最近失败"; |
| default: |
| return status === "idle" ? "待命中" : "排队中"; |
| } |
| } |
| |
| async function api<T>(url: string, options: RequestInit = {}): Promise<T> { |
| const response = await fetch(url, { |
| credentials: "same-origin", |
| ...options, |
| headers: { |
| ...(options.body ? { "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 || data.message || `Request failed (${response.status})`); |
| } |
| return data as T; |
| } |
|
|
| function applyBuild(data: any) { |
| if (data?.build) { |
| buildState = data.build; |
| } |
| if (dashboard) { |
| dashboard = { ...dashboard, build: buildState }; |
| } |
| } |
|
|
| async function refreshSession() { |
| sessionLoading = true; |
| sessionRefreshing = sessionResolved; |
| try { |
| const data = await api<any>("/api/admin/session"); |
| authenticated = Boolean(data.authenticated); |
| configured = data.configured !== false; |
| username = String(data.username || ""); |
| if (data.build) applyBuild(data); |
| } catch (error) { |
| authenticated = false; |
| configured = true; |
| showToast( |
| "error", |
| error instanceof Error ? error.message : "后台会话检查失败", |
| ); |
| } finally { |
| sessionResolved = true; |
| sessionLoading = false; |
| sessionRefreshing = false; |
| } |
| } |
|
|
| async function loadBuild() { |
| const data = await api<BuildState>("/api/admin/build"); |
| buildState = data; |
| if (dashboard) dashboard = { ...dashboard, build: buildState }; |
| } |
|
|
| async function loadDashboard() { |
| const data = await api<DashboardState>("/api/admin/dashboard"); |
| dashboard = data; |
| buildState = data.build; |
| } |
|
|
| async function loadConfig() { |
| const data = await api<{ config: EditableConfig }>("/api/admin/config"); |
| configs = normalizeConfig(data.config); |
| syncEditors(); |
| } |
|
|
| async function loadPosts() { |
| const data = await api<{ posts: PostListItem[] }>("/api/admin/posts"); |
| postsList = data.posts || []; |
| filteredPosts = postsList; |
| } |
|
|
| async function loadPages() { |
| const data = await api<{ pages: PageRecord[] }>("/api/admin/pages"); |
| specPages = data.pages || []; |
| if (selectedPageSlug) { |
| const current = specPages.find((page) => page.slug === selectedPageSlug); |
| if (current) pageDraftBody = current.body; |
| } |
| } |
|
|
| async function loadAssets() { |
| const data = await api<{ assets: AssetRecord[] }>("/api/admin/assets"); |
| mediaItems = data.assets || []; |
| filteredAssets = mediaItems; |
| }
|
|
|
| async function loadAll() { |
| await Promise.all([loadConfig(), loadDashboard(), loadPosts(), loadPages(), loadAssets(), loadBuild()]); |
| } |
|
|
| function parseKeywords(value: string) { |
| return value.split(",").map((item) => item.trim()).filter(Boolean); |
| } |
|
|
| function setWallpaperSubtitles(value: string) { |
| configs.wallpaper.banner.homeText.subtitle = value |
| .split(/\r?\n/) |
| .map((item: string) => item.trim()) |
| .filter(Boolean); |
| } |
|
|
| function getWallpaperSubtitlesText() { |
| const subtitle = configs.wallpaper.banner.homeText.subtitle; |
| return Array.isArray(subtitle) ? subtitle.join("\n") : String(subtitle || ""); |
| } |
|
|
| function parseEditor<T>(label: string, raw: string): T | null { |
| try { |
| return JSON.parse(raw) as T; |
| } catch (error) { |
| showToast("error", `${label} 不是有效的 JSON:${error instanceof Error ? error.message : "解析失败"}`); |
| return null; |
| } |
| } |
|
|
| async function saveSection(section: string, label: string, data: unknown) { |
| const response = await api<any>(`/api/admin/config/${section}`, { |
| method: "PUT", |
| body: JSON.stringify(data), |
| }); |
| applyBuild(response); |
| showToast("success", `${label}已保存并加入构建队列`); |
| } |
|
|
| async function saveSite() { |
| configs.site.font = cloneData(configs.font); |
| await saveSection("site", "站点设置", configs.site); |
| syncEditors(); |
| } |
|
|
| async function saveProfile() { |
| const parsed = parseEditor<any[]>("资料链接", profileLinksJson); |
| if (!parsed) return; |
| configs.profile.links = parsed; |
| await saveSection("profile", "个人资料", configs.profile); |
| syncEditors(); |
| } |
|
|
| async function saveWallpaper() { |
| await saveSection("wallpaper", "壁纸设置", configs.wallpaper); |
| } |
|
|
| async function saveAnnouncement() { |
| await saveSection("announcement", "公告设置", configs.announcement); |
| } |
|
|
| async function saveFooter() { |
| await saveSection("footer", "页脚设置", configs.footer); |
| } |
|
|
| async function saveJsonSection(section: JsonSectionKey, label: string) { |
| const parsed = parseEditor<Record<string, any>>(label, sectionEditors[section]); |
| if (!parsed) return; |
| if (section === "font") { |
| configs.font = parsed; |
| configs.site.font = cloneData(parsed); |
| } else { |
| (configs as Record<string, any>)[section] = parsed; |
| } |
| await saveSection(section, label, parsed); |
| syncEditors(); |
| } |
|
|
| function applyLoginSearchFeedback() { |
| const currentUrl = new URL(window.location.href); |
| const loginStatus = currentUrl.searchParams.get("login"); |
| if (loginStatus === "invalid") { |
| showToast("error", "账号或密码错误,请重新输入"); |
| } else if (loginStatus === "disabled") { |
| showToast("error", "后台尚未启用,请先配置 ADMIN 和 PASSWORD"); |
| } |
| if (!loginStatus) { |
| return; |
| } |
| currentUrl.searchParams.delete("login"); |
| const nextPath = currentUrl.pathname + currentUrl.search + currentUrl.hash; |
| window.history.replaceState({}, "", nextPath || currentUrl.pathname); |
| } |
|
|
| async function handleLogin(event?: SubmitEvent) { |
| event?.preventDefault(); |
| loginSubmitting = true; |
| try { |
| const response = await api<any>("/api/admin/login", { |
| method: "POST", |
| body: JSON.stringify(loginForm), |
| }); |
| authenticated = Boolean(response.authenticated); |
| configured = response.configured !== false; |
| sessionResolved = true; |
| username = String(response.username || loginForm.username || ""); |
| loginForm.password = ""; |
| applyBuild(response); |
| await loadAll(); |
| showToast("success", "登录成功"); |
| } catch (error) { |
| showToast("error", error instanceof Error ? error.message : "\u767b\u5f55\u5931\u8d25"); |
| } finally { |
| loginSubmitting = false; |
| } |
| } |
|
|
| async function handleLogout() { |
| await api("/api/admin/logout", { method: "POST", body: JSON.stringify({}) }); |
| authenticated = false; |
| username = ""; |
| sessionResolved = true; |
| loginForm.password = ""; |
| showToast("info", "已退出后台"); |
| } |
|
|
| function createNewPost() { |
| selectedPostSlug = null; |
| postDraft = createEmptyPost(); |
| activeNav = "posts"; |
| } |
|
|
| async function openPost(slug: string) { |
| const data = await api<Record<string, any>>(`/api/admin/posts/${encodeURIComponent(slug)}`); |
| selectedPostSlug = slug; |
| postDraft = mapPostDraft(data); |
| } |
|
|
| function duplicatePost() { |
| postDraft = { |
| ...cloneData(postDraft), |
| slug: `${postDraft.slug || "post"}-copy`, |
| title: `${postDraft.title || "未命名文章"}(副本)`, |
| __originalSlug: undefined, |
| }; |
| selectedPostSlug = null; |
| showToast("info", "已创建文章副本,请检查 slug 后保存"); |
| } |
|
|
| async function savePost(publish = false) { |
| const payload = { ...postDraft, body: postDraft.body, publish }; |
| const url = selectedPostSlug |
| ? `/api/admin/posts/${encodeURIComponent(selectedPostSlug)}` |
| : "/api/admin/posts"; |
| const method = selectedPostSlug ? "PUT" : "POST"; |
| const response = await api<any>(url, { |
| method, |
| body: JSON.stringify(payload), |
| }); |
| selectedPostSlug = response.post.slug; |
| postDraft = mapPostDraft(response.post); |
| applyBuild(response); |
| await Promise.all([loadPosts(), loadDashboard()]); |
| showToast("success", publish ? "文章已保存并加入构建队列" : "文章已保存"); |
| } |
|
|
| async function removePost() { |
| if (!selectedPostSlug) return; |
| if (!window.confirm(`确定删除文章 ${selectedPostSlug} 吗?`)) return; |
| const response = await api<any>(`/api/admin/posts/${encodeURIComponent(selectedPostSlug)}`, { |
| method: "DELETE", |
| }); |
| applyBuild(response); |
| createNewPost(); |
| await Promise.all([loadPosts(), loadDashboard()]); |
| showToast("success", "文章已删除"); |
| } |
|
|
| function selectPage(page: PageRecord) { |
| selectedPageSlug = page.slug; |
| pageDraftBody = page.body; |
| } |
|
|
| function createNewPage() { |
| const slug = window.prompt("请输入新页面的 slug", "notes"); |
| if (!slug) return; |
| selectedPageSlug = slug.trim(); |
| pageDraftBody = "# 新页面\n\n开始编辑这里的内容。"; |
| activeNav = "pages"; |
| } |
|
|
| async function savePage(publish = false) { |
| if (!selectedPageSlug) { |
| showToast("error", "请先选择或创建页面"); |
| return; |
| } |
| const response = await api<any>(`/api/admin/pages/${encodeURIComponent(selectedPageSlug)}`, { |
| method: "PUT", |
| body: JSON.stringify({ body: pageDraftBody, publish }), |
| }); |
| pageDraftBody = response.page.body; |
| selectedPageSlug = response.page.slug; |
| applyBuild(response); |
| await Promise.all([loadPages(), loadDashboard()]); |
| showToast("success", publish ? "页面已保存并加入构建队列" : "页面已保存"); |
| } |
|
|
| function chooseUploadFile(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) { |
| showToast("error", "请先选择图片文件"); |
| return; |
| } |
| const response = await api<any>("/api/admin/assets", { |
| method: "POST", |
| body: JSON.stringify({ |
| root: uploadRoot, |
| name: uploadName, |
| folder: uploadFolder, |
| dataUrl: uploadDataUrl, |
| }), |
| }); |
| applyBuild(response); |
| uploadName = ""; |
| uploadDataUrl = ""; |
| await Promise.all([loadAssets(), loadDashboard()]); |
| showToast("success", "图片已上传并加入构建队列"); |
| } |
|
|
| async function removeAsset(asset: AssetRecord) { |
| if (!window.confirm(`确定删除图片 ${asset.name} 吗?`)) return; |
| const response = await api<any>(`/api/admin/assets?root=${encodeURIComponent(asset.root)}&path=${encodeURIComponent(asset.path)}`, { |
| method: "DELETE", |
| }); |
| applyBuild(response); |
| await Promise.all([loadAssets(), loadDashboard()]); |
| showToast("success", "图片已删除"); |
| } |
|
|
| async function rebuildSite() { |
| const response = await api<any>("/api/admin/rebuild", { method: "POST", body: JSON.stringify({}) }); |
| applyBuild(response); |
| showToast("info", response.queued ? "构建已排队" : "已开始重新构建站点"); |
| } |
|
|
| async function copyText(text: string, label: string) { |
| try { |
| await navigator.clipboard.writeText(text); |
| showToast("success", `${label}已复制`); |
| } catch { |
| showToast("error", "复制失败,请手动复制"); |
| } |
| } |
|
|
| $: filteredPosts = postsList.filter((post) => { |
| const keyword = postSearch.trim().toLowerCase(); |
| if (!keyword) return true; |
| return `${post.title} ${post.slug} ${post.description || ""}`.toLowerCase().includes(keyword); |
| }); |
| $: filteredAssets = mediaItems.filter((asset) => { |
| const keyword = mediaSearch.trim().toLowerCase(); |
| if (!keyword) return true; |
| return `${asset.name} ${asset.path} ${asset.directory} ${asset.reference}`.toLowerCase().includes(keyword); |
| }); |
| $: postPreview = renderAdminMarkdown(postDraft.body || ""); |
| $: pagePreview = renderAdminMarkdown(pageDraftBody || ""); |
|
|
| onMount(() => { |
| const timer = window.setInterval(async () => { |
| if (!authenticated) return; |
| try { |
| await loadBuild(); |
| } catch { |
| |
| } |
| }, 3000); |
|
|
| (async () => { |
| applyLoginSearchFeedback(); |
| await refreshSession(); |
| if (authenticated) { |
| await loadAll(); |
| } |
| })(); |
|
|
| return () => window.clearInterval(timer); |
| }); |
| </script> |
|
|
| <div class="admin-shell"> |
| {#if toast} |
| <div class={`admin-toast ${toast.tone}`}>{toast.text}</div> |
| {/if} |
|
|
| {#if !sessionResolved} |
| <section class="admin-auth-shell admin-auth-shell-pending"> |
| <div class="card-base admin-panel admin-auth-copy"> |
| <div class="admin-login-badge">Firefly Admin</div> |
| <div class="admin-auth-intro"> |
| <h2>正在连接后台</h2> |
| <p>正在检查管理员会话与后台启用状态,确认完成后会显示登录入口或直接进入内容管理台。</p> |
| </div> |
| <div class="admin-loading-stack" aria-hidden="true"> |
| <div class="admin-loading-line"></div> |
| <div class="admin-loading-line short"></div> |
| <div class="admin-loading-line medium"></div> |
| </div> |
| </div> |
| <div class="card-base admin-login admin-login-stage admin-login-pending"> |
| <div class="admin-login-badge">会话检查中</div> |
| <h2>后台准备中</h2> |
| <p class="admin-login-status">正在从当前 Space 读取后台环境变量并确认登录状态。</p> |
| </div> |
| </section> |
| {:else if !configured} |
| <div class="card-base admin-panel admin-empty"> |
| <h2>后台尚未启用</h2> |
| <p>请在 Hugging Face Space 中配置环境变量 <code>ADMIN</code> 和 <code>PASSWORD</code> 后再访问管理页。</p> |
| </div> |
| {:else if !authenticated} |
| <section class="admin-auth-shell"> |
| <div class="card-base admin-panel admin-auth-copy"> |
| <div class="admin-login-badge">Firefly Admin</div> |
| <div class="admin-auth-intro"> |
| <h2>后台管理入口</h2> |
| <p>在这里统一管理博客标题、介绍、导航、侧栏、文章、图片素材与构建状态,界面风格与前台博客保持一致。</p> |
| </div> |
| <div class="admin-auth-facts"> |
| <div class="admin-auth-fact"> |
| <strong>账号来源</strong> |
| <span><code>ADMIN</code></span> |
| </div> |
| <div class="admin-auth-fact"> |
| <strong>密码来源</strong> |
| <span><code>PASSWORD</code></span> |
| </div> |
| <div class="admin-auth-fact"> |
| <strong>内容存储</strong> |
| <span>当前版本保存到容器本地目录</span> |
| </div> |
| </div> |
| </div> |
| <form class="card-base admin-login admin-login-stage" method="post" action={`${entryPath}/login`} on:submit|preventDefault={handleLogin}> |
| <div class="admin-login-badge">管理员登录</div> |
| <h2>输入管理员凭证</h2> |
| <p>登录成功后将进入内容工作台,可继续管理站点配置、文章与图片资源。</p> |
| <label class="admin-field"> |
| <span>管理员账号</span> |
| <input class="admin-input" name="username" autocomplete="username" autocapitalize="none" placeholder="管理员账号" bind:value={loginForm.username} required /> |
| </label> |
| <label class="admin-field"> |
| <span>登录密码</span> |
| <input class="admin-input" name="password" autocomplete="current-password" type="password" placeholder="密码" bind:value={loginForm.password} required /> |
| </label> |
| <button class="btn-regular admin-button primary" type="submit" disabled={loginSubmitting}> |
| {loginSubmitting ? "登录中..." : "登录后台"} |
| </button> |
| <p class="admin-login-status">后台凭证直接读取自 Hugging Face Space 环境变量,不写入前端代码。</p> |
| {#if sessionRefreshing || sessionLoading} |
| <p class="admin-login-status">正在刷新当前后台会话...</p> |
| {/if} |
| </form> |
| </section> |
| {:else} |
| <div class="admin-workbench"> |
| <aside class="admin-sidebar"> |
| <div class="card-base admin-panel admin-sidebar-card"> |
| <div class="admin-login-badge">Firefly Admin</div> |
| <h3>内容后台</h3> |
| <p>当前管理员:<strong>{username}</strong></p> |
| <div class={`build-pill state-${buildState.status}`}>{buildLabel(buildState.status)}</div> |
| <div class="admin-quick-actions"> |
| <button class="btn-regular admin-button primary" on:click={rebuildSite}>重新构建</button> |
| <button class="btn-plain admin-button ghost" on:click={handleLogout}>退出登录</button> |
| </div> |
| </div>
|
| <div class="card-base admin-panel admin-sidebar-card nav-card"> |
| {#each navItems as item} |
| <button class:active={activeNav === item.key} class="btn-plain admin-nav-button" on:click={() => (activeNav = item.key)}> |
| <span>{item.label}</span> |
| <small>{item.hint}</small> |
| </button> |
| {/each} |
| </div> |
| </aside> |
|
|
| <section class="admin-main"> |
| {#if activeNav === "overview"} |
| <div class="admin-grid metrics-grid"> |
| {#each [["文章", dashboard.posts], ["草稿", dashboard.drafts], ["页面", dashboard.pages], ["图片", dashboard.assets], ["标签", dashboard.tags], ["分类", dashboard.categories]] as [label, value]} |
| <div class="card-base admin-panel metric-card"> |
| <span>{label}</span> |
| <strong>{value}</strong> |
| </div> |
| {/each} |
| </div> |
| <div class="admin-grid two-col"> |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>快捷操作</h3></div> |
| <div class="admin-quick-actions wrap"> |
| <button class="btn-regular admin-button primary" on:click={createNewPost}>新建文章</button> |
| <button class="btn-plain admin-button ghost" on:click={createNewPage}>新建页面</button> |
| <button class="btn-plain admin-button ghost" on:click={() => (activeNav = "media")}>上传图片</button> |
| <button class="btn-plain admin-button ghost" on:click={() => loadAll()}>刷新数据</button> |
| </div> |
| <div class="overview-list"> |
| {#each postsList.slice(0, 6) as post} |
| <button class="btn-plain overview-item" on:click={() => openPost(post.slug)}> |
| <strong>{post.title || post.slug}</strong> |
| <small>{post.slug}</small> |
| </button> |
| {/each} |
| </div> |
| </div> |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>最近构建</h3></div> |
| <p>状态:<strong>{buildLabel(buildState.status)}</strong></p> |
| <p>开始时间:{formatDateTime(buildState.startedAt)}</p> |
| <p>完成时间:{formatDateTime(buildState.finishedAt)}</p> |
| <p>最近成功:{formatDateTime(buildState.lastBuiltAt)}</p> |
| <p>排队数量:{buildState.queueLength || 0}</p> |
| {#if buildState.lastError}<p class="danger-text">错误:{buildState.lastError}</p>{/if} |
| <pre class="admin-code build-log">{buildState.logs.join("\n") || "这里会显示构建日志。"}</pre> |
| </div> |
| </div> |
| {/if} |
|
|
| {#if activeNav === "site"} |
| <div class="admin-grid two-col"> |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>基础信息</h3><button class="btn-regular admin-button primary" on:click={saveSite}>保存站点</button></div> |
| <div class="field-grid"> |
| <input class="admin-input" placeholder="站点标题" bind:value={configs.site.title} /> |
| <input class="admin-input" placeholder="站点副标题" bind:value={configs.site.subtitle} /> |
| <input class="admin-input" placeholder="站点地址" bind:value={configs.site.site_url} /> |
| <input class="admin-input" placeholder="语言,例如 zh_CN" bind:value={configs.site.lang} /> |
| <input class="admin-input field-span-2" placeholder="站点描述" bind:value={configs.site.description} /> |
| <input class="admin-input field-span-2" placeholder="关键词,逗号分隔" value={Array.isArray(configs.site.keywords) ? configs.site.keywords.join(', ') : ''} on:input={(event) => (configs.site.keywords = parseKeywords((event.currentTarget as HTMLInputElement).value))} /> |
| <input class="admin-input" placeholder="站点开始日期 YYYY-MM-DD" bind:value={configs.site.siteStartDate} /> |
| <input class="admin-input" placeholder="时区,例如 Asia/Shanghai" bind:value={configs.site.timezone} /> |
| </div> |
| <div class="field-grid compact-grid"> |
| <label>主题色 Hue<input class="admin-input" type="number" bind:value={configs.site.themeColor.hue} /></label> |
| <label>默认模式<select class="admin-input" bind:value={configs.site.themeColor.defaultMode}><option value="system">system</option><option value="light">light</option><option value="dark">dark</option></select></label> |
| <label>每页文章数<input class="admin-input" type="number" bind:value={configs.site.pagination.postsPerPage} /></label> |
| <label>过期阈值<input class="admin-input" type="number" bind:value={configs.site.outdatedThreshold} /></label> |
| </div> |
| <div class="check-row"> |
| <label><input type="checkbox" bind:checked={configs.site.themeColor.fixed} /> 固定主题色</label> |
| <label><input type="checkbox" bind:checked={configs.site.card.border} /> 启用卡片边框</label> |
| <label><input type="checkbox" bind:checked={configs.site.showLastModified} /> 显示最后修改</label> |
| <label><input type="checkbox" bind:checked={configs.site.sharePoster} /> 显示分享海报</label> |
| <label><input type="checkbox" bind:checked={configs.site.pages.sponsor} /> 启用赞助页</label> |
| <label><input type="checkbox" bind:checked={configs.site.pages.guestbook} /> 启用留言页</label> |
| <label><input type="checkbox" bind:checked={configs.site.pages.bangumi} /> 启用 Bangumi 页</label> |
| </div> |
| </div> |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>站点 JSON</h3><button class="btn-plain admin-button ghost" on:click={() => saveJsonSection('site', '站点 JSON')}>按 JSON 保存</button></div> |
| <textarea class="admin-code section-editor" bind:value={sectionEditors.site}></textarea> |
| </div> |
| </div> |
| {/if} |
|
|
| {#if activeNav === "appearance"} |
| <div class="admin-grid two-col"> |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>个人资料</h3><button class="btn-regular admin-button primary" on:click={saveProfile}>保存资料</button></div> |
| <input class="admin-input" placeholder="头像路径" bind:value={configs.profile.avatar} /> |
| <input class="admin-input" placeholder="名称" bind:value={configs.profile.name} /> |
| <textarea class="admin-code small-editor" placeholder="个人简介" bind:value={configs.profile.bio}></textarea> |
| <div class="section-subtitle">社交链接 JSON</div> |
| <textarea class="admin-code small-editor" bind:value={profileLinksJson}></textarea> |
| </div> |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>公告与页脚</h3><button class="btn-regular admin-button primary" on:click={saveAnnouncement}>保存公告</button></div> |
| <input class="admin-input" placeholder="公告标题" bind:value={configs.announcement.title} /> |
| <textarea class="admin-code small-editor" placeholder="公告内容" bind:value={configs.announcement.content}></textarea> |
| <input class="admin-input" placeholder="公告链接文字" bind:value={configs.announcement.link.text} /> |
| <input class="admin-input" placeholder="公告链接地址" bind:value={configs.announcement.link.url} /> |
| <div class="check-row"><label><input type="checkbox" bind:checked={configs.announcement.closable} /> 可关闭</label><label><input type="checkbox" bind:checked={configs.announcement.link.enable} /> 启用链接</label></div> |
| <div class="section-head inline-head"><h4>页脚 HTML</h4><button class="btn-plain admin-button ghost" on:click={saveFooter}>保存页脚</button></div> |
| <textarea class="admin-code section-editor" bind:value={configs.footer.customHtml}></textarea> |
| </div> |
| <div class="card-base admin-panel full-span"> |
| <div class="section-head"><h3>壁纸与首页文案</h3><button class="btn-regular admin-button primary" on:click={saveWallpaper}>保存壁纸</button></div> |
| <div class="field-grid"> |
| <select class="admin-input" bind:value={configs.wallpaper.mode}><option value="banner">banner</option><option value="overlay">overlay</option><option value="none">none</option></select> |
| <label class="checkbox-inline"><input type="checkbox" bind:checked={configs.wallpaper.switchable} /> 允许切换壁纸模式</label> |
| <input class="admin-input" placeholder="桌面壁纸路径" bind:value={configs.wallpaper.src.desktop} /> |
| <input class="admin-input" placeholder="移动壁纸路径" bind:value={configs.wallpaper.src.mobile} /> |
| <input class="admin-input" placeholder="首页标题" bind:value={configs.wallpaper.banner.homeText.title} /> |
| <input class="admin-input" placeholder="标题尺寸,例如 3.8rem" bind:value={configs.wallpaper.banner.homeText.titleSize} /> |
| <input class="admin-input" placeholder="副标题尺寸,例如 1.4rem" bind:value={configs.wallpaper.banner.homeText.subtitleSize} /> |
| <input class="admin-input" placeholder="Banner 位置,例如 center" bind:value={configs.wallpaper.banner.position} /> |
| </div> |
| <div class="check-row"><label><input type="checkbox" bind:checked={configs.wallpaper.banner.homeText.enable} /> 启用首页文案</label><label><input type="checkbox" bind:checked={configs.wallpaper.banner.homeText.typewriter.enable} /> 启用打字机</label></div> |
| <textarea class="admin-code section-editor" placeholder="每行一个副标题" value={getWallpaperSubtitlesText()} on:input={(event) => setWallpaperSubtitles((event.currentTarget as HTMLTextAreaElement).value)}></textarea> |
| </div> |
| </div> |
| {/if}
|
|
|
| {#if activeNav === "navigation"} |
| <div class="admin-grid two-col"> |
| <div class="card-base admin-panel"><div class="section-head"><h3>导航栏 JSON</h3><button class="btn-regular admin-button primary" on:click={() => saveJsonSection('navbar', '导航栏配置')}>保存导航</button></div><textarea class="admin-code section-editor" bind:value={sectionEditors.navbar}></textarea></div> |
| <div class="card-base admin-panel"><div class="section-head"><h3>侧栏 JSON</h3><button class="btn-regular admin-button primary" on:click={() => saveJsonSection('sidebar', '侧栏配置')}>保存侧栏</button></div><textarea class="admin-code section-editor" bind:value={sectionEditors.sidebar}></textarea></div> |
| </div> |
| {/if} |
|
|
| {#if activeNav === "posts"} |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>文章管理</h3><div class="admin-quick-actions wrap"><button class="btn-plain admin-button ghost" on:click={createNewPost}>新建</button><button class="btn-plain admin-button ghost" on:click={duplicatePost}>复制</button><button class="btn-plain admin-button ghost" on:click={() => savePost(false)}>保存</button><button class="btn-regular admin-button primary" on:click={() => savePost(true)}>保存并发布</button><button class="btn-plain admin-button ghost danger" on:click={removePost}>删除</button></div></div> |
| <div class="editor-layout posts-layout"> |
| <div class="editor-list"><input class="admin-input" placeholder="搜索文章" bind:value={postSearch} />{#each filteredPosts as post}<button class:active={selectedPostSlug === post.slug} class="btn-plain editor-item" on:click={() => openPost(post.slug)}><strong>{post.title || post.slug}</strong><small>{post.slug}</small></button>{/each}</div> |
| <div class="post-form"><div class="field-grid"><input class="admin-input" placeholder="标题" bind:value={postDraft.title} /><input class="admin-input" placeholder="slug" bind:value={postDraft.slug} /><input class="admin-input" placeholder="发布日期 YYYY-MM-DD" bind:value={postDraft.published} /><input class="admin-input" placeholder="更新日期 YYYY-MM-DD" bind:value={postDraft.updated} /><input class="admin-input" placeholder="封面路径" bind:value={postDraft.image} /><input class="admin-input" placeholder="分类" bind:value={postDraft.category} /><input class="admin-input" placeholder="标签,逗号分隔" value={postDraft.tags.join(', ')} on:input={(event) => (postDraft.tags = parseKeywords((event.currentTarget as HTMLInputElement).value))} /><input class="admin-input" placeholder="语言" bind:value={postDraft.lang} /><input class="admin-input" placeholder="作者" bind:value={postDraft.author} /><input class="admin-input" placeholder="来源链接" bind:value={postDraft.sourceLink} /><input class="admin-input" placeholder="版权名称" bind:value={postDraft.licenseName} /><input class="admin-input" placeholder="版权链接" bind:value={postDraft.licenseUrl} /><textarea class="admin-code small-editor field-span-2" placeholder="摘要" bind:value={postDraft.description}></textarea></div><div class="check-row"><label><input type="checkbox" bind:checked={postDraft.draft} /> 草稿</label><label><input type="checkbox" bind:checked={postDraft.pinned} /> 置顶</label><label><input type="checkbox" bind:checked={postDraft.comment} /> 启用评论</label><label><input type="checkbox" checked={postDraft.extension === "mdx"} on:change={(event) => (postDraft.extension = (event.currentTarget as HTMLInputElement).checked ? "mdx" : "md")} /> 使用 MDX</label></div><textarea class="admin-code editor-input" bind:value={postDraft.body}></textarea></div> |
| <div class="editor-preview custom-md prose dark:prose-invert max-w-none">{@html postPreview}</div> |
| </div> |
| </div> |
| {/if} |
|
|
| {#if activeNav === "pages"} |
| <div class="card-base admin-panel"> |
| <div class="section-head"><h3>固定页面</h3><div class="admin-quick-actions wrap"><button class="btn-plain admin-button ghost" on:click={createNewPage}>新建页面</button><button class="btn-plain admin-button ghost" on:click={() => savePage(false)}>保存</button><button class="btn-regular admin-button primary" on:click={() => savePage(true)}>保存并发布</button></div></div> |
| <div class="editor-layout"><div class="editor-list">{#each specPages as page}<button class:active={selectedPageSlug === page.slug} class="btn-plain editor-item" on:click={() => selectPage(page)}><strong>{page.slug}</strong><small>{page.filePath}</small></button>{/each}</div><textarea class="admin-code editor-input" bind:value={pageDraftBody}></textarea><div class="editor-preview custom-md prose dark:prose-invert max-w-none">{@html pagePreview}</div></div> |
| </div> |
| {/if} |
|
|
| {#if activeNav === "media"} |
| <div class="card-base admin-panel"><div class="section-head"><h3>图片与素材</h3><button class="btn-regular admin-button primary" on:click={uploadAsset}>上传图片</button></div><div class="field-grid"><select class="admin-input" bind:value={uploadRoot}><option value="public">public</option><option value="content">content/posts</option></select><input class="admin-input" placeholder="目录,例如 uploads/posts 或 covers" bind:value={uploadFolder} /><input class="admin-input" type="file" accept="image/*" on:change={chooseUploadFile} /><input class="admin-input" placeholder="搜索图片" bind:value={mediaSearch} /></div><div class="asset-grid">{#each filteredAssets as asset}<div class="card-base asset-card">{#if asset.previewUrl}<img src={asset.previewUrl} alt={asset.name} />{/if}<strong>{asset.name}</strong><small>{asset.root} / {asset.path}</small><small>引用:{asset.reference}</small><small>{formatBytes(asset.size)} · {formatDateTime(asset.modifiedAt)}</small><div class="asset-actions"><button class="btn-plain admin-button ghost" on:click={() => copyText(asset.reference, '引用路径')}>复制引用</button><button class="btn-plain admin-button ghost" on:click={() => copyText(asset.previewUrl, '预览链接')}>复制预览</button><button class="btn-plain admin-button ghost danger" on:click={() => removeAsset(asset)}>删除</button></div></div>{/each}</div></div> |
| {/if} |
|
|
| {#if activeNav === "advanced"} |
| <div class="admin-grid two-col">{#each [['friends','友链配置'],['sponsor','赞助配置'],['ad','广告配置'],['comment','评论配置'],['music','音乐配置'],['pio','看板娘配置'],['coverImage','封面图配置'],['font','字体配置'],['sakura','樱花特效']] as [key, label]}<div class="card-base admin-panel"><div class="section-head"><h3>{label}</h3><button class="btn-regular admin-button primary" on:click={() => saveJsonSection(key as JsonSectionKey, label)}>保存</button></div><textarea class="admin-code section-editor" bind:value={sectionEditors[key as JsonSectionKey]}></textarea></div>{/each}</div> |
| {/if} |
|
|
| {#if activeNav === "build"} |
| <div class="card-base admin-panel"><div class="section-head"><h3>构建与发布</h3><button class="btn-regular admin-button primary" on:click={rebuildSite}>手动构建</button></div><div class="build-meta"><p>状态:<strong>{buildLabel(buildState.status)}</strong></p><p>开始:{formatDateTime(buildState.startedAt)}</p><p>完成:{formatDateTime(buildState.finishedAt)}</p><p>最近成功:{formatDateTime(buildState.lastBuiltAt)}</p><p>队列:{buildState.queueLength || 0}</p>{#if buildState.queuedReason}<p>排队原因:{buildState.queuedReason}</p>{/if}{#if buildState.lastError}<p class="danger-text">错误:{buildState.lastError}</p>{/if}</div><pre class="admin-code build-log">{buildState.logs.join("\n") || "这里会显示完整构建日志。"}</pre></div> |
| {/if} |
| </section> |
| </div> |
| {/if} |
| </div> |
|
|
| <style> |
| .admin-shell { |
| display: flex; |
| flex-direction: column; |
| gap: 1rem; |
| } |
| .admin-workbench { |
| display: grid; |
| grid-template-columns: 18rem minmax(0, 1fr); |
| gap: 1rem; |
| } |
| .admin-sidebar, .admin-main { |
| display: flex; |
| flex-direction: column; |
| gap: 1rem; |
| } |
| .admin-panel, .admin-login { |
| padding: 1.2rem; |
| } |
| .admin-auth-shell { |
| display: grid; |
| grid-template-columns: minmax(0, 1.15fr) minmax(22rem, 0.85fr); |
| gap: 1rem; |
| align-items: stretch; |
| } |
| .admin-auth-copy { |
| position: relative; |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| justify-content: space-between; |
| gap: 1.4rem; |
| min-height: 22rem; |
| background: |
| radial-gradient(circle at top right, rgb(255 255 255 / 0.18), transparent 32%), |
| linear-gradient(145deg, color-mix(in oklch, var(--card-bg) 78%, white 22%), color-mix(in oklch, var(--card-bg) 92%, var(--btn-card-bg-hover) 8%)); |
| border: 1px solid color-mix(in oklch, var(--line-divider) 72%, white 28%); |
| box-shadow: 0 28px 80px rgb(8 20 28 / 0.12); |
| } |
| .admin-auth-intro { |
| display: flex; |
| flex-direction: column; |
| gap: 0.75rem; |
| } |
| .admin-auth-intro h2 { |
| margin: 0; |
| font-size: clamp(2rem, 4vw, 3rem); |
| line-height: 1.08; |
| } |
| .admin-auth-intro p { |
| max-width: 40rem; |
| } |
| .admin-auth-facts { |
| display: grid; |
| grid-template-columns: repeat(3, minmax(0, 1fr)); |
| gap: 0.8rem; |
| } |
| .admin-auth-fact { |
| display: flex; |
| flex-direction: column; |
| gap: 0.35rem; |
| padding: 0.9rem; |
| border-radius: 1rem; |
| border: 1px solid color-mix(in oklch, var(--line-divider) 74%, white 26%); |
| background: color-mix(in oklch, var(--card-bg) 82%, white 18%); |
| } |
| .admin-auth-fact strong { |
| font-size: 0.95rem; |
| } |
| .admin-auth-fact span { |
| font-size: 0.92rem; |
| color: rgba(0,0,0,.72); |
| } |
| :root.dark .admin-auth-fact span { |
| color: rgba(255,255,255,.72); |
| } |
| .admin-login { |
| max-width: 30rem; |
| margin: 0 auto; |
| display: flex; |
| flex-direction: column; |
| gap: 0.9rem; |
| } |
| .admin-auth-shell .admin-login { |
| max-width: none; |
| width: 100%; |
| margin: 0; |
| justify-content: center; |
| } |
| .admin-field { |
| display: flex; |
| flex-direction: column; |
| gap: 0.4rem; |
| } |
| .admin-field > span { |
| font-size: 0.88rem; |
| font-weight: 700; |
| } |
| .admin-login-pending { |
| min-height: 22rem; |
| justify-content: center; |
| } |
| .admin-loading-stack { |
| display: flex; |
| flex-direction: column; |
| gap: 0.75rem; |
| } |
| .admin-loading-line { |
| height: 0.9rem; |
| border-radius: 999px; |
| background: linear-gradient(90deg, rgb(255 255 255 / 0.28), rgb(255 255 255 / 0.62), rgb(255 255 255 / 0.22)); |
| background-size: 200% 100%; |
| animation: adminPulse 1.8s ease-in-out infinite; |
| } |
| .admin-loading-line.short { |
| width: 48%; |
| } |
| .admin-loading-line.medium { |
| width: 70%; |
| } |
| .admin-login-stage { |
| position: relative; |
| z-index: 6; |
| border: 1px solid color-mix(in oklch, var(--line-divider) 68%, white 32%); |
| background: color-mix(in oklch, var(--card-bg) 86%, white 14%); |
| backdrop-filter: blur(18px); |
| box-shadow: 0 28px 80px rgb(8 20 28 / 0.18); |
| } |
| .admin-login-badge, .build-pill { |
| display: inline-flex; |
| align-items: center; |
| gap: 0.4rem; |
| padding: 0.35rem 0.75rem; |
| border-radius: 999px; |
| background: color-mix(in oklch, var(--card-bg) 68%, var(--btn-regular-bg-hover) 32%); |
| font-size: 0.82rem; |
| font-weight: 700; |
| width: fit-content; |
| } |
| .build-pill { margin-top: 0.5rem; } |
| .state-building, .state-success { color: var(--primary); } |
| .state-error { color: oklch(0.62 0.2 25); } |
| .admin-toast { |
| padding: 0.85rem 1rem; |
| border-radius: 1rem; |
| border: 1px solid var(--line-divider); |
| } |
| .admin-toast.success { background: color-mix(in oklch, var(--primary) 14%, var(--card-bg) 86%); } |
| .admin-toast.error { background: color-mix(in oklch, oklch(0.68 0.18 25) 16%, var(--card-bg) 84%); } |
| .admin-toast.info { background: color-mix(in oklch, var(--btn-regular-bg-hover) 35%, var(--card-bg) 65%); } |
| .admin-sidebar-card h3, .admin-panel h3, .admin-panel h4, .admin-login h2 { margin: 0; } |
| .admin-sidebar-card p, .admin-panel p, .admin-login p, .admin-login-status { margin: 0; color: rgba(0,0,0,.72); } |
| :root.dark .admin-sidebar-card p, :root.dark .admin-panel p, :root.dark .admin-login p, :root.dark .admin-login-status { color: rgba(255,255,255,.72); } |
| .admin-quick-actions { display: flex; gap: 0.6rem; align-items: center; } |
| .admin-quick-actions.wrap { flex-wrap: wrap; } |
| .admin-button { min-height: 2.7rem; padding: 0.65rem 1rem; border-radius: 0.95rem; } |
| .admin-button[disabled] { opacity: 0.7; cursor: wait; } |
| .admin-button.ghost { border: 1px solid var(--line-divider); } |
| .admin-button.danger { color: oklch(0.62 0.2 25); } |
| .admin-nav-button, .editor-item, .overview-item { |
| width: 100%; |
| padding: 0.85rem 0.95rem; |
| border-radius: 0.95rem; |
| justify-content: flex-start; |
| align-items: flex-start; |
| text-align: left; |
| display: flex; |
| flex-direction: column; |
| gap: 0.15rem; |
| } |
| .admin-nav-button.active, .editor-item.active { background: var(--btn-regular-bg-hover); color: var(--primary); } |
| .admin-nav-button small, .editor-item small, .overview-item small { color: rgba(0,0,0,.55); } |
| :root.dark .admin-nav-button small, :root.dark .editor-item small, :root.dark .overview-item small { color: rgba(255,255,255,.55); } |
| .admin-grid { display: grid; gap: 1rem; } |
| .metrics-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } |
| .two-col { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| .metric-card { display: flex; flex-direction: column; gap: 0.45rem; } |
| .metric-card strong { font-size: 1.8rem; } |
| .field-grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| .field-grid.compact-grid { margin-top: 0.8rem; } |
| .field-span-2, .full-span { grid-column: 1 / -1; } |
| .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: 0.95rem; |
| padding: 0.8rem 0.95rem; |
| color: inherit; |
| } |
| .admin-code { font-family: "JetBrains Mono Variable", ui-monospace, Consolas, monospace; line-height: 1.6; resize: vertical; } |
| .small-editor { min-height: 7rem; } |
| .section-editor { min-height: 18rem; } |
| .editor-layout { display: grid; grid-template-columns: 15rem minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; } |
| .posts-layout { grid-template-columns: 16rem minmax(0, 1.1fr) minmax(0, 0.9fr); } |
| .editor-list, .post-form, .overview-list { display: flex; flex-direction: column; gap: 0.7rem; } |
| .editor-input, .editor-preview { min-height: 28rem; } |
| .editor-preview { overflow: auto; padding: 1rem; border: 1px solid var(--line-divider); border-radius: 1rem; background: color-mix(in oklch, var(--card-bg) 92%, var(--btn-card-bg-hover) 8%); } |
| .asset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; } |
| .asset-card { padding: 0.9rem; display: flex; flex-direction: column; gap: 0.45rem; } |
| .asset-card img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 0.85rem; } |
| .asset-actions, .check-row, .section-head, .inline-head, .build-meta { display: flex; gap: 0.75rem; } |
| .asset-actions, .check-row, .build-meta { flex-wrap: wrap; } |
| .section-head, .inline-head { justify-content: space-between; align-items: center; margin-bottom: 0.8rem; } |
| .section-subtitle { font-size: 0.88rem; font-weight: 700; margin-top: 0.4rem; } |
| .build-log { max-height: 22rem; overflow: auto; } |
| .danger-text { color: oklch(0.62 0.2 25); } |
| .admin-empty { padding: 2rem; text-align: center; } |
| @keyframes adminPulse { |
| 0% { |
| background-position: 0% 50%; |
| } |
| 100% { |
| background-position: 100% 50%; |
| } |
| } |
| @media (max-width: 1200px) { |
| .admin-auth-shell, |
| .admin-auth-facts, |
| .admin-workbench, |
| .two-col, |
| .metrics-grid, |
| .asset-grid, |
| .editor-layout, |
| .posts-layout { |
| grid-template-columns: 1fr; |
| } |
| } |
| @media (max-width: 800px) { |
| .admin-login { |
| max-width: 100%; |
| } |
| .field-grid { grid-template-columns: 1fr; } |
| .field-span-2 { grid-column: auto; } |
| } |
| </style>
|
|
|
|
|
|
|