Compare commits
10
Commits
6dedb5469f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2ed9f950 | ||
|
|
aa86f1f5ed | ||
|
|
cc142bfd06 | ||
|
|
9eed1d32d3 | ||
|
|
6836cd529e | ||
|
|
b8c1ee1f2d | ||
|
|
a296ba4ce1 | ||
|
|
4f630afafb | ||
|
|
e7ddbb6654 | ||
|
|
fbc562f495 |
+5
-5
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"pid": 52048,
|
"pid": 78610,
|
||||||
"port": 4322,
|
"port": 4323,
|
||||||
"url": "http://localhost:4322",
|
"url": "http://localhost:4323",
|
||||||
"urls": {
|
"urls": {
|
||||||
"local": [
|
"local": [
|
||||||
"http://localhost:4322/"
|
"http://localhost:4323/"
|
||||||
],
|
],
|
||||||
"network": [],
|
"network": [],
|
||||||
"networkInterfaceNames": []
|
"networkInterfaceNames": []
|
||||||
},
|
},
|
||||||
"background": false,
|
"background": false,
|
||||||
"startedAt": "2026-09-13T08:12:40.425Z"
|
"startedAt": "2026-09-13T13:48:49.043Z"
|
||||||
}
|
}
|
||||||
Vendored
-1
@@ -1,3 +1,2 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
/// <reference path="integrations/_astrojs_cloudflare/cloudflare.d.ts" />
|
/// <reference path="integrations/_astrojs_cloudflare/cloudflare.d.ts" />
|
||||||
/// <reference path="content.d.ts" />
|
|
||||||
@@ -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`。
|
- **`wrangler.jsonc` 有佔位 id**:`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。R2 bucket 唔使貼 id,用 `r2:create`(或 `npm run setup`)建立即可,binding 係 `MEDIA`。
|
||||||
- **圖片上傳**:圖片存 Cloudflare R2(binding `MEDIA`、bucket `yingfung-solar-media`),由同一個 Worker 的 `/media/*` 出圖;上傳經 `/admin/upload`,client 端先縮圖轉 WebP。本機靠 `platformProxy` 模擬 R2,雲端要先 `npm run r2:create`。圖片欄位仍可貼 URL;換圖/刪除時 `lib/media.ts` 會清舊 R2 檔。
|
- **圖片上傳**:圖片存 Cloudflare R2(binding `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`)。
|
- **上線前要改 `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。
|
- **後台認證**:`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。
|
- **Chakra v3 + Emotion**:前台 React 元件要用 `Provider` / `SiteChrome`(內含 `<ChakraProvider value={system}>`),theme 在 `src/theme/system.ts`。Astro 以 `client:load` 掛 island。
|
||||||
- **語言**:UI 文案、註解、後台全部都係繁體中文(廣東話)——新增內容請保持一致;網站只做中文,不做雙語。
|
- **語言**:UI 文案、註解、後台全部都係繁體中文(廣東話)——新增內容請保持一致;網站只做中文,不做雙語。
|
||||||
|
|||||||
@@ -50,21 +50,38 @@ npm run dev
|
|||||||
|
|
||||||
## 部署到 Cloudflare
|
## 部署到 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
|
```bash
|
||||||
npm run login # 瀏覽器授權
|
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/seed?build + deploy?若 `site` 仍係 `https://example.com`,會跳過 build + deploy 並提醒你改(改完再 `npm run deploy`)。
|
||||||
|
|
||||||
### 之後更新
|
### 3. 之後更新
|
||||||
|
|
||||||
改完 `astro.config.mjs` 嘅 `site` 做真域名後:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run deploy
|
npm run deploy # = npm run build + wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
### Git push 自動部署(Cloudflare Workers Builds)
|
### Git push 自動部署(Cloudflare Workers Builds)
|
||||||
@@ -76,6 +93,7 @@ npm run deploy
|
|||||||
|
|
||||||
**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑
|
**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑
|
||||||
`npm run db:generate` → `npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。
|
`npm run db:generate` → `npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。
|
||||||
|
`seed.sql` 放喺 `scripts/`(唔可以放喺 `migrations/`,否則 `db:migrate` 會連 seed 一齊種)。
|
||||||
|
|
||||||
## AI 生成 Blog
|
## AI 生成 Blog
|
||||||
|
|
||||||
@@ -110,12 +128,13 @@ src/
|
|||||||
├─ layouts/AdminLayout.astro 後台外框
|
├─ layouts/AdminLayout.astro 後台外框
|
||||||
├─ pages/
|
├─ pages/
|
||||||
│ ├─ index.astro SSR 首頁
|
│ ├─ index.astro SSR 首頁
|
||||||
|
│ ├─ about.astro 關於(靜態)
|
||||||
│ ├─ blog/… Blog
|
│ ├─ blog/… Blog
|
||||||
│ ├─ admin/… 後台(含 admin/ai、admin/upload 上傳 endpoint)
|
│ ├─ admin/… 後台(含 admin/ai、admin/upload 上傳 endpoint)
|
||||||
│ ├─ media/[...key].ts R2 圖片出圖 endpoint
|
│ ├─ media/[...key].ts R2 圖片出圖 endpoint
|
||||||
│ └─ sitemap.xml.ts / robots.txt.ts
|
│ └─ sitemap.xml.ts / robots.txt.ts
|
||||||
├─ components/admin/ImageField.astro 後台圖片欄(URL + 上傳 + 預覽)
|
├─ 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/
|
migrations/
|
||||||
├─ 0000_init.sql … Drizzle migrations
|
├─ 0000_init.sql … Drizzle migrations
|
||||||
scripts/
|
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` 做單一 shell(sidebar + CSS variables),保留原有 R2 上傳 script。新增 `getDashboardData(db)` 喺 `src/data/content.ts` 提供統計,唔改 schema。`index.astro` 改寫成 dashboard,新增 `posts.astro` 承接原文章列表。
|
||||||
|
|
||||||
|
**Tech Stack:** Astro 7 SSR、Drizzle/D1、純 CSS(CSS variables)。驗證=`npm run build`(專案冇 test/lint/typecheck)。
|
||||||
|
|
||||||
|
**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: 換 form/table/card 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: success(0 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`** 成 dashboard(frontmatter 用 `getDashboardData` + `getSettings`,砌 `todos`;markup 見 spec §3)。
|
||||||
|
- [x] **Step 3: 加 dashboard CSS**(`.stats`/`.stat`/`.dash`/`.todo`/`.status-row`/`.status-dot`,RWD breakpoints 1100/900/560)。
|
||||||
|
- [x] **Step 4: 改 redirect** — `post/[id].astro` line 29/39/96 `"/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 逐頁睇 cases/content 四 kind/settings/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 + CSS(CSS 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` 只讀 DB;AI 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`(唯一自動驗證;專案冇 test/lint/typecheck)。
|
||||||
|
- `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
@@ -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),
|
('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_title', '盈豐太陽能工程有限公司 — 香港村屋太陽能一站式服務', strftime('%s','now')*1000),
|
||||||
('seo_default_description', '專營香港村屋太陽能系統:現場評估、合法支架、代辦申請、專業安裝及驗收認證。透明報價,專人跟進,幫你善用天台賺取發電回報。', 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
|
INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
|
||||||
('ai_enabled', '1', strftime('%s','now')*1000),
|
('ai_enabled', '1', strftime('%s','now')*1000),
|
||||||
|
|||||||
+16
-6
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
|
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
|
||||||
- `theme/system.ts` — Chakra system(tokens / semanticTokens / fonts / globalCss),前台顏色同字體的唯一來源。現行設計:editorial 極簡風(暖米白 `#FAF8F4` 底、琥珀金 `brand` accent、墨黑 `ink`、Noto Serif HK 標題),詳見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`。
|
- `theme/system.ts` — Chakra system(tokens / 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 head(canonical / OG / Twitter / theme-color / JSON-LD `jsonLd` prop / sitemap link)+ Noto Sans HK(非阻塞載入,`media="print" onload` + `<noscript>` fallback)。
|
- `layouts/Base.astro` — 前台 SEO head(canonical / 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` — 後台外框,純 CSS、與前台一致的色系。
|
- `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,唔放呢度)。
|
- `config.ts` — build-time 靜態頁用的 `SITE_NAME` / `SITE_TAGLINE`(可管理內容一律放 D1,唔放呢度)。
|
||||||
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`。
|
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`。
|
||||||
- `env.d.ts` — `App.Locals.isAdmin` 型別宣告。
|
- `env.d.ts` — `App.Locals.isAdmin` 型別宣告。
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
### Routes & SSR(`pages/`)
|
### Routes & SSR(`pages/`)
|
||||||
|
|
||||||
- **預設靜態**:`astro.config.mjs` 係 `output: "static"`。要讀 D1 或 request-time 資料的頁面/endpoint 必須在檔案內寫 `export const prerender = false`。
|
- **預設靜態**:`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`。
|
- 目前 SSR:`index.astro`、`about.astro`、`privacy.astro`、`blog/index.astro`、`blog/[slug].astro`、`admin/**`、`media/[...key].ts`、`sitemap.xml.ts`。
|
||||||
- 保持靜態:`about.astro`、`robots.txt.ts`。
|
- 保持靜態:`robots.txt.ts`。
|
||||||
- **環境變數**:只用 `getEnv()`,且只可喺 `prerender = false` 的頁面/endpoint 用。
|
- **環境變數**:只用 `getEnv()`,且只可喺 `prerender = false` 的頁面/endpoint 用。
|
||||||
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island(`client:idle`,內容仍 SSR 出 HTML),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`。
|
- **前台動態頁**:讀 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 頁。
|
- **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`,唔使改現有流程。
|
- **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`。
|
- **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 表/API/React 動態注入(本站係 MPA,每次載入=一次 pageview)。
|
||||||
|
|
||||||
### Admin(`pages/admin/`)
|
### Admin(`pages/admin/`)
|
||||||
|
|
||||||
- 所有 `/admin` 路由**必須** `export const prerender = false`。
|
- 所有 `/admin` 路由**必須** `export const prerender = false`。
|
||||||
|
- **視覺**:全部用 `AdminLayout.astro` 的墨黑 sidebar shell,色值一律用其 CSS variables(唔好硬編色)。`/admin` 係總覽 dashboard。
|
||||||
- **認證**由 `middleware.ts` 統一處理;登入喺 `admin/login.astro`(`checkPassword` + `createSession`),登出喺 `logout.ts`。
|
- **認證**由 `middleware.ts` 統一處理;登入喺 `admin/login.astro`(`checkPassword` + `createSession`),登出喺 `logout.ts`。
|
||||||
- **UI**:全部用 `AdminLayout.astro`,純 Astro SSR 表單(`POST` + `formData`),**唔引入** React / Chakra。
|
- **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` 逐欄顯示,唔好手寫逐欄檢查。
|
- **表單驗證**:所有 admin 表單經 `parseForm(form, schema)`(`src/schemas/`),失敗時以 `errors` 逐欄顯示,唔好手寫逐欄檢查。
|
||||||
- **通用動作**:`add` / `save` / `delete` / `up` / `down`(排序以交換 `sortOrder` 實作)。
|
- **通用動作**:`add` / `save` / `delete` / `up` / `down`(排序以交換 `sortOrder` 實作)。
|
||||||
- **通用內容編輯器**:`content/[kind].astro`,`kind ∈ service | feature | step | faq`(見 `db/schema.ts` 的 `CONTENT_KINDS`),欄位標籤由檔案內 `META` 定義。
|
- **通用內容編輯器**:`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。
|
- **網站設定**:`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` 顯示「生成中…」。
|
- **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`),亦可貼 URL;client 端縮到最大寬 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 檔。
|
- **圖片**:圖片欄位(settings 的 `hero_image` / `og_image`、`cases.imageUrl`、`posts.coverImage`)用 `components/admin/ImageField.astro`,可揀檔即時上傳去 R2(binding `MEDIA`),亦可貼 URL;client 端縮到最大寬 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。
|
- 前台可見性靠 `status`(`published` / `draft`);列表頁顯示全部,前台只顯示 published。
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function Footer({ settings }: { settings: Settings }) {
|
|||||||
{ label: "服務流程", href: "#process" },
|
{ label: "服務流程", href: "#process" },
|
||||||
{ label: "常見問題", href: "#faq" },
|
{ label: "常見問題", href: "#faq" },
|
||||||
{ label: "Blog", href: "/blog" },
|
{ label: "Blog", href: "/blog" },
|
||||||
|
{ label: "私隱政策", href: "/privacy" },
|
||||||
].map((l) => (
|
].map((l) => (
|
||||||
<Link
|
<Link
|
||||||
key={l.href}
|
key={l.href}
|
||||||
|
|||||||
+2
-1
@@ -14,11 +14,12 @@ D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`setting
|
|||||||
- **查詢集中**:所有前台/Blog 的 D1 查詢寫喺 `content.ts`,唔好散落喺頁面或元件。
|
- **查詢集中**:所有前台/Blog 的 D1 查詢寫喺 `content.ts`,唔好散落喺頁面或元件。
|
||||||
- **只回前台可見**:`getItems` / `getCases` / `getPublishedPosts` 只回 `status = "published"`,並按 `sortOrder` 或 `publishedAt DESC` 排序;`getPostBySlug` 同時要求 published。
|
- **只回前台可見**:`getItems` / `getCases` / `getPublishedPosts` 只回 `status = "published"`,並按 `sortOrder` 或 `publishedAt DESC` 排序;`getPostBySlug` 同時要求 published。
|
||||||
- **首頁聚合**:`getHomeData(db)` 用 `Promise.all` 一次過攞 settings + services/features/steps/faqs + cases,形狀係 `HomeData`。
|
- **首頁聚合**:`getHomeData(db)` 用 `Promise.all` 一次過攞 settings + services/features/steps/faqs + cases,形狀係 `HomeData`。
|
||||||
|
- **後台總覽聚合**:`getDashboardData(db)` 回 `DashboardData`(`posts`/`cases`/`content`/`keywords` 各狀態計數+`recentPosts` 最新 5 篇+`postsWithoutCover`),供 `/admin` dashboard 用;只讀 DB,AI secrets 由頁面層 `getEnv()` 提供。
|
||||||
- **關鍵字佇列**:`getKeywords(db)` 回全部 `blog_keywords`(按 `createdAt`、`id` 排序),供 `/admin/ai` 用。
|
- **關鍵字佇列**:`getKeywords(db)` 回全部 `blog_keywords`(按 `createdAt`、`id` 排序),供 `/admin/ai` 用。
|
||||||
- **Slug 去重**:`uniqueSlug(db, base, selfId?)` 撞 slug 就加 `-2`、`-3`…;`selfId` 用喺更新自己時排除自己。admin 文章同 AI 生成都用。
|
- **Slug 去重**:`uniqueSlug(db, base, selfId?)` 撞 slug 就加 `-2`、`-3`…;`selfId` 用喺更新自己時排除自己。admin 文章同 AI 生成都用。
|
||||||
- **`Db` 型別**:`DrizzleD1Database<typeof schema>`。
|
- **`Db` 型別**:`DrizzleD1Database<typeof schema>`。
|
||||||
- **連結 helper**:`whatsappHref` / `telHref` / `mailHref` / `digits` / `formatDate`;電話/WhatsApp 一律經呢啲 helper,唔好散寫。
|
- **連結 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)。
|
- **圖片欄位**:`FieldType` 有 `"image"`;`hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
|
||||||
- 顯示用文案繁體中文。
|
- 顯示用文案繁體中文。
|
||||||
|
|
||||||
|
|||||||
+49
-1
@@ -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 type { DrizzleD1Database } from "drizzle-orm/d1";
|
||||||
import {
|
import {
|
||||||
blogKeywords,
|
blogKeywords,
|
||||||
cases,
|
cases,
|
||||||
contentItems,
|
contentItems,
|
||||||
|
CONTENT_KINDS,
|
||||||
posts,
|
posts,
|
||||||
siteSettings,
|
siteSettings,
|
||||||
type BlogKeyword,
|
type BlogKeyword,
|
||||||
@@ -71,6 +72,53 @@ export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
|
|||||||
.orderBy(asc(blogKeywords.createdAt), asc(blogKeywords.id));
|
.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> {
|
export async function getHomeData(db: Db): Promise<HomeData> {
|
||||||
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
|
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
|
||||||
getSettings(db),
|
getSettings(db),
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
|||||||
{ key: "og_image", label: "分享圖片 (OG image)", type: "image" },
|
{ 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));
|
export const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
|
||||||
|
|||||||
+164
-64
@@ -4,18 +4,34 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const { title } = Astro.props;
|
const { title } = Astro.props;
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const links = [
|
|
||||||
{ href: "/admin", label: "文章" },
|
const groups: { label: string; links: { href: string; label: string }[] }[] = [
|
||||||
{ href: "/admin/content/service", label: "服務" },
|
{ label: "總覽", links: [{ href: "/admin", label: "總覽" }] },
|
||||||
{ href: "/admin/content/feature", label: "特色" },
|
{
|
||||||
{ href: "/admin/content/step", label: "流程" },
|
label: "內容管理",
|
||||||
{ href: "/admin/content/faq", label: "常見問題" },
|
links: [
|
||||||
{ href: "/admin/cases", label: "案例" },
|
{ href: "/admin/posts", label: "文章" },
|
||||||
{ href: "/admin/settings", label: "設定" },
|
{ href: "/admin/content/service", label: "服務" },
|
||||||
{ href: "/admin/ai", label: "AI 生成" },
|
{ 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>
|
<!doctype html>
|
||||||
@@ -27,28 +43,45 @@ const isActive = (href: string) =>
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="bar">
|
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
|
||||||
<div class="inner">
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
<a class="brand" href="/admin">
|
<a class="brand" href="/admin">
|
||||||
<span class="dot"></span>
|
<span class="brand-name">盈豐太陽能</span>
|
||||||
盈豐太陽能 · 後台
|
<span class="brand-sub">後台</span>
|
||||||
</a>
|
</a>
|
||||||
<nav>
|
<nav class="nav">
|
||||||
{
|
{
|
||||||
links.map((l) => (
|
groups.map((g) => (
|
||||||
<a href={l.href} class={isActive(l.href) ? "active" : ""}>
|
<>
|
||||||
{l.label}
|
<p class="nav-group">{g.label}</p>
|
||||||
</a>
|
{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>
|
</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>
|
||||||
</header>
|
</div>
|
||||||
<main class="inner">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
<script>
|
<script>
|
||||||
const MAX_WIDTH = 1600;
|
const MAX_WIDTH = 1600;
|
||||||
const QUALITY = 0.85;
|
const QUALITY = 0.85;
|
||||||
@@ -136,72 +169,139 @@ const isActive = (href: string) =>
|
|||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
:root {
|
: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;
|
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
|
||||||
color: #0f1e1a;
|
color: var(--fg);
|
||||||
background: #f2f8f6;
|
background: var(--bg);
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; }
|
body { margin: 0; }
|
||||||
a { color: #0b8a5e; text-decoration: none; }
|
a { color: var(--brand-strong); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
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; }
|
.shell { display: flex; min-height: 100vh; }
|
||||||
.bar .inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; flex-wrap: wrap; }
|
.sidebar {
|
||||||
.brand { font-weight: 800; color: #0f1e1a; display: inline-flex; align-items: center; gap: 8px; }
|
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; }
|
.brand:hover { text-decoration: none; }
|
||||||
.dot { width: 12px; height: 12px; border-radius: 50%; background: linear-gradient(135deg,#0b8a5e,#0ba5ec); display: inline-block; }
|
.brand-name { font-family: "Noto Serif HK", serif; font-weight: 900; font-size: 17px; letter-spacing: 0.02em; }
|
||||||
.bar nav { display: flex; gap: 14px; font-size: 13.5px; flex-wrap: wrap; }
|
.brand-sub { font-size: 11px; color: #9a938a; letter-spacing: 0.28em; }
|
||||||
.bar nav a { color: #33413c; }
|
.nav { display: flex; flex-direction: column; gap: 2px; flex: 1; overflow: auto; }
|
||||||
.bar nav a.active { color: #0b8a5e; font-weight: 700; }
|
.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-family: "Noto Serif HK", serif; font-size: 24px; font-weight: 900; margin: 0 0 6px; }
|
||||||
h1 { font-size: 22px; font-weight: 800; margin: 0 0 6px; }
|
.sub { color: var(--fg-muted); font-size: 13.5px; margin: 0 0 24px; }
|
||||||
.sub { color: #5b6b66; 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; }
|
.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; }
|
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: #5b6b66; padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,.08); }
|
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 rgba(0,0,0,.05); font-size: 14px; vertical-align: middle; }
|
td { padding: 12px 14px; border-bottom: 1px solid var(--border); font-size: 14px; vertical-align: middle; }
|
||||||
tbody tr:last-child td { border-bottom: none; }
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
label { display: block; font-size: 13px; color: #5b6b66; margin: 14px 0 6px; font-weight: 600; }
|
label { display: block; font-size: 13px; color: var(--fg-muted); margin: 14px 0 6px; font-weight: 600; }
|
||||||
input[type="text"], input[type="url"], textarea, select {
|
input[type="text"], input[type="url"], input[type="password"], textarea, select {
|
||||||
width: 100%; font: inherit; font-size: 14px; padding: 9px 12px;
|
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; }
|
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 {
|
button, .btn {
|
||||||
font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: 9px; cursor: pointer;
|
font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: var(--radius-sm); cursor: pointer;
|
||||||
border: 1px solid #0b8a5e; background: #0b8a5e; color: #fff; display: inline-flex; align-items: center; gap: 6px;
|
border: 1px solid var(--ink); background: var(--ink); color: #fff; display: inline-flex; align-items: center; gap: 6px;
|
||||||
}
|
}
|
||||||
button:hover { background: #0a6f4c; }
|
button:hover, .btn:hover { background: #000; }
|
||||||
button.secondary, .btn.secondary { background: #fff; color: #0f1e1a; border-color: rgba(0,0,0,.15); }
|
button.secondary, .btn.secondary { background: var(--surface); color: var(--fg); border-color: var(--border); }
|
||||||
button.secondary:hover { background: #f2f8f6; }
|
button.secondary:hover { background: var(--bg); }
|
||||||
button.danger { background: #fff; color: #a32d2d; border-color: #f09595; }
|
button.danger { background: var(--surface); color: var(--danger); border-color: #f09595; }
|
||||||
button.mini { padding: 5px 10px; font-size: 12.5px; border-radius: 7px; }
|
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; }
|
.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 { font-size: 11px; padding: 2px 8px; border-radius: 20px; border: 1px solid transparent; }
|
||||||
.badge.published { background: #e1f5ee; color: #0f6e56; }
|
.badge.published { background: var(--ok-soft); color: var(--ok); }
|
||||||
.badge.draft { background: #faeeda; color: #854f0b; }
|
.badge.draft { background: var(--warn-soft); color: var(--warn); }
|
||||||
.muted { color: #8a9793; font-size: 13px; }
|
.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; }
|
||||||
.field-error { color: #a32d2d; font-size: 12.5px; font-weight: 600; margin-left: 8px; }
|
.muted { color: var(--fg-muted); font-size: 13px; }
|
||||||
.err { color: #a32d2d; font-size: 13px; font-weight: 600; }
|
.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; }
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
|
|
||||||
.image-field { display: block; }
|
.image-field { display: block; }
|
||||||
.image-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
|
.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-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 { font-size: 12.5px; font-weight: 600; color: var(--ok); }
|
||||||
.image-status.error { color: #a32d2d; }
|
.image-status.error { color: var(--danger); }
|
||||||
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; }
|
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: var(--radius); }
|
||||||
.image-preview[hidden] { display: none; }
|
.image-preview[hidden] { display: none; }
|
||||||
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Props {
|
|||||||
publishedTime?: Date | null;
|
publishedTime?: Date | null;
|
||||||
image?: string;
|
image?: string;
|
||||||
jsonLd?: object | null | (object | null)[];
|
jsonLd?: object | null | (object | null)[];
|
||||||
|
gaId?: string;
|
||||||
|
metaPixelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -19,8 +21,12 @@ const {
|
|||||||
publishedTime = null,
|
publishedTime = null,
|
||||||
image,
|
image,
|
||||||
jsonLd = null,
|
jsonLd = null,
|
||||||
|
gaId = "",
|
||||||
|
metaPixelId = "",
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
|
const hasAnalytics = Boolean(gaId || metaPixelId);
|
||||||
|
|
||||||
const siteName = SITE_NAME;
|
const siteName = SITE_NAME;
|
||||||
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
|
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
|
||||||
const canonical = new URL(Astro.url.pathname, origin + "/").toString();
|
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} />}
|
{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="sitemap" href="/sitemap.xml" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
@@ -79,6 +144,58 @@ const jsonLdHtml = jsonLdItems.length
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<slot />
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -90,4 +207,60 @@ const jsonLdHtml = jsonLdItems.length
|
|||||||
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, -apple-system, sans-serif;
|
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, -apple-system, sans-serif;
|
||||||
background: #faf8f4;
|
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>
|
</style>
|
||||||
|
|||||||
+14
-1
@@ -1,9 +1,22 @@
|
|||||||
---
|
---
|
||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import { SITE_NAME } from "../config";
|
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;">
|
<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>
|
<h1 style="font-size:28px;font-weight:800;color:#1A1917;">我哋嘅服務已整合到主頁</h1>
|
||||||
<p style="color:#6E6862;line-height:1.9;">請返去主頁睇晒我哋嘅村屋太陽能服務、案例同流程。</p>
|
<p style="color:#6E6862;line-height:1.9;">請返去主頁睇晒我哋嘅村屋太陽能服務、案例同流程。</p>
|
||||||
|
|||||||
@@ -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>}
|
{error && <p class="err" style="margin-bottom:16px">{error}</p>}
|
||||||
{
|
{
|
||||||
notice && (
|
notice && (
|
||||||
<p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">
|
<p class="notice">✓ {notice}</p>
|
||||||
✓ {notice}
|
|
||||||
</p>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+146
-31
@@ -1,47 +1,130 @@
|
|||||||
---
|
---
|
||||||
import { desc, eq, sql } from "drizzle-orm";
|
|
||||||
import AdminLayout from "../../layouts/AdminLayout.astro";
|
import AdminLayout from "../../layouts/AdminLayout.astro";
|
||||||
import { getDb } from "../../lib/db";
|
import { getDb } from "../../lib/db";
|
||||||
import { cases, contentItems, posts } from "../../db/schema";
|
|
||||||
import { getEnv } from "../../lib/env";
|
import { getEnv } from "../../lib/env";
|
||||||
|
import { getDashboardData, getSettings } from "../../data/content";
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const db = getDb(getEnv().DB);
|
const env = getEnv();
|
||||||
const all = await db.select().from(posts).orderBy(desc(posts.updatedAt));
|
const db = getDb(env.DB);
|
||||||
|
const data = await getDashboardData(db);
|
||||||
|
const settings = await getSettings(db);
|
||||||
|
|
||||||
const counts = await db
|
const KIND_LABELS: Record<string, string> = {
|
||||||
.select({ kind: contentItems.kind, n: sql<number>`count(*)` })
|
service: "服務",
|
||||||
.from(contentItems)
|
feature: "特色",
|
||||||
.groupBy(contentItems.kind);
|
step: "流程",
|
||||||
const countOf = (k: string) => counts.find((c) => c.kind === k)?.n ?? 0;
|
faq: "FAQ",
|
||||||
const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
|
};
|
||||||
|
|
||||||
|
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="文章">
|
<AdminLayout title="總覽">
|
||||||
<h1>內容總覽</h1>
|
<div class="page-head">
|
||||||
<p class="sub">管理文章、首頁內容同完成案例。前台會自動更新。</p>
|
<div>
|
||||||
|
<h1>總覽</h1>
|
||||||
<div class="card">
|
<p class="sub">內容同系統狀態一覽。前台會自動更新。</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="stats">
|
||||||
<h2 style="font-size:18px;margin:0;flex:1">文章({all.length})</h2>
|
<div class="stat">
|
||||||
<a class="btn" href="/admin/post/new">+ 寫新文</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
all.length === 0 ? (
|
data.recentPosts.length === 0 ? (
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="muted">仲未有任何文章。<a href="/admin/post/new">寫第一篇 →</a></p>
|
<p class="muted">仲未有任何文章。<a href="/admin/post/new">寫第一篇 →</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,11 +135,11 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
|
|||||||
<th>標題</th>
|
<th>標題</th>
|
||||||
<th style="width:110px">狀態</th>
|
<th style="width:110px">狀態</th>
|
||||||
<th style="width:130px">更新時間</th>
|
<th style="width:130px">更新時間</th>
|
||||||
<th style="width:80px"></th>
|
<th style="width:72px"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{all.map((p) => (
|
{data.recentPosts.map((p) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href={`/admin/post/${p.id}`}>{p.title}</a>
|
<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>
|
<span class={`badge ${p.status}`}>{p.status === "published" ? "已發布" : "草稿"}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">{new Date(p.updatedAt).toLocaleDateString("zh-Hant-HK")}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -74,3 +159,33 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</AdminLayout>
|
</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>
|
||||||
|
|||||||
@@ -44,16 +44,17 @@ if (Astro.request.method === "POST") {
|
|||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
:root {
|
:root {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang HK", "Noto Sans HK", sans-serif;
|
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
|
||||||
color: #2c2c2a; background: #f7f7f5; line-height: 1.65; font-size: 15px;
|
color: #1a1917; background: #faf8f4; line-height: 1.65; font-size: 15px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
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; }
|
main { width: 100%; max-width: 360px; padding: 32px; background: #fff; border: 1px solid #e7e2d9; border-radius: 8px; }
|
||||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; }
|
h1 { font-family: "Noto Serif HK", serif; font-size: 20px; font-weight: 900; margin: 0 0 20px; }
|
||||||
label { display: block; font-size: 13px; color: #5f5e5a; margin-bottom: 6px; }
|
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 rgba(0,0,0,.15); border-radius: 8px; }
|
input { width: 100%; font: inherit; font-size: 14px; padding: 9px 12px; border: 1px solid #e7e2d9; border-radius: 4px; }
|
||||||
input:focus { outline: 2px solid #b5d4f4; border-color: #185fa5; }
|
input:focus { outline: 2px solid #fef3c7; border-color: #d97706; }
|
||||||
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; }
|
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; }
|
||||||
.err { background: #fcebeb; color: #a32d2d; padding: 10px 14px; border-radius: 8px; font-size: 14px; margin: 0 0 16px; }
|
button:hover { background: #000; }
|
||||||
|
.err { background: #fcebeb; color: #a32d2d; padding: 10px 14px; border-radius: 4px; font-size: 14px; margin: 0 0 16px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const [existing] = isNew
|
|||||||
: await db.select().from(posts).where(eq(posts.id, id!)).limit(1);
|
: await db.select().from(posts).where(eq(posts.id, id!)).limit(1);
|
||||||
|
|
||||||
if (!isNew && !existing) {
|
if (!isNew && !existing) {
|
||||||
return Astro.redirect("/admin");
|
return Astro.redirect("/admin/posts");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Astro.request.method === "POST") {
|
if (Astro.request.method === "POST") {
|
||||||
@@ -36,7 +36,7 @@ if (Astro.request.method === "POST") {
|
|||||||
if (action === "delete" && !isNew) {
|
if (action === "delete" && !isNew) {
|
||||||
await deleteMedia(env, existing?.coverImage);
|
await deleteMedia(env, existing?.coverImage);
|
||||||
await db.delete(posts).where(eq(posts.id, id!));
|
await db.delete(posts).where(eq(posts.id, id!));
|
||||||
return Astro.redirect("/admin");
|
return Astro.redirect("/admin/posts");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseForm(form, postInput);
|
const parsed = parseForm(form, postInput);
|
||||||
@@ -93,7 +93,7 @@ if (Astro.request.method === "POST") {
|
|||||||
.where(eq(posts.id, id!));
|
.where(eq(posts.id, id!));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Astro.redirect("/admin");
|
return Astro.redirect("/admin/posts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ const post = isNew
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" name="action" value="save">儲存</button>
|
<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 && (
|
!isNew && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -59,7 +59,7 @@ const settings = await getSettings(db);
|
|||||||
|
|
||||||
{error && <p class="err" style="margin-bottom:16px">{error}</p>}
|
{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">
|
<form method="post">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const jsonLd = post
|
|||||||
publishedTime={post.publishedAt}
|
publishedTime={post.publishedAt}
|
||||||
image={post.coverImage ?? undefined}
|
image={post.coverImage ?? undefined}
|
||||||
jsonLd={jsonLd}
|
jsonLd={jsonLd}
|
||||||
|
gaId={settings.ga4_measurement_id}
|
||||||
|
metaPixelId={settings.meta_pixel_id}
|
||||||
>
|
>
|
||||||
<BlogPost
|
<BlogPost
|
||||||
title={post.title}
|
title={post.title}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Astro.response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-re
|
|||||||
title="太陽能知識庫"
|
title="太陽能知識庫"
|
||||||
description={settings.seo_default_description || "村屋太陽能嘅實用資訊、安裝心得同回本分析。"}
|
description={settings.seo_default_description || "村屋太陽能嘅實用資訊、安裝心得同回本分析。"}
|
||||||
image={settings.og_image}
|
image={settings.og_image}
|
||||||
|
gaId={settings.ga4_measurement_id}
|
||||||
|
metaPixelId={settings.meta_pixel_id}
|
||||||
>
|
>
|
||||||
<BlogIndex posts={items} settings={settings} client:idle />
|
<BlogIndex posts={items} settings={settings} client:idle />
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Astro.response.headers.set(
|
|||||||
description={settings.seo_default_description}
|
description={settings.seo_default_description}
|
||||||
image={settings.og_image}
|
image={settings.og_image}
|
||||||
jsonLd={jsonLd}
|
jsonLd={jsonLd}
|
||||||
|
gaId={settings.ga4_measurement_id}
|
||||||
|
metaPixelId={settings.meta_pixel_id}
|
||||||
>
|
>
|
||||||
<HomePage data={data} client:idle />
|
<HomePage data={data} client:idle />
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user