feat(admin): dashboard overview and move posts list to /admin/posts

This commit is contained in:
2026-09-13 21:42:24 +08:00
parent a296ba4ce1
commit b8c1ee1f2d
4 changed files with 208 additions and 35 deletions
+2
View File
@@ -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; }
+146 -31
View File
@@ -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<number>`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<number>`count(*)` }).from(cases);
const KIND_LABELS: Record<string, string> = {
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";
---
<AdminLayout title="文章">
<h1>內容總覽</h1>
<p class="sub">管理文章、首頁內容同完成案例。前台會自動更新。</p>
<div class="card">
<h2>首頁內容</h2>
<div class="actions" style="margin-top:0">
<a class="btn secondary" href="/admin/content/service">服務({countOf("service")}</a>
<a class="btn secondary" href="/admin/content/feature">特色({countOf("feature")}</a>
<a class="btn secondary" href="/admin/content/step">流程({countOf("step")}</a>
<a class="btn secondary" href="/admin/content/faq">常見問題({countOf("faq")}</a>
<a class="btn secondary" href="/admin/cases">完成案例({caseCount?.n ?? 0}</a>
<a class="btn secondary" href="/admin/settings">網站設定</a>
<a class="btn secondary" href="/admin/ai">AI 生成</a>
<AdminLayout title="總覽">
<div class="page-head">
<div>
<h1>總覽</h1>
<p class="sub">內容同系統狀態一覽。前台會自動更新。</p>
</div>
</div>
<div class="actions">
<h2 style="font-size:18px;margin:0;flex:1">文章({all.length}</h2>
<a class="btn" href="/admin/post/new">+ 寫新文</a>
<div class="stats">
<div class="stat">
<div class="n">{postTotal}</div>
<div class="l">文章</div>
<div class="s">已發布 {data.posts.published} · 草稿 {data.posts.draft}</div>
</div>
<div class="stat">
<div class="n">{caseTotal}</div>
<div class="l">完成案例</div>
<div class="s">已發布 {data.cases.published} · 草稿 {data.cases.draft}</div>
</div>
<div class="stat">
<div class="n">{contentPublished}</div>
<div class="l">首頁內容</div>
<div class="s">
{data.content.map((c, i) => `${KIND_LABELS[c.kind] ?? c.kind} ${c.published}${i < data.content.length - 1 ? " · " : ""}`)}
</div>
</div>
<div class="stat">
<div class="n">{kw.pending}</div>
<div class="l">關鍵字待生成</div>
<div class="s">已生成 {kw.generated} · 已略過 {kw.skipped} · 共 {kwTotal}</div>
</div>
</div>
<div class="dash">
<div class="card" style="margin-bottom:0">
<h2>待辦提示</h2>
{
todos.length === 0 ? (
<p class="muted">一切妥當,冇待辦事項。</p>
) : (
todos.map((t) => (
<a class="todo" href={t.href}>
<span class="dot" />
<span>{t.text}</span>
</a>
))
)
}
</div>
<div class="card" style="margin-bottom:0">
<h2>系統狀態</h2>
<div class="status-row">
<span>AI 生成</span>
<span>
<span class={`status-dot ${aiEnabled ? "ok" : "warn"}`} />
{aiEnabled ? "啟用" : "停用"}
</span>
</div>
<div class="status-row">
<span>AI_API_KEY</span>
<span class={`status-value ${secrets.ai ? "" : "off"}`}>{secrets.ai ? "已設定" : "未設定"}</span>
</div>
<div class="status-row">
<span>TAVILY_API_KEY</span>
<span class={`status-value ${secrets.tavily ? "" : "off"}`}>{secrets.tavily ? "已設定" : "未設定"}</span>
</div>
<div class="status-row">
<span>模型</span>
<span class="muted">{settings.ai_chat_model || "deepseek-flash"}</span>
</div>
<div class="status-row" style="border-bottom:none">
<span>Base URL</span>
<span class="muted" style="max-width:60%;text-align:right;word-break:break-all">
{settings.ai_base_url || "https://api.deepseek.com"}
</span>
</div>
</div>
</div>
<div class="page-head" style="margin-bottom:14px">
<h2 style="font-size:16px;font-weight:700;margin:0">最近更新文章</h2>
<a class="btn secondary" href="/admin/post/new">+ 寫新文</a>
</div>
{
all.length === 0 ? (
data.recentPosts.length === 0 ? (
<div class="card">
<p class="muted">仲未有任何文章。<a href="/admin/post/new">寫第一篇 →</a></p>
</div>
@@ -52,11 +135,11 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
<th>標題</th>
<th style="width:110px">狀態</th>
<th style="width:130px">更新時間</th>
<th style="width:80px"></th>
<th style="width:72px"></th>
</tr>
</thead>
<tbody>
{all.map((p) => (
{data.recentPosts.map((p) => (
<tr>
<td>
<a href={`/admin/post/${p.id}`}>{p.title}</a>
@@ -66,7 +149,9 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
<span class={`badge ${p.status}`}>{p.status === "published" ? "已發布" : "草稿"}</span>
</td>
<td class="muted">{new Date(p.updatedAt).toLocaleDateString("zh-Hant-HK")}</td>
<td>{p.status === "published" && <a href={`/blog/${p.slug}`} target="_blank">睇</a>}</td>
<td>
<a href={`/admin/post/${p.id}`}>編輯</a>
</td>
</tr>
))}
</tbody>
@@ -74,3 +159,33 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
)
}
</AdminLayout>
<style is:global>
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.stat .n { font-family: "Noto Serif HK", serif; font-size: 34px; font-weight: 900; color: var(--brand-strong); line-height: 1.1; }
.stat .l { font-size: 13px; font-weight: 700; margin-top: 4px; }
.stat .s { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
.dash { display: grid; grid-template-columns: 2fr 1fr; gap: 18px; margin-bottom: 28px; }
.todo { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--fg); }
.todo:last-child { border-bottom: none; }
.todo:hover { color: var(--brand-strong); text-decoration: none; }
.todo .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--brand); flex: none; }
.status-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; font-size: 13.5px; border-bottom: 1px solid var(--border); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.status-dot.ok { background: var(--ok); }
.status-dot.warn { background: var(--brand); }
.status-value.off { color: var(--warn); font-weight: 700; }
@media (max-width: 1100px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
.dash { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.stats { grid-template-columns: 1fr; }
}
</style>
+4 -4
View File
@@ -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
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<a href="/admin"><button type="button" class="secondary">取消</button></a>
<a href="/admin/posts"><button type="button" class="secondary">取消</button></a>
{
!isNew && (
<button
+56
View File
@@ -0,0 +1,56 @@
---
import { desc } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { posts } from "../../db/schema";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const all = await db.select().from(posts).orderBy(desc(posts.updatedAt));
---
<AdminLayout title="文章">
<div class="page-head">
<div>
<h1>文章</h1>
<p class="sub">管理 Blog 文章。列表顯示全部,前台只顯示「已發布」。</p>
</div>
<a class="btn" href="/admin/post/new">+ 寫新文</a>
</div>
{
all.length === 0 ? (
<div class="card">
<p class="muted">仲未有任何文章。<a href="/admin/post/new">寫第一篇 →</a></p>
</div>
) : (
<table>
<thead>
<tr>
<th>標題</th>
<th style="width:110px">狀態</th>
<th style="width:130px">更新時間</th>
<th style="width:80px"></th>
</tr>
</thead>
<tbody>
{all.map((p) => (
<tr>
<td>
<a href={`/admin/post/${p.id}`}>{p.title}</a>
<div class="muted">/{p.slug}</div>
</td>
<td>
<span class={`badge ${p.status}`}>{p.status === "published" ? "已發布" : "草稿"}</span>
</td>
<td class="muted">{new Date(p.updatedAt).toLocaleDateString("zh-Hant-HK")}</td>
<td>{p.status === "published" && <a href={`/blog/${p.slug}`} target="_blank">睇</a>}</td>
</tr>
))}
</tbody>
</table>
)
}
</AdminLayout>