diff --git a/src/layouts/AdminLayout.astro b/src/layouts/AdminLayout.astro index cbb76d5..6944778 100644 --- a/src/layouts/AdminLayout.astro +++ b/src/layouts/AdminLayout.astro @@ -239,6 +239,8 @@ const isActive = (href: string) => { h1 { font-family: "Noto Serif HK", serif; font-size: 24px; font-weight: 900; margin: 0 0 6px; } .sub { color: var(--fg-muted); font-size: 13.5px; margin: 0 0 24px; } + .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; } + .page-head .sub { margin-bottom: 0; } .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 22px; margin-bottom: 18px; } .card h2 { font-size: 15px; font-weight: 700; margin: 0 0 14px; } diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index 09440d5..e2c52ac 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -1,47 +1,130 @@ --- -import { desc, eq, sql } from "drizzle-orm"; import AdminLayout from "../../layouts/AdminLayout.astro"; import { getDb } from "../../lib/db"; -import { cases, contentItems, posts } from "../../db/schema"; import { getEnv } from "../../lib/env"; +import { getDashboardData, getSettings } from "../../data/content"; export const prerender = false; -const db = getDb(getEnv().DB); -const all = await db.select().from(posts).orderBy(desc(posts.updatedAt)); +const env = getEnv(); +const db = getDb(env.DB); +const data = await getDashboardData(db); +const settings = await getSettings(db); -const counts = await db - .select({ kind: contentItems.kind, n: sql`count(*)` }) - .from(contentItems) - .groupBy(contentItems.kind); -const countOf = (k: string) => counts.find((c) => c.kind === k)?.n ?? 0; -const [caseCount] = await db.select({ n: sql`count(*)` }).from(cases); +const KIND_LABELS: Record = { + service: "服務", + feature: "特色", + step: "流程", + faq: "FAQ", +}; + +const postTotal = data.posts.published + data.posts.draft; +const caseTotal = data.cases.published + data.cases.draft; +const contentPublished = data.content.reduce((n, c) => n + c.published, 0); +const kw = data.keywords; +const kwTotal = kw.pending + kw.generated + kw.skipped; +const contentDraft = data.content.reduce((n, c) => n + c.draft, 0); + +const todos: { text: string; href: string }[] = []; +if (data.posts.draft) todos.push({ text: `${data.posts.draft} 篇文章仍是草稿`, href: "/admin/posts" }); +if (data.postsWithoutCover) + todos.push({ text: `${data.postsWithoutCover} 篇文章未有封面圖`, href: "/admin/posts" }); +if (kw.pending) todos.push({ text: `${kw.pending} 個關鍵字待生成`, href: "/admin/ai" }); +if (data.cases.draft) todos.push({ text: `${data.cases.draft} 個案例仍是草稿`, href: "/admin/cases" }); +if (contentDraft) + todos.push({ text: `${contentDraft} 個首頁內容項目仍是草稿`, href: "/admin/content/service" }); + +const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KEY) }; +const aiEnabled = settings.ai_enabled !== "0"; --- - -

內容總覽

-

管理文章、首頁內容同完成案例。前台會自動更新。

- -
-

首頁內容

-
- 服務({countOf("service")}) - 特色({countOf("feature")}) - 流程({countOf("step")}) - 常見問題({countOf("faq")}) - 完成案例({caseCount?.n ?? 0}) - 網站設定 - AI 生成 + +
+
+

總覽

+

內容同系統狀態一覽。前台會自動更新。

-
-

文章({all.length})

- + 寫新文 +
+
+
{postTotal}
+
文章
+
已發布 {data.posts.published} · 草稿 {data.posts.draft}
+
+
+
{caseTotal}
+
完成案例
+
已發布 {data.cases.published} · 草稿 {data.cases.draft}
+
+
+
{contentPublished}
+
首頁內容
+
+ {data.content.map((c, i) => `${KIND_LABELS[c.kind] ?? c.kind} ${c.published}${i < data.content.length - 1 ? " · " : ""}`)} +
+
+
+
{kw.pending}
+
關鍵字待生成
+
已生成 {kw.generated} · 已略過 {kw.skipped} · 共 {kwTotal}
+
+
+ +
+
+

待辦提示

+ { + todos.length === 0 ? ( +

一切妥當,冇待辦事項。

+ ) : ( + todos.map((t) => ( + + + {t.text} + + )) + ) + } +
+ +
+

系統狀態

+
+ AI 生成 + + + {aiEnabled ? "啟用" : "停用"} + +
+
+ AI_API_KEY + {secrets.ai ? "已設定" : "未設定"} +
+
+ TAVILY_API_KEY + {secrets.tavily ? "已設定" : "未設定"} +
+
+ 模型 + {settings.ai_chat_model || "deepseek-flash"} +
+
+ Base URL + + {settings.ai_base_url || "https://api.deepseek.com"} + +
+
+
+ +
+

最近更新文章

+ + 寫新文
{ - all.length === 0 ? ( + data.recentPosts.length === 0 ? (

仲未有任何文章。寫第一篇 →

@@ -52,11 +135,11 @@ const [caseCount] = await db.select({ n: sql`count(*)` }).from(cases); 標題 狀態 更新時間 - + - {all.map((p) => ( + {data.recentPosts.map((p) => ( {p.title} @@ -66,7 +149,9 @@ const [caseCount] = await db.select({ n: sql`count(*)` }).from(cases); {p.status === "published" ? "已發布" : "草稿"} {new Date(p.updatedAt).toLocaleDateString("zh-Hant-HK")} - {p.status === "published" && } + + 編輯 + ))} @@ -74,3 +159,33 @@ const [caseCount] = await db.select({ n: sql`count(*)` }).from(cases); ) } + + diff --git a/src/pages/admin/post/[id].astro b/src/pages/admin/post/[id].astro index 2ffbbf9..806eeac 100644 --- a/src/pages/admin/post/[id].astro +++ b/src/pages/admin/post/[id].astro @@ -26,7 +26,7 @@ const [existing] = isNew : await db.select().from(posts).where(eq(posts.id, id!)).limit(1); if (!isNew && !existing) { - return Astro.redirect("/admin"); + return Astro.redirect("/admin/posts"); } if (Astro.request.method === "POST") { @@ -36,7 +36,7 @@ if (Astro.request.method === "POST") { if (action === "delete" && !isNew) { await deleteMedia(env, existing?.coverImage); await db.delete(posts).where(eq(posts.id, id!)); - return Astro.redirect("/admin"); + return Astro.redirect("/admin/posts"); } const parsed = parseForm(form, postInput); @@ -93,7 +93,7 @@ if (Astro.request.method === "POST") { .where(eq(posts.id, id!)); } - return Astro.redirect("/admin"); + return Astro.redirect("/admin/posts"); } } @@ -171,7 +171,7 @@ const post = isNew
- + { !isNew && (