192 lines
7.1 KiB
Plaintext
192 lines
7.1 KiB
Plaintext
---
|
|
import AdminLayout from "../../layouts/AdminLayout.astro";
|
|
import { getDb } from "../../lib/db";
|
|
import { getEnv } from "../../lib/env";
|
|
import { getDashboardData, getSettings } from "../../data/content";
|
|
|
|
export const prerender = false;
|
|
|
|
const env = getEnv();
|
|
const db = getDb(env.DB);
|
|
const data = await getDashboardData(db);
|
|
const settings = await getSettings(db);
|
|
|
|
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="總覽">
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>總覽</h1>
|
|
<p class="sub">內容同系統狀態一覽。前台會自動更新。</p>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
{
|
|
data.recentPosts.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:72px"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.recentPosts.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>
|
|
<a href={`/admin/post/${p.id}`}>編輯</a>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)
|
|
}
|
|
</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>
|