Compare commits

...
10 Commits
Author SHA1 Message Date
philip-cat eb2ed9f950 chore: update generated Astro dev artifacts 2026-09-14 18:30:47 +08:00
philip-cat aa86f1f5ed docs: mark admin dashboard plan complete 2026-09-13 21:47:12 +08:00
philip-cat cc142bfd06 docs: document admin dashboard redesign 2026-09-13 21:46:58 +08:00
philip-cat 9eed1d32d3 style(admin): restyle login page 2026-09-13 21:44:25 +08:00
philip-cat 6836cd529e style(admin): align pages with dashboard tokens 2026-09-13 21:44:13 +08:00
philip-cat b8c1ee1f2d feat(admin): dashboard overview and move posts list to /admin/posts 2026-09-13 21:42:24 +08:00
philip-cat a296ba4ce1 feat(admin): add getDashboardData query 2026-09-13 21:41:48 +08:00
philip-cat 4f630afafb feat(admin): sidebar shell and design tokens 2026-09-13 21:41:33 +08:00
philip-cat e7ddbb6654 docs: admin dashboard redesign spec and plan 2026-09-13 21:41:06 +08:00
philip-cat fbc562f495 新增 GA4 / Meta Pixel 追蹤與 Cookie 同意機制
- 追蹤 ID 存 site_settings(空字串即關閉),於 /admin/settings 管理
- Base.astro 僅在用戶同意後載入分析工具,未同意前零請求
- 新增 /privacy 私隱政策頁與「重設 Cookie 偏好」按鈕
- 前台 SSR 頁面(首頁、blog、about、privacy)傳入 gaId / metaPixelId
- seed 加入預設空值,Footer 加入私隱政策連結
- 同步更新 AGENTS.md 與 README 說明
2026-09-13 20:01:57 +08:00
25 changed files with 1142 additions and 137 deletions
+5 -5
View File
@@ -1,14 +1,14 @@
{
"pid": 52048,
"port": 4322,
"url": "http://localhost:4322",
"pid": 78610,
"port": 4323,
"url": "http://localhost:4323",
"urls": {
"local": [
"http://localhost:4322/"
"http://localhost:4323/"
],
"network": [],
"networkInterfaceNames": []
},
"background": false,
"startedAt": "2026-09-13T08:12:40.425Z"
"startedAt": "2026-09-13T13:48:49.043Z"
}
-1
View File
@@ -1,3 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="integrations/_astrojs_cloudflare/cloudflare.d.ts" />
/// <reference path="content.d.ts" />
+1
View File
@@ -25,6 +25,7 @@ Astro 7 + React 19 + Chakra UI v3,部署在 Cloudflare Workers(單一 Worker
- **`wrangler.jsonc` 有佔位 id**`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。R2 bucket 唔使貼 id,用 `r2:create`(或 `npm run setup`)建立即可,binding 係 `MEDIA`
- **圖片上傳**:圖片存 Cloudflare R2binding `MEDIA`、bucket `yingfung-solar-media`),由同一個 Worker 的 `/media/*` 出圖;上傳經 `/admin/upload`client 端先縮圖轉 WebP。本機靠 `platformProxy` 模擬 R2,雲端要先 `npm run r2:create`。圖片欄位仍可貼 URL;換圖/刪除時 `lib/media.ts` 會清舊 R2 檔。
- **上線前要改 `astro.config.mjs``site`**sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。
- **追蹤分析(GA4 / Meta Pixel**ID 存 `site_settings``ga4_measurement_id` / `meta_pixel_id`**空字串=關閉**),喺 `/admin/settings` 改。`Base.astro` 只會喺用戶同意(`localStorage.cookie_consent`)之後先載入,未同意前唔會有追蹤請求/Cookie;`/privacy` 有說明同「重設 Cookie 偏好」。因為係通用 key-value**冇新 migration**,只需 `scripts/seed.sql` 有 default。
- **後台認證**`src/middleware.ts` 保護所有 `/admin`,用 HMAC-signed cookie`src/lib/auth.ts`);未設 `ADMIN_PASSWORD`(本機在 `.dev.vars`,已 gitignore)會回 503。
- **Chakra v3 + Emotion**:前台 React 元件要用 `Provider` / `SiteChrome`(內含 `<ChakraProvider value={system}>`),theme 在 `src/theme/system.ts`。Astro 以 `client:load` 掛 island。
- **語言**:UI 文案、註解、後台全部都係繁體中文(廣東話)——新增內容請保持一致;網站只做中文,不做雙語。
+27 -8
View File
@@ -50,21 +50,38 @@ npm run dev
## 部署到 Cloudflare
### 首次設定(一鍵
### 1. 先設定網址(部署前一定要做
`sitemap` / `canonical` / `OG` 全部靠 `astro.config.mjs``site`,所以**先改佢**
```js
// astro.config.mjs
site: "https://your-domain.com",
```
想用 Cloudflare 自訂網域(例如 `solar.develop-cat.com`)就順手喺 `wrangler.jsonc` 加:
```jsonc
"routes": [
{ "pattern": "solar.develop-cat.com", "custom_domain": true }
],
```
部署時會自動建立 DNS + TLS,唔使自己開 record。唔加就淨係用 `https://<worker-name>.<account>.workers.dev`;亦可以部署後喺 Dashboard → Worker → Settings → Domains & Routes 手動加。
### 2. 首次設定(一鍵)
```bash
npm run login # 瀏覽器授權
npm run setup # 建 D1 / KV / R2、寫入 wrangler.jsonc、設 secret、 migration + seed
npm run setup # 建 D1 / KV / R2、寫入 wrangler.jsonc 嘅 id、設 secret、可選 migration + seed + build + deploy
```
`npm run setup` 會逐步問你:要唔要設定後台密碼、要唔要套 migration/seed、要唔要 build + deploy`astro.config.mjs``site``https://example.com`,會跳過 build + deploy 並提醒你改。
`npm run setup` 會逐步問你:設定後台密碼套 migration/seedbuild + deploy`site` 仍係 `https://example.com`,會跳過 build + deploy 並提醒你改(改完再 `npm run deploy`
### 之後更新
改完 `astro.config.mjs``site` 做真域名後:
### 3. 之後更新
```bash
npm run deploy
npm run deploy # = npm run build + wrangler deploy
```
### Git push 自動部署(Cloudflare Workers Builds
@@ -76,6 +93,7 @@ npm run deploy
**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑
`npm run db:generate``npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。
`seed.sql` 放喺 `scripts/`(唔可以放喺 `migrations/`,否則 `db:migrate` 會連 seed 一齊種)。
## AI 生成 Blog
@@ -110,12 +128,13 @@ src/
├─ layouts/AdminLayout.astro 後台外框
├─ pages/
│ ├─ index.astro SSR 首頁
│ ├─ about.astro 關於(靜態)
│ ├─ blog/… Blog
│ ├─ admin/… 後台(含 admin/ai、admin/upload 上傳 endpoint
│ ├─ media/[...key].ts R2 圖片出圖 endpoint
│ └─ sitemap.xml.ts / robots.txt.ts
├─ components/admin/ImageField.astro 後台圖片欄(URL + 上傳 + 預覽)
├─ lib/{auth,db,env,form,media,ai,markdown}.ts
├─ lib/{auth,db,env,form,media,ai,markdown,schema-org}.ts
migrations/
├─ 0000_init.sql … Drizzle migrations
scripts/
@@ -0,0 +1,189 @@
# 後台 Dashboard 化 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:**`/admin` 由綠色純 CSS 表單頁改造成「墨黑 sidebar + 米白內容」嘅真 dashboard`/admin` 做總覽、文章列表搬去 `/admin/posts`,所有 admin 頁統一視覺;維持純 Astro + CSS、無 React。
**Architecture:** `AdminLayout.astro` 做單一 shellsidebar + CSS variables),保留原有 R2 上傳 script。新增 `getDashboardData(db)``src/data/content.ts` 提供統計,唔改 schema。`index.astro` 改寫成 dashboard,新增 `posts.astro` 承接原文章列表。
**Tech Stack:** Astro 7 SSR、Drizzle/D1、純 CSSCSS variables)。驗證=`npm run build`(專案冇 testlinttypecheck)。
**Spec:** `docs/superpowers/specs/2026-09-13-admin-dashboard-design.md`
---
## File Structure
- Modify `src/layouts/AdminLayout.astro` — shell / tokens / nav(保留 upload script
- Modify `src/data/content.ts` — 加 `getDashboardData`
- Modify `src/pages/admin/index.astro` — dashboard
- Create `src/pages/admin/posts.astro` — 文章列表(由原 index 搬)
- Modify `src/pages/admin/post/[id].astro` — redirect+取消 link
- Modify `src/pages/admin/{cases,settings,ai}.astro``content/[kind].astro` — 只改外觀
- Modify `src/pages/admin/login.astro` — 重新上色
- Modify `src/AGENTS.md` — Admin 段落
---
### Task 1: `AdminLayout` tokens + sidebar shell
**Files:** Modify `src/layouts/AdminLayout.astro`
- [x] **Step 1: 換 `:root` 同佈局 CSS** — 將現有 `:root`line 138-144)+ `.inner``.bar*`149-158)換成:
```css
:root {
--ink:#1A1917; --ink-2:#24221F; --ink-3:#2E2B27;
--bg:#FAF8F4; --surface:#fff;
--fg:#1A1917; --fg-muted:#6E6862;
--border:#E7E2D9;
--brand:#D97706; --brand-strong:#B45309; --brand-soft:#FEF3C7;
--ok:#0F6E56; --ok-soft:#E1F5EE;
--warn:#854F0B; --warn-soft:#FAEEDA;
--danger:#A32D2D; --danger-soft:#FCEBEB;
--radius-sm:4px; --radius:8px; --sidebar-w:248px;
font-family:"Noto Sans HK","PingFang HK","Microsoft JhengHei",system-ui,sans-serif;
color:var(--fg); background:var(--bg); line-height:1.65; font-size:15px;
}
a{color:var(--brand-strong);text-decoration:none} a:hover{text-decoration:underline}
.shell{display:flex;min-height:100vh}
.sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar-w);background:var(--ink);color:#EDEAE4;display:flex;flex-direction:column;padding:22px 14px;z-index:20}
.brand{display:flex;flex-direction:column;gap:2px;padding:0 10px 18px;color:#fff}
.brand-name{font-family:'Noto Serif HK',serif;font-weight:900;font-size:17px;letter-spacing:.02em}
.brand-sub{font-size:11px;color:#9A938A;letter-spacing:.28em}
.nav{display:flex;flex-direction:column;gap:2px;flex:1;overflow:auto}
.nav-group{margin:16px 10px 6px;font-size:10.5px;letter-spacing:.22em;color:#7E776E}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius-sm);color:#CFC9C1;font-size:13.5px;border-left:3px solid transparent}
.nav a:hover{background:var(--ink-2);color:#fff;text-decoration:none}
.nav a.active{background:var(--ink-2);color:var(--brand);border-left-color:var(--brand);font-weight:700}
.sidebar-foot{display:flex;flex-direction:column;gap:4px;border-top:1px solid #2E2B27;padding-top:14px}
.sidebar-foot a{padding:8px 10px;color:#9A938A;font-size:13px}
.sidebar-foot a:hover{color:#fff;text-decoration:none}
.content{flex:1;margin-left:var(--sidebar-w);min-width:0}
.main{max-width:1080px;padding:36px 40px 100px;margin:0 auto}
.topbar{display:none}
```
- [x] **Step 2: 加 mobile CSS**(同一 `<style>` 內):
```css
@media (max-width:900px){
.sidebar{transform:translateX(-100%);transition:transform .2s ease}
#nav-toggle:checked ~ .sidebar{transform:translateX(0)}
.content{margin-left:0}
.topbar{display:flex;align-items:center;gap:12px;position:sticky;top:0;z-index:15;background:var(--surface);border-bottom:1px solid var(--border);padding:12px 18px}
.topbar .brand{flex-direction:row;align-items:baseline;gap:8px;padding:0;color:var(--ink)}
.topbar .brand-sub{color:var(--fg-muted)}
.hamburger{cursor:pointer;font-size:20px;line-height:1;color:var(--ink);border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 10px}
.main{padding:24px 18px 80px}
}
```
- [x] **Step 3: 換 body markup**line 29-51)成:
```astro
<body>
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
<div class="shell">
<aside class="sidebar">
<a class="brand" href="/admin">
<span class="brand-name">盈豐太陽能</span>
<span class="brand-sub">後台</span>
</a>
<nav class="nav">
<p class="nav-group">總覽</p>
<a href="/admin" class={isActive("/admin") ? "active" : ""}>總覽</a>
<p class="nav-group">內容管理</p>
<a href="/admin/posts" class={isActive("/admin/posts") ? "active" : ""}>文章</a>
<a href="/admin/content/service" class={isActive("/admin/content/service") ? "active" : ""}>服務</a>
<a href="/admin/content/feature" class={isActive("/admin/content/feature") ? "active" : ""}>特色</a>
<a href="/admin/content/step" class={isActive("/admin/content/step") ? "active" : ""}>流程</a>
<a href="/admin/content/faq" class={isActive("/admin/content/faq") ? "active" : ""}>常見問題</a>
<a href="/admin/cases" class={isActive("/admin/cases") ? "active" : ""}>完成案例</a>
<p class="nav-group">系統</p>
<a href="/admin/ai" class={isActive("/admin/ai") ? "active" : ""}>AI 生成</a>
<a href="/admin/settings" class={isActive("/admin/settings") ? "active" : ""}>網站設定</a>
</nav>
<div class="sidebar-foot">
<a href="/" target="_blank">睇網站 ↗</a>
<a href="/admin/logout">登出</a>
</div>
</aside>
<div class="content">
<header class="topbar">
<label for="nav-toggle" class="hamburger" aria-label="開合導航">☰</label>
<a class="brand" href="/admin">
<span class="brand-name">盈豐太陽能</span>
<span class="brand-sub">後台</span>
</a>
</header>
<main class="main"><slot /></main>
</div>
</div>
<script>/* 原 upload script 不變 */</script>
</body>
```
- [x] **Step 4: 換 formtablecard CSS**(現有 164-206)— 保留 class 名(`.card` `.badge` `.actions` `.grid2` `.image-*` `.muted` `.err` `.field-error` `.row`),色值改用 tokens`.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px 22px;margin-bottom:18px}``th`/`td``var(--border)`input/textarea/select 用 `var(--border)`focus `outline:2px solid var(--brand-soft);border-color:var(--brand)``button,.btn``background:var(--ink);border-color:var(--ink);border-radius:var(--radius-sm)``.btn.secondary` 白底、`button.danger``--danger``--danger-soft``.badge.published{background:var(--ok-soft);color:var(--ok)}``.badge.draft{background:var(--warn-soft);color:var(--warn)}`
- [x] **Step 5: 驗證**`npm run build`Expected: success0 errors)。
- [x] **Step 6: 目測**`npm run dev`,喺 ≥900px 同 <900px 開 `/admin`,確認 sidebar、active 態、漢堡開合、各頁表單/表格可讀。
- [x] **Step 7: Commit**`git add src/layouts/AdminLayout.astro && git commit -m "feat(admin): sidebar shell and design tokens"`
---
### Task 2: `getDashboardData(db)`
**Files:** Modify `src/data/content.ts`
- [x] **Step 1: 加 import**`import { and, asc, desc, eq, isNull, ne, sql } from "drizzle-orm";`,並喺 schema import 加入 `CONTENT_KINDS`
- [x] **Step 2: 加 type function**`getKeywords` 之後):見 spec §4,實作 `DashboardData` + `getDashboardData`(用 `groupBy` + `sql\`count(*)\```pick` helper 取 status 計數,`recentPosts` limit 5`postsWithoutCover` 用 `isNull(posts.coverImage)`)。
- [x] **Step 3: 驗證**`npm run build`Expected: success。
- [x] **Step 4: Commit**`git add src/data/content.ts && git commit -m "feat(admin): add getDashboardData query"`
---
### Task 3: Dashboard `/admin` 文章頁 `/admin/posts` redirects
**Files:** Create `src/pages/admin/posts.astro`Modify `src/pages/admin/index.astro``src/pages/admin/post/[id].astro`
- [x] **Step 1: 建立 `posts.astro`** — 將原 `index.astro` 查詢+文章表搬去,`<AdminLayout title="文章">``<h1>文章</h1>`,heading 加「+ 寫新文」;保留 `export const prerender = false`
- [x] **Step 2: 改寫 `index.astro`** 成 dashboardfrontmatter 用 `getDashboardData` + `getSettings`,砌 `todos`markup 見 spec §3)。
- [x] **Step 3: 加 dashboard CSS**`.stats``.stat``.dash``.todo``.status-row``.status-dot`RWD breakpoints 1100900560)。
- [x] **Step 4: 改 redirect**`post/[id].astro` line 293996 `"/admin"``"/admin/posts"`line 174 取消 link `href="/admin"``"/admin/posts"`
- [x] **Step 5: 驗證**`npm run build`Expected: success。
- [x] **Step 6: 目測** — dev:登入後 `/admin` 見總覽;`/admin/posts` 新增/儲存/刪文後返 `/admin/posts`;統計同待辦數字正確。
- [x] **Step 7: Commit**`git add src/pages/admin && git commit -m "feat(admin): dashboard overview and move posts list to /admin/posts"`
---
### Task 4: 其餘 admin 頁統一視覺
**Files:** Modify `src/pages/admin/cases.astro``content/[kind].astro``settings.astro``ai.astro`
- [x] **Step 1: 移除 inline 硬編色**,改用 tokens/既有 class;成功提示改 `.notice`
- [x] **Step 2: 統一 section 標題/spacing**
- [x] **Step 3: 驗證**`npm run build`Expected: success。
- [x] **Step 4: 目測** — dev 逐頁睇 casescontent 四 kindsettings/ai,確認色系一致、表單可用、圖片上傳正常。
- [x] **Step 5: Commit**`git add src/pages/admin && git commit -m "style(admin): align pages with dashboard tokens"`
---
### Task 5: `login.astro` 重新上色
**Files:** Modify `src/pages/admin/login.astro`
- [x] **Step 1:**`<style is:global>` 改成同 tokens 一致(米白底、白卡髮絲邊、琥珀 focus、墨黑掣、serif 標題、`.err` 用 danger 色)。
- [x] **Step 2: 驗證**`npm run build`Expected: success。
- [x] **Step 3: 目測** — dev 登出睇 `/admin/login`,登入流程正常。
- [x] **Step 4: Commit**`git add src/pages/admin/login.astro && git commit -m "style(admin): restyle login page"`
---
### Task 6: 更新 DOX 文件
**Files:** Modify `src/AGENTS.md`
- [x] **Step 1:** 更新 `src/AGENTS.md` Admin 段落(路由、dashboard、sidebar、色系、`getDashboardData`)。
- [x] **Step 2: 驗證**`npm run build`Expected: success。
- [x] **Step 3: Commit**`git add src/AGENTS.md && git commit -m "docs: document admin dashboard redesign"`
@@ -0,0 +1,76 @@
# 盈豐太陽能 — 後台 Dashboard 化重新設計 (Redesign Spec)
- **日期**2026-09-13
- **狀態**:已與客戶確認方向
- **背景**:現時 `/admin` 後台用綠色(`#0b8a5e`)/淺綠底(`#f2f8f6`)純 CSS,同前台 editorial(暖米白+琥珀金+墨黑)唔一致;一入 `/admin` 就係文章表,冇總覽。本規格將後台改造成墨黑 sidebar + 米白內容嘅真 dashboard。
- **範圍**:只改後台(`src/layouts/AdminLayout.astro``src/pages/admin/**``src/data/content.ts``src/AGENTS.md`)。**唔郁**前台、D1 schema、migration、認證/middleware 邏輯。
---
## 1. 設計方向
**墨黑 sidebar × 暖米白內容 × 琥珀金 accent** 嘅工具感 dashboard,同前台品牌一致但偏實用。
- 沿用前台語意色:墨黑 `#1A1917`、暖米白 `#FAF8F4`、髮絲線 `#E7E2D9`、暖灰 `#6E6862`、琥珀 `#D97706``#B45309`
- 狀態色只用嚟表達語意,唔做裝飾:成功 `#0F6E56`、警示/草稿琥珀、危險 `#A32D2D`
- 形狀:直角/微圓角(4–8px)、髮絲邊框、無陰影、無漸層。
- 純 Astro SSR + CSSCSS variables),**唔引入 React / Chakra**,維持現有 admin 合約。
## 2. 資訊架構
- **`/admin`(總覽)**dashboard,見 §3。
- **`/admin/posts`(文章)**:由原 `index.astro` 文章列表搬過來。
- 其餘路由不變:`/admin/content/[kind]``/admin/cases``/admin/ai``/admin/settings``/admin/post/[id]``/admin/login``/admin/logout`
### Shell`AdminLayout.astro`
- 桌面(≥900px):左側固定 sidebar 248px、墨黑底;內容區暖米白、`max-width 1080px`
- Sidebar:品牌 serif「盈豐太陽能」+細字「後台」;導航分組:
- **總覽** → `/admin`
- **內容管理** → 文章 `/admin/posts`、服務、特色、流程、常見問題、完成案例
- **系統** → AI 生成、網站設定
- 底部:睇網站 ↗(新視窗)、登出
- Active 項:琥珀文字+左邊 3px 琥珀條+微亮底。
- 手機(<900px):sidebar 收埋,頂欄顯示品牌+純 CSS checkbox 漢堡開合。
- 所有色值集中做 CSS variables`--ink``--ink-2``--bg``--surface``--fg``--fg-muted``--border``--brand``--brand-strong``--brand-soft``--ok``--warn``--danger``--radius-sm``--radius``--sidebar-w`)。
- 保留原有 R2 上傳 `<script>``ImageField` 依賴嘅 `.image-url` / `.image-preview` / `.image-status` class。
## 3. `/admin` 總覽內容
- 頁首:h1「總覽」+更新時間。
- **統計卡 ×4**(大 serif 琥珀數字、髮絲邊框、無陰影):
1. 文章 — 總數;細字:已發布 N · 草稿 N
2. 完成案例 — 總數;細字:已發布 N · 草稿 N
3. 首頁內容 — 總數;細字:服務 · 特色 · 流程 · FAQ
4. 關鍵字佇列 — 待生成數;細字:已生成 N · 已略過 N
- **待辦提示**(主欄):條件式列出,每項可點去對應頁;全清顯示「一切妥當」:
- N 篇文章仍是草稿 → `/admin/posts`
- N 篇文章未有封面圖 → `/admin/posts`
- N 個關鍵字待生成 → `/admin/ai`
- N 個案例仍是草稿 → `/admin/cases`
- N 個首頁內容項目仍是草稿 → `/admin/content/service`
- **系統狀態**(側欄):AI 生成 啟用/停用、`AI_API_KEY``TAVILY_API_KEY` 已設定/未設定、目前 model 與 Base URL(唯讀顯示)。
- **最近更新文章**(全寬):最新 5 篇(標題+slug、狀態 badge、更新日期、編輯 link),section 右上「+ 寫新文」。
## 4. 資料層
- `src/data/content.ts``getDashboardData(db): Promise<DashboardData>`,集中提供 dashboard 統計(沿用「查詢集中喺 content.ts」慣例)。
- `DashboardData``posts{published,draft}``cases{published,draft}``content[{kind,published,draft}]``keywords{pending,generated,skipped}``recentPosts``postsWithoutCover`
- `getDashboardData` 只讀 DBAI secrets`env.AI_API_KEY``env.TAVILY_API_KEY`)同 `ai_enabled` 喺頁面層處理。
- **冇 schema 改動、冇 migration。**
## 5. 各頁統一
- `cases``content/[kind]``settings``ai` 只改外觀(卡片、badge、掣、表格、提示),表單 action 同流程不變。
- badge:已發布=淡綠、草稿=琥珀、已略過/muted=灰;成功提示由 `.badge.published` 改為 `.notice`
- `post/[id].astro` 存/刪/取消後 redirect 去 `/admin/posts`
- `login.astro` 用同一 tokens 重新上色。
## 6. 驗證
- `npm run build`(唯一自動驗證;專案冇 testlinttypecheck)。
- `npm run dev` 目測:所有 admin 頁、RWD(≥900 / <900)、登入/登出、圖片上傳、表單存檔 redirect、`/blog/[slug]` 404 不受影響。
## 7. 文件同步
- `src/AGENTS.md` Admin 段落:`/admin` 由文章列表改為總覽、文章列表喺 `/admin/posts``AdminLayout` 改為墨黑 sidebar shell、色系改用 CSS variables、`getDashboardData` 位置。
+3 -1
View File
@@ -19,7 +19,9 @@ INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('hero_image', 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1600&q=80', strftime('%s','now')*1000),
('seo_default_title', '盈豐太陽能工程有限公司 — 香港村屋太陽能一站式服務', strftime('%s','now')*1000),
('seo_default_description', '專營香港村屋太陽能系統:現場評估、合法支架、代辦申請、專業安裝及驗收認證。透明報價,專人跟進,幫你善用天台賺取發電回報。', strftime('%s','now')*1000),
('og_image', 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1200&q=80', strftime('%s','now')*1000);
('og_image', 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1200&q=80', strftime('%s','now')*1000),
('ga4_measurement_id', '', strftime('%s','now')*1000),
('meta_pixel_id', '', strftime('%s','now')*1000);
INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('ai_enabled', '1', strftime('%s','now')*1000),
+16 -6
View File
@@ -8,8 +8,8 @@
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
- `theme/system.ts` — Chakra systemtokens / semanticTokens / fonts / globalCss),前台顏色同字體的唯一來源。現行設計:editorial 極簡風(暖米白 `#FAF8F4` 底、琥珀金 `brand` accent、墨黑 `ink`、Noto Serif HK 標題),詳見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`
- `layouts/Base.astro` — 前台 SEO headcanonical / OG / Twitter / theme-color / JSON-LD `jsonLd` prop / sitemap link)+ Noto Sans HK(非阻塞載入,`media="print" onload` + `<noscript>` fallback)。
- `layouts/AdminLayout.astro` — 後台外框,純 CSS、與前台一致的色系
- `layouts/Base.astro` — 前台 SEO headcanonical / OG / Twitter / theme-color / JSON-LD `jsonLd` prop / sitemap link)+ Noto Sans HK(非阻塞載入,`media="print" onload` + `<noscript>` fallback+ 同意後先載入嘅 analytics(`gaId` / `metaPixelId` props,見下面 Analytics & consent
- `layouts/AdminLayout.astro` — 後台外框:墨黑左側 sidebar`/admin` 總覽、內容管理、系統分組)+米白內容區,純 CSS、色值集中喺 CSS variables`--ink` / `--brand` / `--bg` 等),手機用純 CSS checkbox 漢堡開合;同時保留 R2 上傳 script(`ImageField` 依賴 `.image-url` / `.image-preview` / `.image-status`
- `config.ts` — build-time 靜態頁用的 `SITE_NAME` / `SITE_TAGLINE`(可管理內容一律放 D1,唔放呢度)。
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`
- `env.d.ts``App.Locals.isAdmin` 型別宣告。
@@ -28,8 +28,8 @@
### Routes & SSR`pages/`
- **預設靜態**`astro.config.mjs``output: "static"`。要讀 D1 或 request-time 資料的頁面/endpoint 必須在檔案內寫 `export const prerender = false`
- 目前 SSR`index.astro``blog/index.astro``blog/[slug].astro``admin/**``media/[...key].ts``sitemap.xml.ts`
- 保持靜態:`about.astro``robots.txt.ts`
- 目前 SSR`index.astro``about.astro``privacy.astro``blog/index.astro``blog/[slug].astro``admin/**``media/[...key].ts``sitemap.xml.ts`
- 保持靜態:`robots.txt.ts`
- **環境變數**:只用 `getEnv()`,且只可喺 `prerender = false` 的頁面/endpoint 用。
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island`client:idle`,內容仍 SSR 出 HTML),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`
- **404**`blog/[slug].astro` 找唔到文章 → 設 `Astro.response.status = 404` 並 render noindex 頁。
@@ -38,17 +38,27 @@
- **AI 生成(同步)**`/admin/ai` 的生成喺 admin POST 內同步 `await` 完成先 redirect。如日後要改做背景處理,可用 `Astro.locals.cfContext`Cloudflare `ExecutionContext`)嘅 `waitUntil`,唔使改現有流程。
- **SEO**:用 `Base.astro`canonical / OG / sitemap 全部靠 `astro.config.mjs``site`(現為佔位 `https://example.com`,上線前要改)。所有頁面都要有單一 `h1`section 標題用 `h2`、項目用 `h3`,唔可以跳級(heading 語意一律用 Chakra `as`,唔可以淨靠字級)。JSON-LD 由 `lib/schema-org.ts` 砌,經 `Base.astro``jsonLd` prop 輸出:首頁 `LocalBusiness` + `FAQPage`、文章 `BlogPosting` + `BreadcrumbList`
### Analytics & consent
- **ID 放 D1**GA4 / Meta Pixel 的 ID 存 `site_settings``ga4_measurement_id` / `meta_pixel_id`admin `/admin/settings` 可改);**空字串=唔載入**,冇 `enabled` boolean。因為係通用 key-value**唔使 migration**,只需 `scripts/seed.sql` 有 default。
- **注入方式**`Base.astro` 收 optional props `gaId` / `metaPixelId`;只有 SSR 頁讀到 D1 再傳落去(靜態頁要傳就必須轉 `prerender = false`)。GA4 / Meta script 一律喺 **consent 之後**由 inline JS 動態 append,未同意前唔會有任何追蹤請求或 Cookie(亦**冇** Meta `<noscript>` pixel)。
- **Consent**:單一接受/拒絕,存 `localStorage` key `cookie_consent``granted` / `denied`);banner 同載入邏輯都喺 `Base.astro`(純 HTML/CSS + `is:inline`,唔用 React)。已同意者每次載入即追蹤。
- **`/privacy`**`pages/privacy.astro`SSR)記錄 Cookie / GA4 / Meta 用途,並提供「重設 Cookie 偏好」清除 `localStorage`
- 免維護原則:唔使抄參考專案嘅 singleton 表/APIReact 動態注入(本站係 MPA,每次載入=一次 pageview)。
### Admin`pages/admin/`
- 所有 `/admin` 路由**必須** `export const prerender = false`
- **視覺**:全部用 `AdminLayout.astro` 的墨黑 sidebar shell,色值一律用其 CSS variables(唔好硬編色)。`/admin` 係總覽 dashboard。
- **認證**由 `middleware.ts` 統一處理;登入喺 `admin/login.astro``checkPassword` + `createSession`),登出喺 `logout.ts`
- **UI**:全部用 `AdminLayout.astro`,純 Astro SSR 表單(`POST` + `formData`),**唔引入** React / Chakra。
- **流程**:每個 POST 處理完 `Astro.redirect` 返對應列表頁;`/admin/ai` 用 POST/Redirect/Get + `?ok=<key>` + `okMessages` 顯示成功提示(避免重複提交)。
- **流程**:每個 POST 處理完 `Astro.redirect` 返對應列表頁;`/admin/ai` 用 POST/Redirect/Get + `?ok=<key>` + `okMessages` 顯示成功提示(避免重複提交)。成功提示用 `.notice`(唔好再用 `.badge.published`)。
- **總覽**`index.astro``/admin`)係 dashboard,資料由 `data/content.ts``getDashboardData(db)` 提供(統計卡/待辦提示/最近更新文章/系統狀態);AI secrets 由頁面層 `getEnv()` 讀。
- **表單驗證**:所有 admin 表單經 `parseForm(form, schema)``src/schemas/`),失敗時以 `errors` 逐欄顯示,唔好手寫逐欄檢查。
- **通用動作**`add` / `save` / `delete` / `up` / `down`(排序以交換 `sortOrder` 實作)。
- **通用內容編輯器**`content/[kind].astro``kind ∈ service | feature | step | faq`(見 `db/schema.ts``CONTENT_KINDS`),欄位標籤由檔案內 `META` 定義。
- **網站設定**`settings.astro``data/settings-fields.ts``SETTINGS_GROUPS` / `ALL_SETTING_KEYS` 產生表單,逐 key upsert。
- **文章**`index.astro` 列表、`post/[id].astro` 新增/編輯`id === "new"` 為新增);slug 自動 `slugify` 並用 `uniqueSlug` 去重。
- **文章**列表喺 `posts.astro``/admin/posts`)、編輯喺 `post/[id].astro``id === "new"` 為新增);slug 自動 `slugify` 並用 `uniqueSlug` 去重。文章存/刪後 redirect 去 `/admin/posts`
- **AI 生成**`ai.astro``/admin/ai`)管 AI 設定 + 關鍵字佇列,同步呼叫 `generateBlogPost`;成功會 redirect 去新草稿 `/admin/post/<id>`。生成按鈕用 inline `onsubmit` 顯示「生成中…」。
- **圖片**:圖片欄位(settings 的 `hero_image` / `og_image``cases.imageUrl``posts.coverImage`)用 `components/admin/ImageField.astro`,可揀檔即時上傳去 R2(binding `MEDIA`),亦可貼 URLclient 端縮到最大寬 1600px 並轉 WebP 先上傳。上傳 endpoint `/admin/upload`(受 middleware 保護,`scope ∈ settings | cases | posts`、JPG/PNG/WebP/GIF、上限 8MB),出圖 endpoint `/media/[...key]`(公開、`immutable` cache)。換圖/刪除時由 `lib/media.ts``deleteMedia` 清舊 R2 檔。
- 前台可見性靠 `status``published` / `draft`);列表頁顯示全部,前台只顯示 published。
+1
View File
@@ -39,6 +39,7 @@ export function Footer({ settings }: { settings: Settings }) {
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
{ label: "私隱政策", href: "/privacy" },
].map((l) => (
<Link
key={l.href}
+2 -1
View File
@@ -14,11 +14,12 @@ D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`setting
- **查詢集中**:所有前台/Blog 的 D1 查詢寫喺 `content.ts`,唔好散落喺頁面或元件。
- **只回前台可見**`getItems` / `getCases` / `getPublishedPosts` 只回 `status = "published"`,並按 `sortOrder``publishedAt DESC` 排序;`getPostBySlug` 同時要求 published。
- **首頁聚合**`getHomeData(db)``Promise.all` 一次過攞 settings + services/features/steps/faqs + cases,形狀係 `HomeData`
- **後台總覽聚合**`getDashboardData(db)``DashboardData``posts``cases``content``keywords` 各狀態計數+`recentPosts` 最新 5 篇+`postsWithoutCover`),供 `/admin` dashboard 用;只讀 DBAI secrets 由頁面層 `getEnv()` 提供。
- **關鍵字佇列**`getKeywords(db)` 回全部 `blog_keywords`(按 `createdAt``id` 排序),供 `/admin/ai` 用。
- **Slug 去重**`uniqueSlug(db, base, selfId?)` 撞 slug 就加 `-2``-3`…;`selfId` 用喺更新自己時排除自己。admin 文章同 AI 生成都用。
- **`Db` 型別**`DrizzleD1Database<typeof schema>`
- **連結 helper**`whatsappHref` / `telHref` / `mailHref` / `digits` / `formatDate`;電話/WhatsApp 一律經呢啲 helper,唔好散寫。
- **設定欄位**`settings-fields.ts``SETTINGS_GROUPS``/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `scripts/seed.sql`
- **設定欄位**`settings-fields.ts``SETTINGS_GROUPS``/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `scripts/seed.sql`追蹤分析欄位(`ga4_measurement_id` / `meta_pixel_id`,空字串=關閉)由 `Base.astro` 消費,見父層 `src/AGENTS.md` 的 Analytics & consent。
- **圖片欄位**`FieldType``"image"``hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
- 顯示用文案繁體中文。
+49 -1
View File
@@ -1,9 +1,10 @@
import { and, asc, desc, eq, ne } from "drizzle-orm";
import { and, asc, desc, eq, isNull, ne, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import {
blogKeywords,
cases,
contentItems,
CONTENT_KINDS,
posts,
siteSettings,
type BlogKeyword,
@@ -71,6 +72,53 @@ export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
.orderBy(asc(blogKeywords.createdAt), asc(blogKeywords.id));
}
export type DashboardData = {
posts: { published: number; draft: number };
cases: { published: number; draft: number };
content: { kind: ContentKind; published: number; draft: number }[];
keywords: { pending: number; generated: number; skipped: number };
recentPosts: Post[];
postsWithoutCover: number;
};
/** 後台總覽用嘅統計(只讀 DB;AI secrets 由頁面層提供)。 */
export async function getDashboardData(db: Db): Promise<DashboardData> {
const [postRows, caseRows, itemRows, kwRows, recentPosts, noCover] = await Promise.all([
db.select({ status: posts.status, n: sql<number>`count(*)` }).from(posts).groupBy(posts.status),
db.select({ status: cases.status, n: sql<number>`count(*)` }).from(cases).groupBy(cases.status),
db
.select({ kind: contentItems.kind, status: contentItems.status, n: sql<number>`count(*)` })
.from(contentItems)
.groupBy(contentItems.kind, contentItems.status),
db
.select({ status: blogKeywords.status, n: sql<number>`count(*)` })
.from(blogKeywords)
.groupBy(blogKeywords.status),
db.select().from(posts).orderBy(desc(posts.updatedAt)).limit(5),
db.select({ n: sql<number>`count(*)` }).from(posts).where(isNull(posts.coverImage)),
]);
const pick = <T extends string>(rows: { status: T; n: number }[], status: T) =>
Number(rows.find((r) => r.status === status)?.n ?? 0);
return {
posts: { published: pick(postRows, "published"), draft: pick(postRows, "draft") },
cases: { published: pick(caseRows, "published"), draft: pick(caseRows, "draft") },
content: CONTENT_KINDS.map((kind) => ({
kind,
published: Number(itemRows.find((r) => r.kind === kind && r.status === "published")?.n ?? 0),
draft: Number(itemRows.find((r) => r.kind === kind && r.status === "draft")?.n ?? 0),
})),
keywords: {
pending: pick(kwRows, "pending"),
generated: pick(kwRows, "generated"),
skipped: pick(kwRows, "skipped"),
},
recentPosts,
postsWithoutCover: Number(noCover[0]?.n ?? 0),
};
}
export async function getHomeData(db: Db): Promise<HomeData> {
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
getSettings(db),
+15
View File
@@ -50,6 +50,21 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
{ key: "og_image", label: "分享圖片 (OG image)", type: "image" },
],
},
{
title: "追蹤分析",
fields: [
{
key: "ga4_measurement_id",
label: "GA4 評估 ID",
placeholder: "G-XXXXXXXXXX",
},
{
key: "meta_pixel_id",
label: "Meta Pixel ID",
placeholder: "123456789012345",
},
],
},
];
export const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
+164 -64
View File
@@ -4,18 +4,34 @@ interface Props {
}
const { title } = Astro.props;
const path = Astro.url.pathname;
const links = [
{ href: "/admin", label: "文章" },
{ href: "/admin/content/service", label: "服務" },
{ href: "/admin/content/feature", label: "特色" },
{ href: "/admin/content/step", label: "流程" },
{ href: "/admin/content/faq", label: "常見問題" },
{ href: "/admin/cases", label: "案例" },
{ href: "/admin/settings", label: "設定" },
{ href: "/admin/ai", label: "AI 生成" },
const groups: { label: string; links: { href: string; label: string }[] }[] = [
{ label: "總覽", links: [{ href: "/admin", label: "總覽" }] },
{
label: "內容管理",
links: [
{ href: "/admin/posts", label: "文章" },
{ href: "/admin/content/service", label: "服務" },
{ href: "/admin/content/feature", label: "特色" },
{ href: "/admin/content/step", label: "流程" },
{ href: "/admin/content/faq", label: "常見問題" },
{ href: "/admin/cases", label: "完成案例" },
],
},
{
label: "系統",
links: [
{ href: "/admin/ai", label: "AI 生成" },
{ href: "/admin/settings", label: "網站設定" },
],
},
];
const isActive = (href: string) =>
href === "/admin" ? path === "/admin" : path === href || path.startsWith(href + "/");
const isActive = (href: string) => {
if (href === "/admin") return path === "/admin";
if (href === "/admin/posts") return path === "/admin/posts" || path.startsWith("/admin/post/");
return path === href || path.startsWith(href + "/");
};
---
<!doctype html>
@@ -27,28 +43,45 @@ const isActive = (href: string) =>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<header class="bar">
<div class="inner">
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
<div class="shell">
<aside class="sidebar">
<a class="brand" href="/admin">
<span class="dot"></span>
盈豐太陽能 · 後台
<span class="brand-name">盈豐太陽能</span>
<span class="brand-sub">後台</span>
</a>
<nav>
<nav class="nav">
{
links.map((l) => (
<a href={l.href} class={isActive(l.href) ? "active" : ""}>
{l.label}
</a>
groups.map((g) => (
<>
<p class="nav-group">{g.label}</p>
{g.links.map((l) => (
<a href={l.href} class={isActive(l.href) ? "active" : ""}>
{l.label}
</a>
))}
</>
))
}
<a href="/" target="_blank">睇網站</a>
<a href="/admin/logout">登出</a>
</nav>
<div class="sidebar-foot">
<a href="/" target="_blank">睇網站 ↗</a>
<a href="/admin/logout">登出</a>
</div>
</aside>
<div class="content">
<header class="topbar">
<label for="nav-toggle" class="hamburger" aria-label="開合導航">☰</label>
<a class="brand" href="/admin">
<span class="brand-name">盈豐太陽能</span>
<span class="brand-sub">後台</span>
</a>
</header>
<main class="main">
<slot />
</main>
</div>
</header>
<main class="inner">
<slot />
</main>
</div>
<script>
const MAX_WIDTH = 1600;
const QUALITY = 0.85;
@@ -136,72 +169,139 @@ const isActive = (href: string) =>
<style is:global>
:root {
--ink: #1a1917;
--ink-2: #24221f;
--ink-3: #2e2b27;
--bg: #faf8f4;
--surface: #fff;
--fg: #1a1917;
--fg-muted: #6e6862;
--border: #e7e2d9;
--brand: #d97706;
--brand-strong: #b45309;
--brand-soft: #fef3c7;
--ok: #0f6e56;
--ok-soft: #e1f5ee;
--warn: #854f0b;
--warn-soft: #faeeda;
--danger: #a32d2d;
--danger-soft: #fcebeb;
--radius-sm: 4px;
--radius: 8px;
--sidebar-w: 248px;
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
color: #0f1e1a;
background: #f2f8f6;
color: var(--fg);
background: var(--bg);
line-height: 1.65;
font-size: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; }
a { color: #0b8a5e; text-decoration: none; }
a { color: var(--brand-strong); text-decoration: none; }
a:hover { text-decoration: underline; }
.inner { max-width: 920px; margin: 0 auto; padding: 0 24px; }
.bar { background: #fff; border-bottom: 1px solid rgba(0,0,0,.08); position: sticky; top: 0; z-index: 10; }
.bar .inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; flex-wrap: wrap; }
.brand { font-weight: 800; color: #0f1e1a; display: inline-flex; align-items: center; gap: 8px; }
.shell { display: flex; min-height: 100vh; }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: var(--sidebar-w);
background: var(--ink);
color: #edeae4;
display: flex;
flex-direction: column;
padding: 22px 14px;
z-index: 20;
}
.brand { display: flex; flex-direction: column; gap: 2px; padding: 0 10px 18px; color: #fff; }
.brand:hover { text-decoration: none; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: linear-gradient(135deg,#0b8a5e,#0ba5ec); display: inline-block; }
.bar nav { display: flex; gap: 14px; font-size: 13.5px; flex-wrap: wrap; }
.bar nav a { color: #33413c; }
.bar nav a.active { color: #0b8a5e; font-weight: 700; }
.brand-name { font-family: "Noto Serif HK", serif; font-weight: 900; font-size: 17px; letter-spacing: 0.02em; }
.brand-sub { font-size: 11px; color: #9a938a; letter-spacing: 0.28em; }
.nav { display: flex; flex-direction: column; gap: 2px; flex: 1; overflow: auto; }
.nav-group { margin: 16px 10px 6px; font-size: 10.5px; letter-spacing: 0.22em; color: #7e776e; }
.nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: var(--radius-sm);
color: #cfc9c1;
font-size: 13.5px;
border-left: 3px solid transparent;
}
.nav a:hover { background: var(--ink-2); color: #fff; text-decoration: none; }
.nav a.active { background: var(--ink-2); color: var(--brand); border-left-color: var(--brand); font-weight: 700; }
.sidebar-foot { display: flex; flex-direction: column; gap: 4px; border-top: 1px solid var(--ink-3); padding-top: 14px; }
.sidebar-foot a { padding: 8px 10px; color: #9a938a; font-size: 13px; }
.sidebar-foot a:hover { color: #fff; text-decoration: none; }
.content { flex: 1; margin-left: var(--sidebar-w); min-width: 0; }
.main { max-width: 1080px; padding: 36px 40px 100px; margin: 0 auto; }
.topbar { display: none; }
main { padding: 32px 24px 100px; }
h1 { font-size: 22px; font-weight: 800; margin: 0 0 6px; }
.sub { color: #5b6b66; font-size: 13.5px; margin: 0 0 24px; }
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: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; padding: 20px 22px; margin-bottom: 18px; }
.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; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; overflow: hidden; }
th { text-align: left; font-weight: 600; font-size: 12px; color: #5b6b66; padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,.08); }
td { padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,.05); font-size: 14px; vertical-align: middle; }
table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
th { text-align: left; font-weight: 600; font-size: 12px; color: var(--fg-muted); padding: 10px 14px; border-bottom: 1px solid var(--border); }
td { padding: 12px 14px; border-bottom: 1px solid var(--border); font-size: 14px; vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
label { display: block; font-size: 13px; color: #5b6b66; margin: 14px 0 6px; font-weight: 600; }
input[type="text"], input[type="url"], textarea, select {
label { display: block; font-size: 13px; color: var(--fg-muted); margin: 14px 0 6px; font-weight: 600; }
input[type="text"], input[type="url"], input[type="password"], textarea, select {
width: 100%; font: inherit; font-size: 14px; padding: 9px 12px;
border: 1px solid rgba(0,0,0,.15); border-radius: 9px; background: #fff; color: #0f1e1a;
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--fg);
}
textarea { min-height: 88px; resize: vertical; }
input:focus, textarea:focus, select:focus { outline: 2px solid #9fdcc2; border-color: #0b8a5e; }
input:focus, textarea:focus, select:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
button, .btn {
font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: 9px; cursor: pointer;
border: 1px solid #0b8a5e; background: #0b8a5e; color: #fff; display: inline-flex; align-items: center; gap: 6px;
font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: var(--radius-sm); cursor: pointer;
border: 1px solid var(--ink); background: var(--ink); color: #fff; display: inline-flex; align-items: center; gap: 6px;
}
button:hover { background: #0a6f4c; }
button.secondary, .btn.secondary { background: #fff; color: #0f1e1a; border-color: rgba(0,0,0,.15); }
button.secondary:hover { background: #f2f8f6; }
button.danger { background: #fff; color: #a32d2d; border-color: #f09595; }
button.mini { padding: 5px 10px; font-size: 12.5px; border-radius: 7px; }
button:hover, .btn:hover { background: #000; }
button.secondary, .btn.secondary { background: var(--surface); color: var(--fg); border-color: var(--border); }
button.secondary:hover { background: var(--bg); }
button.danger { background: var(--surface); color: var(--danger); border-color: #f09595; }
button.danger:hover { background: var(--danger-soft); }
button.mini { padding: 5px 10px; font-size: 12.5px; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.actions { display: flex; gap: 10px; align-items: center; margin-top: 20px; flex-wrap: wrap; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 20px; border: 1px solid transparent; }
.badge.published { background: #e1f5ee; color: #0f6e56; }
.badge.draft { background: #faeeda; color: #854f0b; }
.muted { color: #8a9793; font-size: 13px; }
.field-error { color: #a32d2d; font-size: 12.5px; font-weight: 600; margin-left: 8px; }
.err { color: #a32d2d; font-size: 13px; font-weight: 600; }
.badge.published { background: var(--ok-soft); color: var(--ok); }
.badge.draft { background: var(--warn-soft); color: var(--warn); }
.notice { display: inline-block; background: var(--ok-soft); color: var(--ok); font-size: 13px; font-weight: 700; padding: 8px 14px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.muted { color: var(--fg-muted); font-size: 13px; }
.field-error { color: var(--danger); font-size: 12.5px; font-weight: 600; margin-left: 8px; }
.err { color: var(--danger); font-size: 13px; font-weight: 600; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
.image-field { display: block; }
.image-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.image-actions input[type="file"] { font: inherit; font-size: 13px; max-width: 100%; }
.image-status { font-size: 12.5px; font-weight: 600; color: #0b8a5e; }
.image-status.error { color: #a32d2d; }
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; }
.image-status { font-size: 12.5px; font-weight: 600; color: var(--ok); }
.image-status.error { color: var(--danger); }
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: var(--radius); }
.image-preview[hidden] { display: none; }
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
@media (max-width: 640px) {
.grid2 { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.sidebar { transform: translateX(-100%); transition: transform 0.2s ease; }
#nav-toggle:checked ~ .sidebar { transform: translateX(0); }
.content { margin-left: 0; }
.topbar {
display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 15;
background: var(--surface); border-bottom: 1px solid var(--border); padding: 12px 18px;
}
.topbar .brand { flex-direction: row; align-items: baseline; gap: 8px; padding: 0; color: var(--ink); }
.topbar .brand-sub { color: var(--fg-muted); }
.hamburger { cursor: pointer; font-size: 20px; line-height: 1; color: var(--ink); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 10px; }
.main { padding: 24px 18px 80px; }
}
</style>
+173
View File
@@ -9,6 +9,8 @@ interface Props {
publishedTime?: Date | null;
image?: string;
jsonLd?: object | null | (object | null)[];
gaId?: string;
metaPixelId?: string;
}
const {
@@ -19,8 +21,12 @@ const {
publishedTime = null,
image,
jsonLd = null,
gaId = "",
metaPixelId = "",
} = Astro.props;
const hasAnalytics = Boolean(gaId || metaPixelId);
const siteName = SITE_NAME;
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
const canonical = new URL(Astro.url.pathname, origin + "/").toString();
@@ -61,6 +67,65 @@ const jsonLdHtml = jsonLdItems.length
{jsonLdHtml && <script type="application/ld+json" is:inline set:html={jsonLdHtml} />}
{
hasAnalytics && (
<script is:inline define:vars={{ gaId, metaPixelId }}>
window.__analyticsIds = { gaId, metaPixelId };
window.__loadAnalytics = function () {
var ids = window.__analyticsIds || {};
if (window.__analyticsLoaded) return;
window.__analyticsLoaded = true;
if (ids.gaId) {
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
window.gtag("config", ids.gaId);
var gaScript = document.createElement("script");
gaScript.async = true;
gaScript.src =
"https://www.googletagmanager.com/gtag/js?id=" +
encodeURIComponent(ids.gaId);
document.head.appendChild(gaScript);
}
if (ids.metaPixelId) {
(function (f, b, e, v, n, t, s) {
if (f.fbq) return;
n = f.fbq = function () {
n.callMethod
? n.callMethod.apply(n, arguments)
: n.queue.push(arguments);
};
if (!f._fbq) f._fbq = n;
n.push = n;
n.loaded = true;
n.version = "2.0";
n.queue = [];
t = b.createElement(e);
t.async = true;
t.src = v;
s = b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t, s);
})(
window,
document,
"script",
"https://connect.facebook.net/en_US/fbevents.js",
);
window.fbq("init", ids.metaPixelId);
window.fbq("track", "PageView");
}
};
try {
if (window.localStorage.getItem("cookie_consent") === "granted") {
window.__loadAnalytics();
}
} catch (e) {}
</script>
)
}
<link rel="sitemap" href="/sitemap.xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -79,6 +144,58 @@ const jsonLdHtml = jsonLdItems.length
</head>
<body>
<slot />
{
hasAnalytics && (
<div id="cookie-consent" class="cookie-consent" hidden>
<p class="cookie-consent__text">
我哋用 Cookie 分析網站使用情況(Google Analytics 同 Meta),以改善服務。你可以選擇接受或拒絕。
</p>
<div class="cookie-consent__actions">
<a class="cookie-consent__link" href="/privacy">私隱政策</a>
<button class="cookie-consent__btn" type="button" data-consent="denied">
拒絕
</button>
<button
class="cookie-consent__btn cookie-consent__btn--primary"
type="button"
data-consent="granted"
>
接受
</button>
</div>
</div>
)
}
{
hasAnalytics && (
<script is:inline>
(function () {
var el = document.getElementById("cookie-consent");
if (!el) return;
var choice = null;
try {
choice = window.localStorage.getItem("cookie_consent");
} catch (e) {}
if (!choice) el.hidden = false;
el.addEventListener("click", function (event) {
var target = event.target;
var btn = target && target.closest
? target.closest("[data-consent]")
: null;
if (!btn) return;
var value = btn.getAttribute("data-consent");
try {
window.localStorage.setItem("cookie_consent", value);
} catch (e) {}
if (value === "granted" && typeof window.__loadAnalytics === "function") {
window.__loadAnalytics();
}
el.hidden = true;
});
})();
</script>
)
}
</body>
</html>
@@ -90,4 +207,60 @@ const jsonLdHtml = jsonLdItems.length
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, -apple-system, sans-serif;
background: #faf8f4;
}
.cookie-consent[hidden] {
display: none;
}
.cookie-consent {
position: fixed;
inset: auto 0 0 0;
z-index: 1000;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 24px;
padding: 16px 24px;
background: #faf8f4;
border-top: 1px solid #e5dfd6;
}
.cookie-consent__text {
margin: 0;
flex: 1 1 320px;
font-size: 13px;
line-height: 1.7;
color: #4a453f;
}
.cookie-consent__actions {
display: flex;
align-items: center;
gap: 12px;
}
.cookie-consent__link {
font-size: 13px;
color: #6e6862;
text-decoration: underline;
}
.cookie-consent__btn {
padding: 8px 20px;
font: inherit;
font-size: 13px;
font-weight: 700;
color: #1a1917;
background: transparent;
border: 1px solid #1a1917;
border-radius: 2px;
cursor: pointer;
}
.cookie-consent__btn--primary {
color: #fff;
background: #b45309;
border-color: #b45309;
}
</style>
+14 -1
View File
@@ -1,9 +1,22 @@
---
import Base from "../layouts/Base.astro";
import { SITE_NAME } from "../config";
import { getSettings } from "../data/content";
import { getDb } from "../lib/db";
import { getEnv } from "../lib/env";
export const prerender = false;
const settings = await getSettings(getDb(getEnv().DB));
---
<Base title="關於我哋" description={`${SITE_NAME} — 香港村屋太陽能一站式服務。`} noindex={true}>
<Base
title="關於我哋"
description={`${SITE_NAME} — 香港村屋太陽能一站式服務。`}
noindex={true}
gaId={settings.ga4_measurement_id}
metaPixelId={settings.meta_pixel_id}
>
<div style="max-width:640px;margin:15vh auto;padding:0 24px;text-align:center;font-family:'Noto Sans HK',sans-serif;">
<h1 style="font-size:28px;font-weight:800;color:#1A1917;">我哋嘅服務已整合到主頁</h1>
<p style="color:#6E6862;line-height:1.9;">請返去主頁睇晒我哋嘅村屋太陽能服務、案例同流程。</p>
+1 -3
View File
@@ -94,9 +94,7 @@ const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KE
{error && <p class="err" style="margin-bottom:16px">{error}</p>}
{
notice && (
<p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">
✓ {notice}
</p>
<p class="notice">✓ {notice}</p>
)
}
+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>
+10 -9
View File
@@ -44,16 +44,17 @@ if (Astro.request.method === "POST") {
<style is:global>
:root {
font-family: -apple-system, BlinkMacSystemFont, "PingFang HK", "Noto Sans HK", sans-serif;
color: #2c2c2a; background: #f7f7f5; line-height: 1.65; font-size: 15px;
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
color: #1a1917; background: #faf8f4; line-height: 1.65; font-size: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
main { width: 100%; max-width: 360px; padding: 32px; background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 12px; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; }
label { display: block; font-size: 13px; color: #5f5e5a; margin-bottom: 6px; }
input { width: 100%; font: inherit; font-size: 14px; padding: 9px 12px; border: 1px solid rgba(0,0,0,.15); border-radius: 8px; }
input:focus { outline: 2px solid #b5d4f4; border-color: #185fa5; }
button { margin-top: 18px; width: 100%; font: inherit; font-size: 14px; padding: 9px 18px; border-radius: 8px; cursor: pointer; border: 1px solid #185fa5; background: #185fa5; color: #fff; }
.err { background: #fcebeb; color: #a32d2d; padding: 10px 14px; border-radius: 8px; font-size: 14px; margin: 0 0 16px; }
main { width: 100%; max-width: 360px; padding: 32px; background: #fff; border: 1px solid #e7e2d9; border-radius: 8px; }
h1 { font-family: "Noto Serif HK", serif; font-size: 20px; font-weight: 900; margin: 0 0 20px; }
label { display: block; font-size: 13px; color: #6e6862; margin-bottom: 6px; font-weight: 600; }
input { width: 100%; font: inherit; font-size: 14px; padding: 9px 12px; border: 1px solid #e7e2d9; border-radius: 4px; }
input:focus { outline: 2px solid #fef3c7; border-color: #d97706; }
button { margin-top: 18px; width: 100%; font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: 4px; cursor: pointer; border: 1px solid #1a1917; background: #1a1917; color: #fff; }
button:hover { background: #000; }
.err { background: #fcebeb; color: #a32d2d; padding: 10px 14px; border-radius: 4px; font-size: 14px; margin: 0 0 16px; }
</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>
+1 -1
View File
@@ -59,7 +59,7 @@ const settings = await getSettings(db);
{error && <p class="err" style="margin-bottom:16px">{error}</p>}
{saved && <p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">✓ 已儲存</p>}
{saved && <p class="notice">✓ 已儲存</p>}
<form method="post">
{
+2
View File
@@ -41,6 +41,8 @@ const jsonLd = post
publishedTime={post.publishedAt}
image={post.coverImage ?? undefined}
jsonLd={jsonLd}
gaId={settings.ga4_measurement_id}
metaPixelId={settings.meta_pixel_id}
>
<BlogPost
title={post.title}
+2
View File
@@ -25,6 +25,8 @@ Astro.response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-re
title="太陽能知識庫"
description={settings.seo_default_description || "村屋太陽能嘅實用資訊、安裝心得同回本分析。"}
image={settings.og_image}
gaId={settings.ga4_measurement_id}
metaPixelId={settings.meta_pixel_id}
>
<BlogIndex posts={items} settings={settings} client:idle />
</Base>
+2
View File
@@ -26,6 +26,8 @@ Astro.response.headers.set(
description={settings.seo_default_description}
image={settings.og_image}
jsonLd={jsonLd}
gaId={settings.ga4_measurement_id}
metaPixelId={settings.meta_pixel_id}
>
<HomePage data={data} client:idle />
</Base>
+182
View File
@@ -0,0 +1,182 @@
---
import Base from "../layouts/Base.astro";
import { SITE_NAME } from "../config";
import { getSettings } from "../data/content";
import { getDb } from "../lib/db";
import { getEnv } from "../lib/env";
export const prerender = false;
const settings = await getSettings(getDb(getEnv().DB));
const siteName = settings.company_name || SITE_NAME;
const email = settings.email || "";
---
<Base
title="私隱政策"
description={`${siteName} 網站私隱政策:Cookie、分析工具同資料使用方式。`}
gaId={settings.ga4_measurement_id}
metaPixelId={settings.meta_pixel_id}
>
<main class="policy">
<p class="eyebrow">私隱政策</p>
<h1>Cookie 同資料使用</h1>
<p class="lead">
呢頁解釋我哋點樣喺 {siteName} 網站使用 Cookie 同分析工具。我哋只會喺你同意之後先載入分析工具,亦唔會出售你嘅個人資料。
</p>
<section>
<h2>1. 咩係 Cookie</h2>
<p>
Cookie 係網站存放喺你瀏覽器嘅細細檔案,用嚟記住你嘅設定同行為。你嘅瀏覽器亦可能使用類似技術(例如
localStorage)去記住你嘅選擇。
</p>
</section>
<section>
<h2>2. 我哋用嘅工具</h2>
<ul>
<li>
<strong>Google Analytics 4</strong> — 分析網站流量同瀏覽行為(例如瀏覽頁面、停留時間、裝置同來源),
幫我哋了解同改善網站內容。
</li>
<li>
<strong>Meta Pixel</strong> — 量度廣告同推廣活動嘅成效,了解訪客喺網站嘅互動情況。
</li>
</ul>
<p>
呢啲工具由第三方供應商(Google、Meta)提供,佢哋嘅資料處理方式受各自嘅私隱政策約束。我哋唔會透過呢啲工具收集你嘅姓名、電話或電郵等可直接識別身份嘅資料。
</p>
</section>
<section>
<h2>3. 你嘅選擇</h2>
<p>
你入到網站時會見到 Cookie 提示,可以選擇「接受」或「拒絕」。
<strong>如果你拒絕,我哋唔會載入 Google Analytics 或 Meta Pixel,亦唔會寫入相關 Cookie。</strong>
你嘅選擇會存放喺你嘅瀏覽器;你亦可隨時撳下面嘅按鈕清除選擇,下次再重新決定。
</p>
<button id="reset-consent" type="button">重設 Cookie 偏好</button>
</section>
<section>
<h2>4. 資料保留同安全</h2>
<p>
分析資料一般以匿名化或彙總形式保存,並只會保留達到上述目的所需嘅時間。我哋會以合理嘅技術同管理措施保障資料安全。
</p>
</section>
<section>
<h2>5. 查詢</h2>
<p>
如你對我哋嘅 Cookie 使用或私隱做法有任何查詢,歡迎電郵
{email ? <a href={`mailto:${email}`}>{email}</a> : "我哋"} 同我哋聯絡。
</p>
</section>
<p class="back"><a href="/">← 返去主頁</a></p>
</main>
</Base>
<script is:inline>
(function () {
var btn = document.getElementById("reset-consent");
if (!btn) return;
btn.addEventListener("click", function () {
try {
window.localStorage.removeItem("cookie_consent");
} catch (e) {}
window.location.reload();
});
})();
</script>
<style>
.policy {
max-width: 680px;
margin: 0 auto;
padding: 96px 24px 120px;
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
color: #2f2b27;
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #b45309;
}
h1 {
margin: 0 0 24px;
font-family: "Noto Serif HK", serif;
font-size: 34px;
font-weight: 700;
color: #1a1917;
}
.lead {
margin: 0 0 40px;
font-size: 16px;
line-height: 1.9;
color: #4a453f;
}
section {
margin: 0 0 36px;
padding-top: 28px;
border-top: 1px solid #e5dfd6;
}
h2 {
margin: 0 0 12px;
font-family: "Noto Serif HK", serif;
font-size: 20px;
font-weight: 700;
color: #1a1917;
}
p,
li {
font-size: 15px;
line-height: 1.9;
color: #4a453f;
}
ul {
margin: 0 0 12px;
padding-left: 20px;
}
li {
margin-bottom: 8px;
}
a {
color: #b45309;
}
button {
margin-top: 8px;
padding: 10px 22px;
font: inherit;
font-size: 14px;
font-weight: 700;
color: #1a1917;
background: transparent;
border: 1px solid #1a1917;
border-radius: 2px;
cursor: pointer;
}
button:hover {
background: #1a1917;
color: #faf8f4;
}
.back {
margin-top: 48px;
font-size: 14px;
}
</style>