Add Zod validation layer and AI blog generation

Introduce a schema-first validation layer and an AI blog generation
feature, plus one-command Cloudflare provisioning.

- src/schemas/ holds Zod input schemas for post, content, case,
  settings, AI, and keywords; parseForm() in src/lib/form.ts validates
  FormData and returns per-field errors.
- Migrate all admin POST handlers to parseForm, showing field-level
  errors and only redirecting once validation passes.
- Add blog_keywords table and posts.focus_keyword (migration 0002);
  uniqueSlug() centralised in src/data/content.ts.
- Add src/lib/ai.ts (OpenAI-compatible chat completions + optional
  Tavily research) and /admin/ai for AI settings, keyword queue, and
  draft generation.
- Add scripts/setup.mjs (npm run setup) to provision D1/KV, set
  secrets, and optionally migrate, seed, and deploy.
- Document AI secrets, Workers Builds deploy flow, and new schemas
  across README and AGENTS docs.
This commit is contained in:
2026-09-11 23:49:18 +08:00
parent 5b69bc818a
commit fdee1aabd7
41 changed files with 3240 additions and 296 deletions
-159
View File
@@ -1,159 +0,0 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function render<C extends keyof LiveContentConfig['collections']>(
entry: import('astro').LiveDataEntry<LiveLoaderDataType<C>>,
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = never;
export type LiveContentConfig = never;
}
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"pid": 27812, "pid": 98774,
"port": 4321, "port": 4321,
"url": "http://localhost:4321", "url": "http://localhost:4321",
"urls": { "urls": {
@@ -10,5 +10,5 @@
"networkInterfaceNames": [] "networkInterfaceNames": []
}, },
"background": true, "background": true,
"startedAt": "2026-09-11T07:43:31.316Z" "startedAt": "2026-09-11T08:49:14.193Z"
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"_variables": { "_variables": {
"lastUpdateCheck": 1789106959098 "lastUpdateCheck": 1789116553811
} }
} }
-1
View File
@@ -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" />
+2
View File
@@ -1 +1,3 @@
ADMIN_PASSWORD=改成你自己嘅密碼 ADMIN_PASSWORD=改成你自己嘅密碼
AI_API_KEY=
TAVILY_API_KEY=
+1
View File
@@ -4,3 +4,4 @@ dist/
.dev.vars .dev.vars
*.log *.log
.DS_Store .DS_Store
.playwright-mcp/
+9 -5
View File
@@ -9,14 +9,18 @@ Astro 7 + React 19 + Chakra UI v3,部署在 Cloudflare Workers(單一 Worker
- `npm run preview` / `npm run deploy` — build 後用 wrangler dev / deploy - `npm run preview` / `npm run deploy` — build 後用 wrangler dev / deploy
- DB(本機用 `:local`,雲端省略):`npm run db:generate``db:migrate:local` / `db:migrate``db:seed:local` / `db:seed` - DB(本機用 `:local`,雲端省略):`npm run db:generate``db:migrate:local` / `db:migrate``db:seed:local` / `db:seed`
- `npm run types` — 重新產生 `worker-configuration.d.ts``npm run secret` — 設定 `ADMIN_PASSWORD` - `npm run types` — 重新產生 `worker-configuration.d.ts``npm run secret` — 設定 `ADMIN_PASSWORD`
- `npm run setup` — 一鍵 Cloudflare provision(建 D1 / KV、寫 `wrangler.jsonc`、設 secret、可選套 migration + seed
- 沒有 test / lint / typecheck script(未安裝 @astrojs/check)。`npm run build` 是唯一可跑的驗證。 - 沒有 test / lint / typecheck script(未安裝 @astrojs/check)。`npm run build` 是唯一可跑的驗證。
## 關鍵慣例與陷阱 ## 關鍵慣例與陷阱
- **SSR 頁面必須自己聲明**`astro.config.mjs``output: "static"`,所有要讀 D1 的動態頁面都靠檔案內 `export const prerender = false`(首頁、blog、admin、sitemap/robots)。新增需要 request-time 資料的頁面時一定要加。 - **SSR 頁面必須自己聲明**`astro.config.mjs``output: "static"`,所有要讀 D1 的動態頁面都靠檔案內 `export const prerender = false`(首頁、blog、admin、sitemap.xml)。新增需要 request-time 資料的頁面時一定要加。
- **環境變數只用 `getEnv()`**`src/lib/env.ts`,底層 `cloudflare:workers``env`)。Astro v6 起已移除 `Astro.locals.runtime.env``getEnv()` 只能在 `prerender = false` 的頁面/endpoint 用。 - **環境變數只用 `getEnv()`**`src/lib/env.ts`,底層 `cloudflare:workers``env`)。Astro v6 起已移除 `Astro.locals.runtime.env``getEnv()` 只能在 `prerender = false` 的頁面/endpoint 用。
- **資料層**`getDb(getEnv().DB)` → Drizzle;查詢集中在 `src/data/content.ts`schema 在 `src/db/schema.ts`。改 schema 後跑 `npm run db:generate` 產生 migration。 - **資料層**`getDb(getEnv().DB)` → Drizzle;查詢集中在 `src/data/content.ts`schema 在 `src/db/schema.ts`。改 schema 後跑 `npm run db:generate` 產生 migration。
- **Seed 與 migration 是分開的**README 講法有誤)`db:migrate:local` 只套 migrations`migrations/seed.sql` 要用 `db:seed:local` 另外執行;seed 可重複跑。 - **Seed 與 migration 是分開的**`db:migrate:local` / `db:migrate` 只套 migrations`migrations/seed.sql` 要用 `db:seed:local` / `db:seed` 另外執行;seed 可重複跑。
- **表單驗證集中在 `src/schemas/`**Zod)+ `src/lib/form.ts``parseForm(form, schema)`:admin 頁面唔好再手寫逐欄驗證。實體輸入型別用 `z.infer` 由 schemas 匯出。
- **AI Blog**`src/lib/ai.ts`OpenAI 相容 chat completions + Tavily)+ `/admin/ai`;同步生成、一律存草稿並標記關鍵字 `generated`。需要 secrets `AI_API_KEY`(必需)/`TAVILY_API_KEY`(可選);未設時回明確錯誤,唔會 throw。
- **部署用 Cloudflare Workers Builds**Git push 自動):build `npm run build`、deploy `npx wrangler deploy`。**Migration 唔會喺 CI 執行**,改 schema 要手動 `npm run db:migrate`(雲端)。
- **`wrangler.jsonc` 有佔位 id**`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。 - **`wrangler.jsonc` 有佔位 id**`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。
- **上線前要改 `astro.config.mjs``site`**sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。 - **上線前要改 `astro.config.mjs``site`**sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。
- **後台認證**`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。
@@ -110,12 +114,12 @@ When the user requests a durable behavior change, record it here or in the relev
| Path | Scope | | Path | Scope |
|---|---| |---|---|
| `src/AGENTS.md` | 原始碼全層:架構、跨層慣例;擁有 `theme/``layouts/``config.ts``middleware.ts``env.d.ts`,以及 `pages/` 路由與 `/admin` 後台合約 | | `src/AGENTS.md` | 原始碼全層:架構、跨層慣例;擁有 `theme/``layouts/``schemas/``config.ts``middleware.ts``env.d.ts`,以及 `pages/` 路由與 `/admin` 後台合約 |
| `src/components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 | | `src/components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 |
| `src/data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 | | `src/data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
| `src/db/AGENTS.md` | Drizzle schemamigration 的唯一來源) | | `src/db/AGENTS.md` | Drizzle schemamigration 的唯一來源) |
| `src/lib/AGENTS.md` | auth / env / db / markdown 基礎工具 | | `src/lib/AGENTS.md` | auth / env / db / form / ai / markdown 基礎工具 |
| `migrations/AGENTS.md` | D1 migration 與 seed SQL | | `migrations/AGENTS.md` | D1 migration 與 seed SQL |
| `docs/AGENTS.md` | 設計規格與實作計劃(`superpowers/` 為設計權威) | | `docs/AGENTS.md` | 設計規格與實作計劃(`superpowers/` 為設計權威) |
Project-level files with no child doc are owned by this root: `astro.config.mjs``wrangler.jsonc``drizzle.config.ts``tsconfig.json``package.json``README.md``.dev.vars.example` Project-level files with no child doc are owned by this root: `astro.config.mjs``wrangler.jsonc``drizzle.config.ts``tsconfig.json``package.json``README.md``.dev.vars.example``scripts/`
+41 -17
View File
@@ -9,6 +9,7 @@
- **一頁式官網** `/`(SSR,讀 D1):Hero、服務、特色、流程、完成案例、FAQ、聯絡 CTA - **一頁式官網** `/`(SSR,讀 D1):Hero、服務、特色、流程、完成案例、FAQ、聯絡 CTA
- **Blog** `/blog``/blog/[slug]`:封面卡片列表 + Markdown 文章頁 - **Blog** `/blog``/blog/[slug]`:封面卡片列表 + Markdown 文章頁
- **後台** `/admin`(密碼登入):管理文章、首頁內容(服務/特色/流程/FAQ)、完成案例、網站設定(公司資料/聯絡/Hero/SEO) - **後台** `/admin`(密碼登入):管理文章、首頁內容(服務/特色/流程/FAQ)、完成案例、網站設定(公司資料/聯絡/Hero/SEO)
- **AI 生成 Blog** `/admin/ai`:設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(DeepSeek 相容 API,可選 Tavily 上網研究)
- **SEO**canonical、OG/Twitter card、`/sitemap.xml``/robots.txt`、結構化語意 HTML - **SEO**canonical、OG/Twitter card、`/sitemap.xml``/robots.txt`、結構化語意 HTML
- 首頁/Blog 用邊緣快取(`s-maxage`),後台改完約 1 分鐘內生效 - 首頁/Blog 用邊緣快取(`s-maxage`),後台改完約 1 分鐘內生效
@@ -17,7 +18,7 @@
| 層 | 選用 | | 層 | 選用 |
|---|---| |---|---|
| 前端框架 | Astro 7`output: "static"` + per-page SSR | | 前端框架 | Astro 7`output: "static"` + per-page SSR |
| UI | React 19 + `@chakra-ui/react` v3 + `@emotion/react`清新天藍/綠色系 theme | | UI | React 19 + `@chakra-ui/react` v3 + `@emotion/react`editorial 極簡 theme:暖米白底、琥珀金 accent |
| Adapter | `@astrojs/cloudflare`(一個 Worker | | Adapter | `@astrojs/cloudflare`(一個 Worker |
| DB | Cloudflare D1SQLite | | DB | Cloudflare D1SQLite |
| ORM | Drizzle | | ORM | Drizzle |
@@ -32,45 +33,66 @@
```bash ```bash
npm install npm install
# 建本機 D1 + 套 migrationmigration 會連 seed.sql 一齊套) # 建本機 D1 + 套 migration
npm run db:migrate:local npm run db:migrate:local
# 重新匯入種子資料(可重複執行) # 匯入種子資料(同 migration 分開,可重複執行)
npm run db:seed:local npm run db:seed:local
# 開發 server:4321 # 開發 server:4321
npx astro dev npm run dev
``` ```
本機後台密碼喺 `.dev.vars``ADMIN_PASSWORD=changeme-local`,已 gitignore)。 本機後台密碼喺 `.dev.vars``ADMIN_PASSWORD=changeme-local`,已 gitignore)。
想模擬 productionwrangler):`npx wrangler dev` 想模擬 productionwrangler):`npm run preview`
## 部署到 Cloudflare ## 部署到 Cloudflare
### 首次設定(一鍵)
```bash ```bash
npm run login # 瀏覽器授權 npm run login # 瀏覽器授權
npm run setup # 建立 D1 / KV、寫入 wrangler.jsonc、設 secret、套 migration + seed
```
npm run db:create # 印 database_id → 填入 wrangler.jsonc `npm run setup` 會逐步問你:要唔要設定後台密碼、要唔要套 migration/seed、要唔要 build + deploy。若 `astro.config.mjs``site` 仍然係 `https://example.com`,會跳過 build + deploy 並提醒你改。
npm run kv:create # 印 namespace id → 填入 wrangler.jsonc
npm run db:migrate # 雲端套 migration ### 之後更新
npm run db:seed # 雲端匯入種子(公司資料 + 樣本內容)
npx wrangler secret put ADMIN_PASSWORD # 設定後台密碼 改完 `astro.config.mjs``site` 做真域名後:
# 改 astro.config.mjs 嘅 site = 真網域 ```bash
npm run deploy npm run deploy
``` ```
之後更新:`npm run deploy` ### Git push 自動部署(Cloudflare Workers Builds
1. Cloudflare Dashboard → Workers & Pages → 連接 GitHub repo
2. Build command`npm run build`
3. Deploy command`npx wrangler deploy`
4. 喺 Dashboard 設 secrets`ADMIN_PASSWORD``AI_API_KEY``TAVILY_API_KEY`
**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑
`npm run db:generate``npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。
## AI 生成 Blog
後台 `/admin/ai`
- 設定 AI 供應商(OpenAI 相容 Base URL + 模型)、寫作風格、公司背景
- 維護關鍵字佇列(新增 / 略過 / 刪除)
- 「生成草稿」= 自由輸入主題;「生成下一篇」= 攞佇列下一個 pending
- 生成係同步進行(約 10–40 秒),完成後一律存為**草稿**並跳去文章編輯頁覆核
- 需要 secret`AI_API_KEY`(必需)、`TAVILY_API_KEY`(如開上網研究)
## 後台點用 ## 後台點用
1.`/admin`,輸入密碼登入 1.`/admin`,輸入密碼登入
2. **內容總覽** → 服務 / 特色 / 流程 / 常見問題 / 完成案例 / 網站設定 2. **內容總覽** → 服務 / 特色 / 流程 / 常見問題 / 完成案例 / 網站設定 / AI 生成
3. 每個項目可新增、修改、刪除、用 ↑↓ 排序、「已發布 / 草稿」控制前台顯示 3. 每個項目可新增、修改、刪除、用 ↑↓ 排序、「已發布 / 草稿」控制前台顯示
4. 圖片:直接貼圖片 URL(暫時;之後可換成上傳) 4. 圖片:直接貼圖片 URL(暫時;之後可換成上傳)
5. **AI 生成**:設定供應商同關鍵字佇列,生成草稿後去文章編輯頁覆核發布
## 結構 ## 結構
@@ -81,18 +103,20 @@ src/
├─ data/ ├─ data/
│ ├─ content.ts D1 讀取 + WhatsApp/電話 link helper │ ├─ content.ts D1 讀取 + WhatsApp/電話 link helper
│ └─ settings-fields.ts 後台設定欄位定義 │ └─ settings-fields.ts 後台設定欄位定義
├─ db/schema.ts Drizzle schemaposts / site_settings / content_items / cases ├─ db/schema.ts Drizzle schemaposts / site_settings / content_items / cases / blog_keywords
├─ schemas/ Zod 驗證 schema(表單輸入 + AI 輸出)
├─ layouts/Base.astro SEO head + fonts ├─ layouts/Base.astro SEO head + fonts
├─ layouts/AdminLayout.astro 後台外框 ├─ layouts/AdminLayout.astro 後台外框
├─ pages/ ├─ pages/
│ ├─ index.astro SSR 首頁 │ ├─ index.astro SSR 首頁
│ ├─ blog/… Blog │ ├─ blog/… Blog
│ ├─ admin/… 後台 │ ├─ admin/… 後台(含 admin/ai
│ └─ sitemap.xml.ts / robots.txt.ts │ └─ sitemap.xml.ts / robots.txt.ts
─ lib/{auth,db,env,markdown}.ts ─ lib/{auth,db,env,form,ai,markdown}.ts
migrations/ migrations/
├─ 0000_init.sql … Drizzle migrations ├─ 0000_init.sql … Drizzle migrations
└─ seed.sql 種子資料(與 migration 一齊執行) └─ seed.sql 種子資料(用 db:seed 另外執行)
scripts/setup.mjs 一鍵 Cloudflare provision
``` ```
## 待客戶提供(可喺 `/admin/settings` 自行更新) ## 待客戶提供(可喺 `/admin/settings` 自行更新)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
# 盈豐太陽能 — 結構優化 + AI Blog + 部署便利 (Design Spec)
- **日期**2026-09-11
- **狀態**:已與客戶確認方向
- **背景**:現有網站(見 `2026-09-11-ying-fung-solar-design.md`)功能完整,但想改善三樣嘢:
1. 資料/表單處理散落手砌,欠型別化驗證層
2. 想有 AI 生成 Blog 文章功能(參考 webtemplate,但簡化、唔要 Google keyword search
3. 部署到 Cloudflare 嘅流程手續多,想更方便
- **本 spec 取代**先前 spec 嘅部署一節;其餘架構決策不變。
---
## 1. 目標與非目標
### 目標
1. **輕量 schema-first 驗證層**:用 Zod 做「admin 表單輸入 + DB 寫入」嘅單一真相來源,得到 typed contract 好處,但零 codegen、零 API layer。
2. **AI Blog 生成**:後台一鍵生成完整 Markdown 草稿(DeepSeek via OpenAI 相容端點),可選 Tavily 上網研究,支援關鍵字佇列。
3. **部署便利**:一鍵本機 setup script + Cloudflare Workers BuildsGit push 自動 deploy)。
### 非目標 (Out of scope)
- 唔引入真 OpenAPI spec / codegen / 前後端分離 SPA(已評估唔啱一個一頁式官網)。
- AI **封面圖**生成(唔做)。
- **定時/排程**自動生成(唔做;只做後台手動觸發)。
- **Google Ads keyword discovery**(唔做)。
- 圖片上傳(維持以 URL 貼圖)。
- 雙語、聯絡表單(維持現狀)。
---
## 2. Thread 1 — 輕量 schema-first 驗證層
### 2.1 依賴
新增 `zod`v4`^4`)。
### 2.2 結構
```
src/schemas/
├─ post.ts postInput
├─ content.ts contentItemInputkind enum 用 CONTENT_KINDS
├─ case.ts caseInput
├─ settings.ts 由 SETTINGS_GROUPS 動態砌出可選 key 驗證
├─ ai.ts aiSettingsInput + AI 輸出格式 schema
├─ keyword.ts keywordInput
└─ index.ts barrel(集中 re-export schema 同 z.infer 型別)
src/lib/form.ts parseForm(formData, schema)
```
- Schema 係「表單輸入 + DB 寫入」嘅唯一真相來源;型別一律 `z.infer`
- `settings-fields.ts` 繼續做 **UI metadata**(標籤、分組),Zod 只負責 **驗證**,唔重複定義。
- `parseForm` 介面:
```ts
type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Record<string, string> };
function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T>;
```
錯誤以 `field -> 訊息` 形式回傳,admin 表單喺對應欄位下顯示;未對應欄位嘅錯誤顯示喺表單頂。
### 2.3 改動
- `pages/admin/post/[id].astro`、`content/[kind].astro`、`cases.astro`、`settings.astro`POST handler 改用 `parseForm` 取代手砌 `String(form.get(...))` 同手寫 `if (!title)`。
- `data/content.ts`:改用 `src/schemas` 匯出嘅型別(如 `NewPost` 形狀),與 schema 對齊。
- 排序(`up`/`down`)、`delete` 等非表單欄位動作維持現狀(唔屬 schema 範圍)。
### 2.4 唔做
- 唔改前台元件介面、唔改 DB schema(純驗證層)。
- 唔引入 `openapi.json`、唔引入 client codegen。
---
## 3. Thread 2 — AI Blog 生成
### 3.1 資料模型(改 `src/db/schema.ts` → `npm run db:generate`
**`posts` 加欄位:**
| 欄位 | 型別 | 說明 |
|---|---|---|
| `focusKeyword` | text nullable | 生成時帶入嘅焦點關鍵字(SEO 用) |
**新表 `blog_keywords`**
| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | text PK | UUID |
| `keyword` | text notNull | 關鍵字 / 主題 |
| `status` | text enum `pending` \| `generated` \| `skipped`,預設 `pending` | 狀態 |
| `createdAt` | integer (timestamp_ms) | |
| `usedAt` | integer (timestamp_ms) nullable | 生成時間 |
| `postId` | text nullable | 生成出嚟嘅文章 id(可選連結) |
索引:`idx_keywords_status(status)`。
> 比 webtemplate 減去 Google Ads 專屬欄位(`avg_monthly_searches`、`competition`、`source`)。
### 3.2 Secrets 與設定
**Secrets`wrangler secret` / `.dev.vars`,加入 `AppEnv`):**
- `AI_API_KEY`
- `TAVILY_API_KEY`
**設定(D1 `site_settings`,後台可改):**
| Key | 預設 | 說明 |
|---|---|---|
| `ai_enabled` | `1` | 總開關 |
| `ai_base_url` | `https://api.deepinfra.com/v1/openai` | OpenAI 相容端點 |
| `ai_chat_model` | `deepseek-ai/DeepSeek-V3-0324` | 模型 |
| `ai_context_prompt` | `""` | 寫作風格 / 語氣 |
| `ai_business_context` | seed 由公司資料砌 | 公司背景(grounding |
| `ai_web_search_enabled` | `0` | Tavily 研究開關 |
| `ai_web_search_max_results` | `5` | Tavily 結果數 |
### 3.3 生成流程(`src/lib/ai.ts`
```
POST /admin/ai
action = "generate-topic"(自由輸入 topic
| "generate-next"(攞下一個 pending keyword
generateBlogPost({ db, env, topic })
1. 讀 ai_* 設定;檢查 ai_enabled + AI_API_KEY
2. 若係 generate-next → 攞最舊一個 pending keywordmark 佢 "generated" + usedAt + postId
generate-topic(自由輸入)唔會寫入佇列
3. 若 ai_web_search_enabled + TAVILY_API_KEY → Tavily search → research_brief
4. 組 system prompt
ai_context_prompt + ai_business_context + research_brief
+ SEO/GEO 硬規則 + 輸出格式合約
5. 呼叫 DeepSeek chat completionsOpenAI 相容 /chat/completions
6. 解析輸出(TITLE / CONTENT / META_DESC),用 Zod 驗證格式;失敗回錯誤
7. slugify + uniqueSlugautoExcerpt 補 excerpt
8. 插入 postsstatus = "draft"、focusKeyword = topic)→ 回 post id
redirect 去 /admin/post/<id> 覆核
```
**執行方式:同步 await。** POST handler 直接等生成完成(約 1040 秒)再 redirect。原因:最簡單、admin 流程直觀,符合「唔使太複雜」;Workers 對 outbound fetch 等待唔計 CPU limit。若日後遇到超時,先改 `Astro.locals.cfContext.waitUntil` + 狀態欄(已知 adapter 有注入 `locals.cfContext`)。
**提示合約:**
- 輸出用固定分隔格式(TITLE / CONTENT / META_DESC),比純 JSON 容忍度更高。
- 內建規則:繁體中文(廣東話書面)、800–1200 字、H2/H3 結構、關鍵字放標題及首段、meta ≤ 160 字、GEOsummary-first + Q&A)。
### 3.4 Admin UI(新頁 `/admin/ai`
一頁過包含:
1. **AI 設定表單**`ai_*` keys,密碼類 secret 只顯示「已設定 / 未設定」,唔顯示值)
2. **關鍵字佇列**:新增、刪除、標記 skipped;顯示狀態
3. **生成掣**`generate-topic`(自由輸入)+ `generate-next`(下一個 pending
- 生成掣用 POST 表單 + 提交時 JS 顯示「生成中…」(純漸進增強,唔引入框架)。
- `/admin` 首頁加入口連結去 `/admin/ai`。
### 3.5 唔做
- AI 圖、排程、Google Ads、多語。
---
## 4. Thread 3 — 部署便利
### 4.1 一鍵本機 setup — `scripts/setup.mjs``npm run setup`
互動式 Node script,逐步做:
1. 檢查 `wrangler whoami`(未登入提示 `npm run login`)。
2. `wrangler d1 create yingfung-solar-db`(若已存在/已填 id 則略過)→ 解析 output 拎 `database_id`。
3. `wrangler kv namespace create CACHE` → 解析 id。
4. 將 id 寫入 `wrangler.jsonc`:以字串替換 `PASTE_D1_DATABASE_ID_HERE` / `PASTE_KV_NAMESPACE_ID_HERE`**保留 JSONC 註解同行距**,唔用 JSON.parse/stringify)。
5. 提示輸入後台密碼 → `wrangler secret put ADMIN_PASSWORD`。
6. 套 migration + seedremote)。
7. 可選:`npm run build` + `npx wrangler deploy`。
- 每一步 idempotent(重跑唔會整爛已設定嘅嘢)。
- 唔會自動改 `astro.config.mjs` 嘅 `site`(提示用戶手動改)。
### 4.2 Cloudflare Workers BuildsGit push 自動 deploy
- 喺 Cloudflare Dashboard → Workers & Pages → 連接 Git repo。
- **Build command**`npm run build`
- **Deploy command**`npx wrangler deploy`
- Secrets`ADMIN_PASSWORD`、`AI_API_KEY`、`TAVILY_API_KEY`)喺 Dashboard 設定。
- **Migration 唔入 CI**:改 schema 時手動跑 `npm run db:migrate`(避免每次 push 亂郁 DB)。README 寫明呢個分工。
### 4.3 唔做
- 唔加 GitHub Actions。
- 唔自動化 Cloudflare Dashboard 連 repo(需要人手授權)。
---
## 5. 分階段實作
| 階段 | 內容 | 完成標準 |
|---|---|---|
| **Phase 1** | Zod schema-first 驗證層 | `npm run build` 過;admin 表單驗證錯誤正常顯示 |
| **Phase 2** | AI Blog + 關鍵字佇列 | `npm run db:generate` 出 migration`npm run dev` 實測生成草稿 |
| **Phase 3** | setup script + Workers Builds 文件 | 新 clone 行 `npm run setup` 可完成 provisionREADME 更新 |
三階段相對獨立,逐階段完成並 `npm run build`。
---
## 6. 風險與對策
| 風險 | 對策 |
|---|---|
| Zod 加入 admin POST 改動面大 | 逐個 admin POST handler 遷移;`parseForm` 提供一致 fallbackbuild 驗證 |
| LLM 輸出格式唔穩定 | 用容錯分隔格式 + Zod 驗證,失敗回明確錯誤、唔插入壞資料 |
| 同步生成請求時間長 | Workers I/O 等待唔計 CPU;若超時先改 `cfContext.waitUntil` + 狀態欄 |
| `wrangler.jsonc` 有註解,程式改寫易壞 | 只做精準字串替換,唔 parse 整個檔 |
| Workers Builds 跑 migration 風險 | 明確唔放 CImigration 手動執行 |
| 外部 AI 供應商停机 | 生成失敗只回錯誤,唔影響網站;設定可改 `ai_base_url`/model |
---
## 7. 驗證
- 每階段:`npm run build`(唯一 build/type 檢查)。
- Phase 2`npm run db:generate``npm run dev` 手動生成一篇草稿並覆核。
- Phase 3`npm run setup` 喺未 provision 環境 dry-run(或已有環境重跑,確認 idempotent)。
---
## 8. 文件同步
- `AGENTS.md`(root):指令、部署流程、AI secret。
- `src/AGENTS.md`:新 `schemas/`、`/admin/ai` 路由、AI 執行方式。
- `src/db/AGENTS.md`:新 `blog_keywords` 表、`posts.focusKeyword`。
- `src/data/AGENTS.md`:關鍵字查詢、型別來源改為 schemas。
- `src/lib/AGENTS.md``ai.ts`、`form.ts`、`env.ts` 新 secret。
- `README.md`setup script、Workers Builds、AI 設定步驟。
- `migrations/AGENTS.md`:新 migration。
---
## 9. 待客戶提供
- `AI_API_KEY`DeepInfra 或同類 OpenAI 相容供應商)。
- `TAVILY_API_KEY`(若用上網研究)。
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE `blog_keywords` (
`id` text PRIMARY KEY NOT NULL,
`keyword` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`created_at` integer NOT NULL,
`used_at` integer,
`post_id` text
);
--> statement-breakpoint
CREATE INDEX `idx_keywords_status` ON `blog_keywords` (`status`);--> statement-breakpoint
ALTER TABLE `posts` ADD `focus_keyword` text;
+3 -2
View File
@@ -11,13 +11,14 @@ Cloudflare D1 的 schema migration 同種子資料。
## Local Contracts ## Local Contracts
- **Migration 由工具產生**`0000_*.sql``0001_*.sql``npm run db:generate`drizzle-kit)依 `schema.ts` 產生;`migrations/meta/` 係產生檔。**唔好手改**呢啲檔或 meta。 - **Migration 由工具產生**`0000_*.sql``0001_*.sql``0002_*.sql``npm run db:generate`drizzle-kit)依 `schema.ts` 產生;`migrations/meta/` 係產生檔。**唔好手改**呢啲檔或 meta。`0002_*.sql` 加咗 `posts.focus_keyword``blog_keywords` 表。
- **`seed.sql` 係手寫、獨立於 migration**:全部用 `INSERT OR REPLACE`,可重複執行。`db:migrate` **唔會**執行 seed。 - **`seed.sql` 係手寫、獨立於 migration**:全部用 `INSERT OR REPLACE`,可重複執行。`db:migrate` **唔會**執行 seed。
- **Migration 唔會喺 CI 執行**Cloudflare Workers Builds 只跑 build + deploy;改 schema 後要手動 `npm run db:migrate`(雲端)。
- **指令**(本機用 `:local`,雲端省略): - **指令**(本機用 `:local`,雲端省略):
- `npm run db:generate` — 由 schema 產生 migration - `npm run db:generate` — 由 schema 產生 migration
- `npm run db:migrate:local` / `npm run db:migrate` — 套用 migration - `npm run db:migrate:local` / `npm run db:migrate` — 套用 migration
- `npm run db:seed:local` / `npm run db:seed` — 匯入種子 - `npm run db:seed:local` / `npm run db:seed` — 匯入種子
- **Seed 內容**:公司資料(`site_settings`)、首頁內容(`content_items`service/feature/step/faq)、`cases`、一篇示範 `posts` - **Seed 內容**:公司資料(`site_settings`)、首頁內容(`content_items`service/feature/step/faq)、`cases`、一篇示範 `posts`、三個示範 `blog_keywords``pending`);另有 7 個 `ai_*` AI 設定 keys(獨立 seed block
- DB 名稱 `yingfung-solar-db`binding 喺 `wrangler.jsonc``database_id` 仍係佔位,部署前要填)。 - DB 名稱 `yingfung-solar-db`binding 喺 `wrangler.jsonc``database_id` 仍係佔位,部署前要填)。
## Work Guidance ## Work Guidance
+391
View File
@@ -0,0 +1,391 @@
{
"version": "6",
"dialect": "sqlite",
"id": "80766b44-b986-4f68-ba33-1a0d0c811830",
"prevId": "13b1956d-d6ac-4a99-82db-372bdb300834",
"tables": {
"blog_keywords": {
"name": "blog_keywords",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"keyword": {
"name": "keyword",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used_at": {
"name": "used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_keywords_status": {
"name": "idx_keywords_status",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"cases": {
"name": "cases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_cases_status": {
"name": "idx_cases_status",
"columns": [
"status",
"sort_order"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_items": {
"name": "content_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"extra": {
"name": "extra",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_items_kind": {
"name": "idx_items_kind",
"columns": [
"kind",
"status",
"sort_order"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_image": {
"name": "cover_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"focus_keyword": {
"name": "focus_keyword",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta_description": {
"name": "meta_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_posts_slug": {
"name": "idx_posts_slug",
"columns": [
"slug"
],
"isUnique": true
},
"idx_posts_published_at": {
"name": "idx_posts_published_at",
"columns": [
"published_at"
],
"isUnique": false
},
"idx_posts_status": {
"name": "idx_posts_status",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_settings": {
"name": "site_settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1789107074198, "when": 1789107074198,
"tag": "0001_regular_multiple_man", "tag": "0001_regular_multiple_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1789114640162,
"tag": "0002_red_roughhouse",
"breakpoints": true
} }
] ]
} }
+14
View File
@@ -21,6 +21,15 @@ INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('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);
INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('ai_enabled', '1', strftime('%s','now')*1000),
('ai_base_url', 'https://api.deepinfra.com/v1/openai', strftime('%s','now')*1000),
('ai_chat_model', 'deepseek-ai/DeepSeek-V3-0324', strftime('%s','now')*1000),
('ai_context_prompt', '用香港繁體中文(廣東話書面語)書寫,語氣專業、親切、務實,避免誇大。', strftime('%s','now')*1000),
('ai_business_context', '盈豐太陽能工程有限公司專營香港村屋太陽能系統:現場評估、合法鋁合金支架、代辦中電/港燈上網電價(FiT)申請、專業安裝及驗收認證。着重透明報價、專人跟進、安全合規。', strftime('%s','now')*1000),
('ai_web_search_enabled', '0', strftime('%s','now')*1000),
('ai_web_search_max_results', '5', strftime('%s','now')*1000);
INSERT OR REPLACE INTO content_items (id, kind, title, description, extra, sort_order, status, updated_at) VALUES INSERT OR REPLACE INTO content_items (id, kind, title, description, extra, sort_order, status, updated_at) VALUES
('svc-001', 'service', '村屋天台太陽能系統', '善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。', 'sun', 1, 'published', strftime('%s','now')*1000), ('svc-001', 'service', '村屋天台太陽能系統', '善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。', 'sun', 1, 'published', strftime('%s','now')*1000),
('svc-002', 'service', '合法合規支架工程', '按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。', 'shield', 2, 'published', strftime('%s','now')*1000), ('svc-002', 'service', '合法合規支架工程', '按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。', 'shield', 2, 'published', strftime('%s','now')*1000),
@@ -54,6 +63,11 @@ INSERT OR REPLACE INTO cases (id, title, location, completed_at, description, im
('case-002', '元朗山下村 — 香檳色鋁合金支架', '元朗屏山', '2025年7月', '香檳色支架配合村屋外牆色調,施工整齊,發電穩定。', 'https://images.unsplash.com/photo-1592833159155-c62df1b65634?auto=format&fit=crop&w=1200&q=80', 2, 'published', strftime('%s','now')*1000), ('case-002', '元朗山下村 — 香檳色鋁合金支架', '元朗屏山', '2025年7月', '香檳色支架配合村屋外牆色調,施工整齊,發電穩定。', 'https://images.unsplash.com/photo-1592833159155-c62df1b65634?auto=format&fit=crop&w=1200&q=80', 2, 'published', strftime('%s','now')*1000),
('case-003', '西貢西澳村 — 焗白色鋁合金支架', '新界西貢', '2025年5月', '焗白色支架低調美觀,善用天台每一寸可用空間。', 'https://images.unsplash.com/photo-1508514177221-188b1cf16e9d?auto=format&fit=crop&w=1200&q=80', 3, 'published', strftime('%s','now')*1000); ('case-003', '西貢西澳村 — 焗白色鋁合金支架', '新界西貢', '2025年5月', '焗白色支架低調美觀,善用天台每一寸可用空間。', 'https://images.unsplash.com/photo-1508514177221-188b1cf16e9d?auto=format&fit=crop&w=1200&q=80', 3, 'published', strftime('%s','now')*1000);
INSERT OR REPLACE INTO blog_keywords (id, keyword, status, created_at, used_at, post_id) VALUES
('kw-001', '村屋太陽能回本期', 'pending', strftime('%s','now')*1000, NULL, NULL),
('kw-002', '村屋天台安裝太陽能注意事項', 'pending', strftime('%s','now')*1000, NULL, NULL),
('kw-003', '上網電價 FiT 申請流程', 'pending', strftime('%s','now')*1000, NULL, NULL);
INSERT OR REPLACE INTO posts (id, slug, title, excerpt, content, cover_image, tags, meta_description, status, published_at, updated_at) VALUES INSERT OR REPLACE INTO posts (id, slug, title, excerpt, content, cover_image, tags, meta_description, status, published_at, updated_at) VALUES
('post-001', 'village-house-solar-guide', '村屋安裝太陽能前,你要知嘅 5 件事', '想喺村屋天台裝太陽能?由天台面積、日照、支架法規到回本期,呢篇幫你一次過搞清楚。', ('post-001', 'village-house-solar-guide', '村屋安裝太陽能前,你要知嘅 5 件事', '想喺村屋天台裝太陽能?由天台面積、日照、支架法規到回本期,呢篇幫你一次過搞清楚。',
'村屋天台係香港少數可以合法大規模安裝太陽能嘅地方。不過落決定之前,以下 5 點你一定要知。 '村屋天台係香港少數可以合法大規模安裝太陽能嘅地方。不過落決定之前,以下 5 點你一定要知。
+5 -4
View File
@@ -19,7 +19,8 @@
"lucide-react": "^1.44.0", "lucide-react": "^1.44.0",
"marked": "^18.0.12", "marked": "^18.0.12",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0" "react-dom": "^19.3.0",
"zod": "^4.6.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.2", "@types/node": "^22.20.2",
@@ -9455,9 +9456,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.6.1", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz",
"integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+4 -2
View File
@@ -19,7 +19,8 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"kv:create": "wrangler kv namespace create CACHE", "kv:create": "wrangler kv namespace create CACHE",
"secret": "wrangler secret put ADMIN_PASSWORD", "secret": "wrangler secret put ADMIN_PASSWORD",
"types": "wrangler types" "types": "wrangler types",
"setup": "node scripts/setup.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "^14.3.1", "@astrojs/cloudflare": "^14.3.1",
@@ -33,7 +34,8 @@
"lucide-react": "^1.44.0", "lucide-react": "^1.44.0",
"marked": "^18.0.12", "marked": "^18.0.12",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0" "react-dom": "^19.3.0",
"zod": "^4.6.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.2", "@types/node": "^22.20.2",
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(SCRIPT_DIR);
const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
const WRANGLER = join(ROOT, "wrangler.jsonc");
const SEED_FILE = join(ROOT, "migrations", "seed.sql");
const ASTRO_CONFIG = join(ROOT, "astro.config.mjs");
const DB_NAME = "yingfung-solar-db";
const rl = createInterface({ input, output });
function run(cmd) {
return execSync(cmd, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
}
function runSoft(cmd) {
try {
return { ok: true, out: run(cmd), err: "" };
} catch (err) {
return { ok: false, out: err.stdout ?? err.stderr ?? "", err: err.stderr ?? "" };
}
}
/** 由 wrangler 輸出抽 id;同時支援 JSON"key": "value")同 TOMLkey = "value")。 */
function extractId(out, keys) {
for (const key of keys) {
const re = new RegExp(`(?<!\\w)"?${key}"?\\s*[=:]\\s*"([0-9a-f-]+)"`, "i");
const m = out.match(re);
if (m) return m[1];
}
return null;
}
/** 由 wrangler 嘅 JSON 陣列輸出搵第一個符合條件嘅項目。 */
function findInJsonArray(text, predicate) {
try {
const arr = JSON.parse(text);
if (Array.isArray(arr)) return arr.find(predicate) ?? null;
} catch {
// 唔係 JSON 就當搵唔到。
}
return null;
}
async function main() {
console.log("\n=== 盈豐太陽能 — Cloudflare 一鍵設定 ===\n");
console.log("檢查 wrangler 登入狀態…");
const who = runSoft("npx wrangler whoami --json");
const whoText = `${who.out}\n${who.err}`;
if (!who.ok || /not authenticated|not logged in/i.test(whoText)) {
console.error("未登入 Cloudflare。請先執行: npm run login");
process.exit(1);
}
let config = readFileSync(WRANGLER, "utf8");
if (config.includes(D1_PLACEHOLDER)) {
console.log(`建立 D1 database「${DB_NAME}」…`);
const res = runSoft(`npx wrangler d1 create ${DB_NAME}`);
let out = res.out || res.err || "";
let id = res.ok ? extractId(out, ["database_id"]) : null;
if (!res.ok) {
console.log(" 建立失敗,可能已經存在;嘗試查出現有 database_id…");
const list = runSoft("npx wrangler d1 list --json");
out = `${out}\n${list.out || list.err || ""}`;
const found = findInJsonArray(list.out, (r) => r.name === DB_NAME);
id = found?.uuid ?? null;
}
if (!id) {
console.error("建立 / 查詢 D1 database 失敗。以下係 wrangler 輸出:");
console.error(out);
process.exit(1);
}
config = config.replace(D1_PLACEHOLDER, () => id);
writeFileSync(WRANGLER, config, "utf8");
console.log(` database_id = ${id}(已寫入 wrangler.jsonc`);
} else {
console.log("D1 id 已設定,略過。");
}
if (config.includes(KV_PLACEHOLDER)) {
console.log("建立 KV namespace「CACHE」…");
const res = runSoft("npx wrangler kv namespace create CACHE");
let out = res.out || res.err || "";
let id = res.ok ? extractId(out, ["id"]) : null;
if (!res.ok) {
console.log(" 建立失敗,可能已經存在;嘗試查出現有 KV id…");
const list = runSoft("npx wrangler kv namespace list");
out = `${out}\n${list.out || list.err || ""}`;
const found = findInJsonArray(list.out, (r) => r.title === "CACHE");
id = found?.id ?? null;
}
if (!id) {
console.error("建立 / 查詢 KV namespace 失敗。以下係 wrangler 輸出:");
console.error(out);
process.exit(1);
}
config = config.replace(KV_PLACEHOLDER, () => id);
writeFileSync(WRANGLER, config, "utf8");
console.log(` kv id = ${id}(已寫入 wrangler.jsonc`);
} else {
console.log("KV id 已設定,略過。");
}
const setPassword = await rl.question("而家設定後台密碼 ADMIN_PASSWORD(y/N)");
if (setPassword.trim().toLowerCase() === "y") {
execSync("npx wrangler secret put ADMIN_PASSWORD", { stdio: "inherit" });
console.log("ADMIN_PASSWORD 已處理。");
} else {
console.log("略過 ADMIN_PASSWORD。");
}
const migrate = await rl.question("而家套用雲端 migration + seed(y/N)");
if (migrate.trim().toLowerCase() === "y") {
execSync(`npx wrangler d1 migrations apply ${DB_NAME} --remote`, { stdio: "inherit" });
execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file="${SEED_FILE}"`, { stdio: "inherit" });
}
let siteReady = true;
try {
if (readFileSync(ASTRO_CONFIG, "utf8").includes("https://example.com")) {
siteReady = false;
console.log("\n⚠️ 警告:astro.config.mjs 嘅 site 仍然係 https://example.com。");
console.log(" 上線前必須改成真域名,否則 sitemap / canonical / OG 會出錯。");
console.log(" 已跳過 build + deploy;更新 site 後請自行執行: npm run deploy\n");
}
} catch {
// 讀唔到 astro.config.mjs 就當冇問題,繼續。
}
if (siteReady) {
const deploy = await rl.question("而家 build + deploy(y/N)");
if (deploy.trim().toLowerCase() === "y") {
execSync("npm run build", { stdio: "inherit" });
execSync("npx wrangler deploy", { stdio: "inherit" });
}
}
console.log("\n完成。\n");
rl.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+8 -4
View File
@@ -13,6 +13,7 @@
- `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` 型別宣告。
- `schemas/` — Zod 驗證層:admin 表單輸入(post / content / case / settings / ai / keyword)同 AI 輸出格式;配合 `lib/form.ts``parseForm``lib/ai.ts` 的生成流程使用(`lib/` 細節由 `lib/AGENTS.md` 擁有)。
- `pages/` — 路由層同 `/admin` 後台(詳見下面 Routes & SSR / Admin,因 Astro 限制冇獨立 child doc)。 - `pages/` — 路由層同 `/admin` 後台(詳見下面 Routes & SSR / Admin,因 Astro 限制冇獨立 child doc)。
## Local Contracts ## Local Contracts
@@ -32,6 +33,7 @@
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island`client:load`),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300` - **前台動態頁**:讀 D1 → 傳 props 畀單一 React island`client:load`),並設邊緣快取 `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 頁。
- **Endpoint**:用 `APIRoute``sitemap.xml.ts` 由 D1 讀已發布文章並設 `s-maxage=3600` - **Endpoint**:用 `APIRoute``sitemap.xml.ts` 由 D1 讀已發布文章並設 `s-maxage=3600`
- **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`,上線前要改)。 - **SEO**:用 `Base.astro`canonical / OG / sitemap 全部靠 `astro.config.mjs``site`(現為佔位 `https://example.com`,上線前要改)。
### Admin`pages/admin/` ### Admin`pages/admin/`
@@ -39,11 +41,13 @@
- 所有 `/admin` 路由**必須** `export const prerender = false` - 所有 `/admin` 路由**必須** `export const prerender = false`
- **認證**由 `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` 返對應列表頁。 - **流程**:每個 POST 處理完 `Astro.redirect` 返對應列表頁`/admin/ai` 用 POST/Redirect/Get + `?ok=<key>` + `okMessages` 顯示成功提示(避免重複提交)
- **表單驗證**:所有 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` 去重。 - **文章**`index.astro` 列表、`post/[id].astro` 新增/編輯(`id === "new"` 為新增);slug 自動 `slugify` 並用 `uniqueSlug` 去重。
- **AI 生成**`ai.astro``/admin/ai`)管 AI 設定 + 關鍵字佇列,同步呼叫 `generateBlogPost`;成功會 redirect 去新草稿 `/admin/post/<id>`。生成按鈕用 inline `onsubmit` 顯示「生成中…」。
- **圖片**:一律以 URL 字串處理(暫無上傳)。 - **圖片**:一律以 URL 字串處理(暫無上傳)。
- 前台可見性靠 `status``published` / `draft`);列表頁顯示全部,前台只顯示 published。 - 前台可見性靠 `status``published` / `draft`);列表頁顯示全部,前台只顯示 published。
@@ -51,8 +55,8 @@
- 改前台視覺先睇 `theme/system.ts` 的 tokens,優先重用語意 token,唔好散落硬編色值。 - 改前台視覺先睇 `theme/system.ts` 的 tokens,優先重用語意 token,唔好散落硬編色值。
- 新增 endpoint 或 SSR 頁後,確認 `prerender = false` 同相應快取 header 都有。 - 新增 endpoint 或 SSR 頁後,確認 `prerender = false` 同相應快取 header 都有。
- 新增一個內容欄位:先改 `db/schema.ts``npm run db:generate`,再改對應 admin 表單同 `data/content.ts` - 新增一個內容欄位:先改 `db/schema.ts``npm run db:generate`,再改對應 `schemas/` 輸入 schema、admin 表單同 `data/content.ts`
- **唔好喺 `src/pages/``.md` 文件**(例如 AGENTS.md):Astro 會將佢變成公開路由(今次已實測 `/AGENTS``/admin/AGENTS`)。Astro 只支援用 `_` 前綴豁免,但同 DOX 的 `AGENTS.md` 命名衝突,所以 `pages/` 的合約一律寫喺本文件。其餘 `src/` 子目錄的 `AGENTS.md`data/db/lib/components)唔會被路由。 - **唔好喺 `src/pages/``.md` 文件**(例如 AGENTS.md):Astro 會將佢變成公開路由(例如 `/AGENTS``/admin/AGENTS`)。Astro 只支援用 `_` 前綴豁免,但同 DOX 的 `AGENTS.md` 命名衝突,所以 `pages/` 的合約一律寫喺本文件。其餘 `src/` 子目錄的 `AGENTS.md`data/db/lib/components)唔會被路由。
## Verification ## Verification
@@ -66,4 +70,4 @@
| `components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 | | `components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 |
| `data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 | | `data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
| `db/AGENTS.md` | Drizzle schemamigration 的唯一來源) | | `db/AGENTS.md` | Drizzle schemamigration 的唯一來源) |
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 | | `lib/AGENTS.md` | auth / env / db / form / ai / markdown 基礎工具 |
+3 -1
View File
@@ -7,13 +7,15 @@ D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`setting
## Ownership ## Ownership
- 擁有 `src/data/` 兩個檔案。 - 擁有 `src/data/` 兩個檔案。
- Drizzle schema 本體喺 `src/db/schema.ts`(見兄弟 `db/AGENTS.md`),呢度只消費其型別 - Drizzle schema 本體喺 `src/db/schema.ts`(見兄弟 `db/AGENTS.md`),呢度只消費其 select 型別;表單同 AI 嘅**輸入**型別由 `src/schemas/`Zod `z.infer`)提供,兩者唔重覆
## Local Contracts ## Local Contracts
- **查詢集中**:所有前台/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`
- **關鍵字佇列**`getKeywords(db)` 回全部 `blog_keywords`(按 `createdAt``id` 排序),供 `/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 之後,要同步加落 `migrations/seed.sql` - **設定欄位**`settings-fields.ts``SETTINGS_GROUPS``/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `migrations/seed.sql`
+25 -1
View File
@@ -1,10 +1,12 @@
import { and, asc, desc, eq } from "drizzle-orm"; import { and, asc, desc, eq, ne } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1"; import type { DrizzleD1Database } from "drizzle-orm/d1";
import { import {
blogKeywords,
cases, cases,
contentItems, contentItems,
posts, posts,
siteSettings, siteSettings,
type BlogKeyword,
type CaseStudy, type CaseStudy,
type ContentItem, type ContentItem,
type ContentKind, type ContentKind,
@@ -62,6 +64,13 @@ export async function getPostBySlug(db: Db, slug: string): Promise<Post | null>
return post ?? null; return post ?? null;
} }
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
return db
.select()
.from(blogKeywords)
.orderBy(asc(blogKeywords.createdAt), asc(blogKeywords.id));
}
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),
@@ -102,3 +111,18 @@ export function formatDate(d: Date | null | undefined): string {
day: "numeric", day: "numeric",
}); });
} }
/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}
+5 -4
View File
@@ -11,12 +11,13 @@ Drizzle schema 定義,係 D1 migration 的唯一來源。
## Local Contracts ## Local Contracts
- **表**`posts``site_settings``content_items``cases` - **表**`posts``site_settings``content_items``cases``blog_keywords`
- **可重複內容**`content_items``kind` 區分,`CONTENT_KINDS = ["service", "feature", "step", "faq"]` - **可重複內容**`content_items``kind` 區分,`CONTENT_KINDS = ["service", "feature", "step", "faq"]`
- **狀態**`posts` / `content_items` / `cases` 都有 `status``draft` | `published`)。 - **狀態**`posts` / `content_items` / `cases` 都有 `status``draft` | `published``blog_keywords``KEYWORD_STATUSES = ["pending", "generated", "skipped"]``KeywordStatus`
- **型別**:由 `$inferSelect` 匯出(`Post``ContentItem``CaseStudy``SiteSetting` 等),其他地方重用呢啲型別 - **AI 相關欄位**`posts.focusKeyword`(生成時存焦點關鍵字);`blog_keywords` 為手動維護的關鍵字佇列,生成成功後 `status``generated` 並記 `usedAt` / `postId`
- **型別**:由 `$inferSelect` / `$inferInsert` 匯出(`Post``ContentItem``CaseStudy``SiteSetting``BlogKeyword``NewBlogKeyword` 等),其他地方重用呢啲型別。
- **改 schema 流程**:改 `schema.ts``npm run db:generate` 產生新 migration。**唔好手改** `migrations/*.sql``seed.sql` 除外)。 - **改 schema 流程**:改 `schema.ts``npm run db:generate` 產生新 migration。**唔好手改** `migrations/*.sql``seed.sql` 除外)。
- 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)`)。 - 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)``idx_keywords_status(status)`)。
## Work Guidance ## Work Guidance
+24
View File
@@ -13,6 +13,7 @@ export const posts = sqliteTable(
content: text("content").notNull(), content: text("content").notNull(),
coverImage: text("cover_image"), coverImage: text("cover_image"),
tags: text("tags"), tags: text("tags"),
focusKeyword: text("focus_keyword"),
metaDescription: text("meta_description"), metaDescription: text("meta_description"),
status: text("status", { enum: ["draft", "published"] }) status: text("status", { enum: ["draft", "published"] })
.notNull() .notNull()
@@ -90,8 +91,31 @@ export const cases = sqliteTable(
(t) => [index("idx_cases_status").on(t.status, t.sortOrder)], (t) => [index("idx_cases_status").on(t.status, t.sortOrder)],
); );
export const KEYWORD_STATUSES = ["pending", "generated", "skipped"] as const;
export type KeywordStatus = (typeof KEYWORD_STATUSES)[number];
/**
* AI 生成用嘅關鍵字佇列(手動維護,冇 Google Ads discovery)。
*/
export const blogKeywords = sqliteTable(
"blog_keywords",
{
id: text("id").primaryKey(),
keyword: text("keyword").notNull(),
status: text("status", { enum: KEYWORD_STATUSES }).notNull().default("pending"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
usedAt: integer("used_at", { mode: "timestamp_ms" }),
postId: text("post_id"),
},
(t) => [index("idx_keywords_status").on(t.status)],
);
export type Post = typeof posts.$inferSelect; export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert; export type NewPost = typeof posts.$inferInsert;
export type ContentItem = typeof contentItems.$inferSelect; export type ContentItem = typeof contentItems.$inferSelect;
export type CaseStudy = typeof cases.$inferSelect; export type CaseStudy = typeof cases.$inferSelect;
export type SiteSetting = typeof siteSettings.$inferSelect; export type SiteSetting = typeof siteSettings.$inferSelect;
export type BlogKeyword = typeof blogKeywords.$inferSelect;
export type NewBlogKeyword = typeof blogKeywords.$inferInsert;
+6 -1
View File
@@ -12,7 +12,10 @@ const links = [
{ href: "/admin/content/faq", label: "常見問題" }, { href: "/admin/content/faq", label: "常見問題" },
{ href: "/admin/cases", label: "案例" }, { href: "/admin/cases", label: "案例" },
{ href: "/admin/settings", label: "設定" }, { href: "/admin/settings", label: "設定" },
{ href: "/admin/ai", label: "AI 生成" },
]; ];
const isActive = (href: string) =>
href === "/admin" ? path === "/admin" : path === href || path.startsWith(href + "/");
--- ---
<!doctype html> <!doctype html>
@@ -33,7 +36,7 @@ const links = [
<nav> <nav>
{ {
links.map((l) => ( links.map((l) => (
<a href={l.href} class={path === l.href || path.startsWith(l.href + "/") ? "active" : ""}> <a href={l.href} class={isActive(l.href) ? "active" : ""}>
{l.label} {l.label}
</a> </a>
)) ))
@@ -107,6 +110,8 @@ const links = [
.badge.published { background: #e1f5ee; color: #0f6e56; } .badge.published { background: #e1f5ee; color: #0f6e56; }
.badge.draft { background: #faeeda; color: #854f0b; } .badge.draft { background: #faeeda; color: #854f0b; }
.muted { color: #8a9793; font-size: 13px; } .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; }
.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; } } @media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
.row { display: grid; gap: 12px; grid-template-columns: 1fr; } .row { display: grid; gap: 12px; grid-template-columns: 1fr; }
+11 -3
View File
@@ -2,26 +2,34 @@
## Purpose ## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、Markdown 處理。 跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理。
## Ownership ## Ownership
- 擁有 `src/lib/` 個檔案。 - 擁有 `src/lib/` 個檔案`env.ts``db.ts``auth.ts``form.ts``ai.ts``markdown.ts`
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。 - 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
## Local Contracts ## Local Contracts
- **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。 - **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?``AI_API_KEY?``TAVILY_API_KEY?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。
- **`db.ts`**`getDb(env.DB)` → Drizzle,並 re-export `schema` - **`db.ts`**`getDb(env.DB)` → Drizzle,並 re-export `schema`
- **`auth.ts`**HMAC-SHA256 signed cookie 認證。 - **`auth.ts`**HMAC-SHA256 signed cookie 認證。
- 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions` - 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions`
- Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"` - Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"`
- 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。 - 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。
- **`form.ts`**`parseForm(formData, schema)``FormData` 抽成物件交畀 Zod 驗證;回 `{ ok: true, data }``{ ok: false, errors }`field → 訊息)。admin 表單一律經呢度,schema 喺 `src/schemas/`
- **`ai.ts`**AI 生成 Blog 流程。
- `getAiSettings(db)``site_settings` 砌出供應商設定(enabled / baseUrl / model / context / web search)。
- Tavily 上網研究(失敗回空字串,唔中斷)+ OpenAI 相容 `chat/completions`(有 timeout)。
- `parseGeneratedPost(text)` 解析 `TITLE` / `CONTENT` / `META_DESC` 並用 `aiGeneratedPost` schema 驗證。
- `generateBlogPost(db, env, input)` 同步生成:`{ mode: "topic" }``{ mode: "next" }`(攞佇列下一個 pending);一律存 **draft** 並將關鍵字標記 `generated`。缺 `AI_API_KEY` / 未啟用時回 `{ ok: false, error }`,唔會 throw。
- **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt` - **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`
## Work Guidance ## Work Guidance
- 認證相關改動要同時檢查 `src/middleware.ts``pages/admin/login.astro``pages/admin/logout.ts` - 認證相關改動要同時檢查 `src/middleware.ts``pages/admin/login.astro``pages/admin/logout.ts`
- 新增/改 admin 表單欄位時,同步更新 `src/schemas/` 對應 schema(輸入型別用 `z.infer` 匯出)。
- AI secrets 喺 `AppEnv` 宣告:本機放 `.dev.vars`,雲端用 `wrangler secret put` 或 Workers Dashboard。
- Markdown 內容由後台輸入;如將來開放不受信任輸入,需重新評估 sanitize。 - Markdown 內容由後台輸入;如將來開放不受信任輸入,需重新評估 sanitize。
## Verification ## Verification
+224
View File
@@ -0,0 +1,224 @@
import { asc, eq } from "drizzle-orm";
import type { Db } from "../data/content";
import { getSettings, uniqueSlug } from "../data/content";
import { blogKeywords, posts } from "../db/schema";
import type { AppEnv } from "./env";
import { autoExcerpt, slugify } from "./markdown";
import { aiGeneratedPost } from "../schemas";
type AiSettings = {
enabled: boolean;
baseUrl: string;
model: string;
contextPrompt: string;
businessContext: string;
webSearchEnabled: boolean;
webSearchMaxResults: number;
};
export async function getAiSettings(db: Db): Promise<AiSettings> {
const s = await getSettings(db);
return {
enabled: s.ai_enabled !== "0",
baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai",
model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324",
contextPrompt: s.ai_context_prompt ?? "",
businessContext: s.ai_business_context ?? "",
webSearchEnabled: s.ai_web_search_enabled === "1",
webSearchMaxResults: Math.min(10, Math.max(1, Number(s.ai_web_search_max_results) || 5)),
};
}
type TavilyResult = { title?: string; url?: string; content?: string };
/** Tavily 搜尋,失敗回空字串(唔中斷生成)。 */
async function tavilyResearch(query: string, apiKey: string, maxResults: number): Promise<string> {
try {
const res = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return "";
const data = (await res.json()) as { results?: TavilyResult[] };
const results = data.results ?? [];
return results
.map((r, i) => `${i + 1}. ${r.title ?? ""}${r.url ?? ""}\n${r.content ?? ""}`)
.join("\n\n");
} catch {
return "";
}
}
function buildSystemPrompt(opts: {
contextPrompt: string;
businessContext: string;
research: string;
topic: string;
}): string {
const parts: string[] = [];
if (opts.contextPrompt) parts.push(opts.contextPrompt);
if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
主題:${opts.topic}
要求:
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
- 用 ## / ### 分段,段落清晰易讀。
- 開頭先畀一段總結(summary-first),再展開。
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
輸出格式(必須嚴格跟隨,唔要加額外說明):
TITLE: <文章標題>
CONTENT:
<Markdown 正文,唔需要重複標題>
META_DESC: <160 字以內 SEO 描述>`);
return parts.join("\n\n");
}
async function callChat(settings: AiSettings, apiKey: string, systemPrompt: string): Promise<string> {
const res = await fetch(`${settings.baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: settings.model,
messages: [{ role: "user", content: systemPrompt }],
max_tokens: 2200,
temperature: 0.7,
}),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`AI 供應商回應 ${res.status}${body.slice(0, 200)}`);
}
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const text = data.choices?.[0]?.message?.content ?? "";
if (!text) throw new Error("AI 冇回覆內容。");
return text;
}
export type ParsedGenerated =
| { ok: true; data: { title: string; content: string; metaDescription: string; excerpt: string } }
| { ok: false; error: string };
export function parseGeneratedPost(text: string): ParsedGenerated {
const normalized = text
.replace(/```[a-z]*\n?/gi, "")
.replace(/```/g, "")
.replace(/\*\*\s*(TITLE|CONTENT|META_DESC)\s*[:]\s*\*\*/gi, "$1:")
.replace(/__\s*(TITLE|CONTENT|META_DESC)\s*[:]\s*__/gi, "$1:")
.replace(/\*\*(TITLE|CONTENT|META_DESC)\*\*\s*[:]/gi, "$1:");
const titleMatch = normalized.match(/TITLE[:]\s*(.+)/i);
const contentMatch = normalized.match(/CONTENT[:]\s*([\s\S]*?)(?:\nMETA_DESC[:]|$)/i);
const metaMatch = normalized.match(/META_DESC[:]\s*([\s\S]+)/i);
const content = (contentMatch?.[1] ?? "").trim();
const metaDescription = (metaMatch?.[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160);
const candidate = {
title: (titleMatch?.[1] ?? "").trim(),
content,
metaDescription,
excerpt: metaDescription || autoExcerpt(content),
};
const parsed = aiGeneratedPost.safeParse(candidate);
if (!parsed.success) return { ok: false, error: "AI 輸出格式唔正確,請再試。" };
return { ok: true, data: parsed.data };
}
export type GenerateResult = { ok: true; postId: string } | { ok: false; error: string };
export async function generateBlogPost(
db: Db,
env: AppEnv,
input: { mode: "topic"; topic: string } | { mode: "next" },
): Promise<GenerateResult> {
const settings = await getAiSettings(db);
if (!settings.enabled) return { ok: false, error: "AI 生成未啟用。" };
const apiKey = env.AI_API_KEY;
if (!apiKey) return { ok: false, error: "未設定 AI_API_KEY。" };
let topic: string;
let keywordId: string | null = null;
if (input.mode === "next") {
const [next] = await db
.select()
.from(blogKeywords)
.where(eq(blogKeywords.status, "pending"))
.orderBy(asc(blogKeywords.createdAt))
.limit(1);
if (!next) return { ok: false, error: "關鍵字佇列冇 pending 項目。" };
topic = next.keyword;
keywordId = next.id;
} else {
topic = input.topic.trim();
if (!topic) return { ok: false, error: "請輸入主題。" };
}
let research = "";
if (settings.webSearchEnabled && env.TAVILY_API_KEY) {
research = await tavilyResearch(topic, env.TAVILY_API_KEY, settings.webSearchMaxResults);
}
let generated;
try {
const text = await callChat(
settings,
apiKey,
buildSystemPrompt({
contextPrompt: settings.contextPrompt,
businessContext: settings.businessContext,
research,
topic,
}),
);
const parsed = parseGeneratedPost(text);
if (!parsed.ok) return parsed;
generated = parsed.data;
} catch (err) {
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
return { ok: false, error: "AI 供應商回應逾時,請再試。" };
}
return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
}
const now = new Date();
const id = crypto.randomUUID();
const excerpt = generated.excerpt || autoExcerpt(generated.content);
try {
const slug = await uniqueSlug(db, slugify(generated.title));
await db.insert(posts).values({
id,
slug,
title: generated.title,
excerpt,
content: generated.content,
coverImage: null,
tags: null,
focusKeyword: topic,
metaDescription: generated.metaDescription || excerpt.slice(0, 155),
status: "draft",
publishedAt: null,
updatedAt: now,
});
if (keywordId) {
await db
.update(blogKeywords)
.set({ status: "generated", usedAt: now, postId: id })
.where(eq(blogKeywords.id, keywordId));
}
} catch {
return { ok: false, error: "寫入草稿失敗,請再試。" };
}
return { ok: true, postId: id };
}
+2
View File
@@ -4,6 +4,8 @@ export type AppEnv = {
DB: D1Database; DB: D1Database;
CACHE: KVNamespace; CACHE: KVNamespace;
ADMIN_PASSWORD?: string; ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
}; };
/** /**
+23
View File
@@ -0,0 +1,23 @@
import type { ZodType } from "zod";
export type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Record<string, string> };
/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */
export function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T> {
const raw: Record<string, unknown> = {};
for (const [key, value] of form.entries()) {
if (typeof value === "string") raw[key] = value;
}
const result = schema.safeParse(raw);
if (result.success) return { ok: true, data: result.data };
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".") || "_form";
if (!errors[key]) errors[key] = issue.message;
}
return { ok: false, errors };
}
+217
View File
@@ -0,0 +1,217 @@
---
import { eq } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { getKeywords, getSettings } from "../../data/content";
import { blogKeywords, siteSettings } from "../../db/schema";
import { parseForm } from "../../lib/form";
import { aiSettingsInput, keywordInput } from "../../schemas";
import { generateBlogPost } from "../../lib/ai";
export const prerender = false;
const env = getEnv();
const db = getDb(env.DB);
let error = "";
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "save-settings") {
const parsed = parseForm(form, aiSettingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const now = new Date();
for (const [key, value] of Object.entries(parsed.data)) {
const v = String(value ?? "");
await db
.insert(siteSettings)
.values({ key, value: v, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value: v, updatedAt: now } });
}
return Astro.redirect("/admin/ai?ok=settings");
}
} else if (action === "add-keyword") {
const parsed = parseForm(form, keywordInput);
if (!parsed.ok) {
error = parsed.errors.keyword ?? "請輸入關鍵字。";
} else {
await db.insert(blogKeywords).values({
id: crypto.randomUUID(),
keyword: parsed.data.keyword,
status: "pending",
createdAt: new Date(),
});
return Astro.redirect("/admin/ai?ok=keyword");
}
} else if (action === "delete-keyword") {
await db.delete(blogKeywords).where(eq(blogKeywords.id, str(form, "id")));
return Astro.redirect("/admin/ai?ok=deleted");
} else if (action === "skip-keyword") {
await db
.update(blogKeywords)
.set({ status: "skipped" })
.where(eq(blogKeywords.id, str(form, "id")));
return Astro.redirect("/admin/ai?ok=skipped");
} else if (action === "generate-topic" || action === "generate-next") {
const result = await generateBlogPost(
db,
env,
action === "generate-next"
? { mode: "next" }
: { mode: "topic", topic: str(form, "topic") },
);
if (result.ok) return Astro.redirect(`/admin/post/${result.postId}`);
error = result.error;
}
}
const okMessages: Record<string, string> = {
settings: "已儲存 AI 設定。",
keyword: "已加入關鍵字。",
deleted: "已刪除。",
skipped: "已標記略過。",
};
const notice = okMessages[Astro.url.searchParams.get("ok") ?? ""] ?? "";
const settings = await getSettings(db);
const keywords = await getKeywords(db);
const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KEY) };
---
<AdminLayout title="AI 生成">
<h1>AI 生成 Blog</h1>
<p class="sub">設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(生成後會開啟文章編輯頁覆核)。</p>
{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>
)
}
<div class="card">
<h2>快速生成</h2>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end" onsubmit="this.style.pointerEvents='none'; this.style.opacity='0.65'; if(event.submitter) event.submitter.textContent='生成中…';">
<div style="flex:1;min-width:240px">
<label for="topic">自由輸入主題</label>
<input id="topic" name="topic" type="text" placeholder="例:村屋太陽能回本期點計?" />
</div>
<button type="submit" name="action" value="generate-topic">生成草稿</button>
<button type="submit" name="action" value="generate-next" class="secondary">生成下一篇(佇列)</button>
</form>
<p class="muted" style="margin:10px 0 0">生成需時約 10–40 秒,請耐心等候,唔好重複提交。</p>
</div>
<div class="card">
<h2>AI 設定</h2>
<form method="post">
<input type="hidden" name="action" value="save-settings" />
<div class="grid2">
<div>
<label for="ai_enabled">AI 生成</label>
<select id="ai_enabled" name="ai_enabled">
<option value="1" selected={settings.ai_enabled !== "0"}>啟用</option>
<option value="0" selected={settings.ai_enabled === "0"}>停用</option>
</select>
</div>
<div>
<label for="ai_chat_model">模型</label>
<input id="ai_chat_model" name="ai_chat_model" type="text" value={settings.ai_chat_model ?? "deepseek-ai/DeepSeek-V3-0324"} />
</div>
<div>
<label for="ai_base_url">OpenAI 相容 Base URL</label>
<input id="ai_base_url" name="ai_base_url" type="text" value={settings.ai_base_url ?? "https://api.deepinfra.com/v1/openai"} />
<p class="muted" style="margin:6px 0 0">⚠️ 只填信任嘅供應商;AI_API_KEY 會傳送去呢個網址。</p>
</div>
<div>
<label for="ai_web_search_enabled">上網研究(Tavily</label>
<select id="ai_web_search_enabled" name="ai_web_search_enabled">
<option value="1" selected={settings.ai_web_search_enabled === "1"}>啟用</option>
<option value="0" selected={settings.ai_web_search_enabled !== "1"}>停用</option>
</select>
</div>
<div>
<label for="ai_web_search_max_results">Tavily 結果數(110</label>
<input id="ai_web_search_max_results" name="ai_web_search_max_results" type="text" value={settings.ai_web_search_max_results ?? "5"} />
</div>
<div>
<label>API Key 狀態</label>
<p class="muted" style="margin:10px 0 0">
AI_API_KEY{secrets.ai ? "已設定" : "未設定"} · TAVILY_API_KEY{secrets.tavily ? "已設定" : "未設定"}
</p>
</div>
<div style="grid-column:1/-1">
<label for="ai_context_prompt">寫作風格 / 語氣</label>
<textarea id="ai_context_prompt" name="ai_context_prompt">{settings.ai_context_prompt ?? ""}</textarea>
</div>
<div style="grid-column:1/-1">
<label for="ai_business_context">公司背景資料(grounding</label>
<textarea id="ai_business_context" name="ai_business_context">{settings.ai_business_context ?? ""}</textarea>
</div>
</div>
<div class="actions"><button type="submit">儲存 AI 設定</button></div>
</form>
</div>
<div class="card">
<h2>關鍵字佇列({keywords.length}</h2>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
<input type="hidden" name="action" value="add-keyword" />
<div style="flex:1;min-width:240px">
<label for="keyword">新增關鍵字 / 主題</label>
<input id="keyword" name="keyword" type="text" placeholder="例:太陽能板保養" />
</div>
<button type="submit" class="secondary">+ 加入</button>
</form>
{
keywords.length === 0 ? (
<p class="muted" style="margin-top:14px">暫時未有關鍵字。</p>
) : (
<table style="margin-top:14px">
<thead>
<tr>
<th>關鍵字</th>
<th style="width:100px">狀態</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{keywords.map((k) => (
<tr>
<td>{k.keyword}</td>
<td>
<span class={`badge ${k.status === "generated" ? "published" : "draft"}`}>
{k.status === "pending" ? "待生成" : k.status === "generated" ? "已生成" : "已略過"}
</span>
</td>
<td>
<form method="post" style="display:inline">
<input type="hidden" name="id" value={k.id} />
{k.status === "pending" && (
<button class="secondary mini" name="action" value="skip-keyword">略過</button>
)}
<button class="danger mini" name="action" value="delete-keyword" onclick="return confirm('確定刪除?')">
刪除
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)
}
</div>
</AdminLayout>
+31 -17
View File
@@ -4,11 +4,17 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
import { cases } from "../../db/schema"; import { cases } from "../../db/schema";
import { getDb } from "../../lib/db"; import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env"; import { getEnv } from "../../lib/env";
import { parseForm } from "../../lib/form";
import { caseInput } from "../../schemas";
export const prerender = false; export const prerender = false;
const db = getDb(getEnv().DB); const db = getDb(getEnv().DB);
let errors: Record<string, string> = {};
let failedId = "";
let failedAdd = false;
function str(form: FormData, key: string): string { function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim(); return String(form.get(key) ?? "").trim();
} }
@@ -17,36 +23,40 @@ if (Astro.request.method === "POST") {
const form = await Astro.request.formData(); const form = await Astro.request.formData();
const action = str(form, "action"); const action = str(form, "action");
if (action === "add") { if (action === "add" || action === "save") {
const title = str(form, "title"); const parsed = parseForm(form, caseInput);
if (title) { if (!parsed.ok) {
errors = parsed.errors;
failedId = str(form, "id");
failedAdd = action === "add";
} else if (action === "add") {
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases); const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
await db.insert(cases).values({ await db.insert(cases).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
title, title: parsed.data.title,
location: str(form, "location") || null, location: parsed.data.location || null,
completedAt: str(form, "completedAt") || null, completedAt: parsed.data.completedAt || null,
description: str(form, "description") || null, description: parsed.data.description || null,
imageUrl: str(form, "imageUrl") || null, imageUrl: parsed.data.imageUrl || null,
sortOrder: (row?.m ?? 0) + 1, sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published", status: parsed.data.status,
updatedAt: new Date(), updatedAt: new Date(),
}); });
} } else {
} else if (action === "save") {
const id = str(form, "id"); const id = str(form, "id");
await db await db
.update(cases) .update(cases)
.set({ .set({
title: str(form, "title"), title: parsed.data.title,
location: str(form, "location") || null, location: parsed.data.location || null,
completedAt: str(form, "completedAt") || null, completedAt: parsed.data.completedAt || null,
description: str(form, "description") || null, description: parsed.data.description || null,
imageUrl: str(form, "imageUrl") || null, imageUrl: parsed.data.imageUrl || null,
status: str(form, "status") === "draft" ? "draft" : "published", status: parsed.data.status,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(cases.id, id)); .where(eq(cases.id, id));
}
} else if (action === "delete") { } else if (action === "delete") {
await db.delete(cases).where(eq(cases.id, str(form, "id"))); await db.delete(cases).where(eq(cases.id, str(form, "id")));
} else if (action === "up" || action === "down") { } else if (action === "up" || action === "down") {
@@ -68,8 +78,10 @@ if (Astro.request.method === "POST") {
} }
} }
if (Object.keys(errors).length === 0) {
return Astro.redirect("/admin/cases"); return Astro.redirect("/admin/cases");
} }
}
const list = await db.select().from(cases).orderBy(asc(cases.sortOrder)); const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
--- ---
@@ -85,6 +97,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
<div class="grid2"> <div class="grid2">
<div> <div>
<label for="a-title">標題</label> <label for="a-title">標題</label>
{failedAdd && errors.title && <span class="field-error">{errors.title}</span>}
<input id="a-title" name="title" type="text" required placeholder="例:大埔上碗窯 — 雙玻光伏板" /> <input id="a-title" name="title" type="text" required placeholder="例:大埔上碗窯 — 雙玻光伏板" />
</div> </div>
<div> <div>
@@ -119,6 +132,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
<div class="grid2"> <div class="grid2">
<div> <div>
<label>標題</label> <label>標題</label>
{failedId === item.id && errors.title && <span class="field-error">{errors.title}</span>}
<input name="title" type="text" value={item.title} required /> <input name="title" type="text" value={item.title} required />
</div> </div>
<div> <div>
+28 -13
View File
@@ -4,6 +4,8 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
import { contentItems, CONTENT_KINDS, type ContentKind } from "../../../db/schema"; import { contentItems, CONTENT_KINDS, type ContentKind } from "../../../db/schema";
import { getDb } from "../../../lib/db"; import { getDb } from "../../../lib/db";
import { getEnv } from "../../../lib/env"; import { getEnv } from "../../../lib/env";
import { parseForm } from "../../../lib/form";
import { contentItemInput } from "../../../schemas";
export const prerender = false; export const prerender = false;
@@ -50,6 +52,10 @@ const META: Record<
const meta = META[kind]; const meta = META[kind];
let errors: Record<string, string> = {};
let failedId = "";
let failedAdd = false;
function str(form: FormData, key: string): string { function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim(); return String(form.get(key) ?? "").trim();
} }
@@ -58,9 +64,14 @@ if (Astro.request.method === "POST") {
const form = await Astro.request.formData(); const form = await Astro.request.formData();
const action = str(form, "action"); const action = str(form, "action");
if (action === "add") { if (action === "add" || action === "save") {
const title = str(form, "title"); form.set("kind", kind);
if (title) { const parsed = parseForm(form, contentItemInput);
if (!parsed.ok) {
errors = parsed.errors;
failedId = str(form, "id");
failedAdd = action === "add";
} else if (action === "add") {
const [row] = await db const [row] = await db
.select({ m: max(contentItems.sortOrder) }) .select({ m: max(contentItems.sortOrder) })
.from(contentItems) .from(contentItems)
@@ -68,26 +79,26 @@ if (Astro.request.method === "POST") {
await db.insert(contentItems).values({ await db.insert(contentItems).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
kind, kind,
title, title: parsed.data.title,
description: str(form, "description"), description: parsed.data.description,
extra: str(form, "extra") || null, extra: parsed.data.extra || null,
sortOrder: (row?.m ?? 0) + 1, sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published", status: parsed.data.status,
updatedAt: new Date(), updatedAt: new Date(),
}); });
} } else {
} else if (action === "save") {
const id = str(form, "id"); const id = str(form, "id");
await db await db
.update(contentItems) .update(contentItems)
.set({ .set({
title: str(form, "title"), title: parsed.data.title,
description: str(form, "description"), description: parsed.data.description,
extra: str(form, "extra") || null, extra: parsed.data.extra || null,
status: str(form, "status") === "draft" ? "draft" : "published", status: parsed.data.status,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(contentItems.id, id), eq(contentItems.kind, kind))); .where(and(eq(contentItems.id, id), eq(contentItems.kind, kind)));
}
} else if (action === "delete") { } else if (action === "delete") {
await db await db
.delete(contentItems) .delete(contentItems)
@@ -116,8 +127,10 @@ if (Astro.request.method === "POST") {
} }
} }
if (Object.keys(errors).length === 0) {
return Astro.redirect(`/admin/content/${kind}`); return Astro.redirect(`/admin/content/${kind}`);
} }
}
const items = await db const items = await db
.select() .select()
@@ -137,6 +150,7 @@ const items = await db
<div class="grid2"> <div class="grid2">
<div> <div>
<label for="add-title">{meta.titleLabel}</label> <label for="add-title">{meta.titleLabel}</label>
{failedAdd && errors.title && <span class="field-error">{errors.title}</span>}
<input id="add-title" name="title" type="text" required /> <input id="add-title" name="title" type="text" required />
</div> </div>
{ {
@@ -169,6 +183,7 @@ const items = await db
<div class="grid2"> <div class="grid2">
<div> <div>
<label>{meta.titleLabel}</label> <label>{meta.titleLabel}</label>
{failedId === item.id && errors.title && <span class="field-error">{errors.title}</span>}
<input name="title" type="text" value={item.title} required /> <input name="title" type="text" value={item.title} required />
</div> </div>
{meta.extraLabel && ( {meta.extraLabel && (
+1
View File
@@ -31,6 +31,7 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
<a class="btn secondary" href="/admin/content/faq">常見問題({countOf("faq")}</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/cases">完成案例({caseCount?.n ?? 0}</a>
<a class="btn secondary" href="/admin/settings">網站設定</a> <a class="btn secondary" href="/admin/settings">網站設定</a>
<a class="btn secondary" href="/admin/ai">AI 生成</a>
</div> </div>
</div> </div>
+32 -35
View File
@@ -1,10 +1,13 @@
--- ---
import { and, eq, ne } from "drizzle-orm"; import { eq } 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 { posts } from "../../../db/schema"; import { posts } from "../../../db/schema";
import { slugify, autoExcerpt } from "../../../lib/markdown"; import { slugify, autoExcerpt } from "../../../lib/markdown";
import { getEnv } from "../../../lib/env"; import { getEnv } from "../../../lib/env";
import { uniqueSlug } from "../../../data/content";
import { parseForm } from "../../../lib/form";
import { postInput } from "../../../schemas";
export const prerender = false; export const prerender = false;
@@ -13,6 +16,7 @@ const isNew = id === "new";
const db = getDb(getEnv().DB); const db = getDb(getEnv().DB);
let error = ""; let error = "";
let errors: Record<string, string> = {};
const [existing] = isNew const [existing] = isNew
? [] ? []
@@ -22,25 +26,6 @@ if (!isNew && !existing) {
return Astro.redirect("/admin"); return Astro.redirect("/admin");
} }
/** slug 撞咗就加 -2、-3… */
async function uniqueSlug(base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(
selfId
? and(eq(posts.slug, candidate), ne(posts.id, selfId))
: eq(posts.slug, candidate),
)
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}
if (Astro.request.method === "POST") { if (Astro.request.method === "POST") {
const form = await Astro.request.formData(); const form = await Astro.request.formData();
const action = String(form.get("action") ?? "save"); const action = String(form.get("action") ?? "save");
@@ -50,29 +35,34 @@ if (Astro.request.method === "POST") {
return Astro.redirect("/admin"); return Astro.redirect("/admin");
} }
const title = String(form.get("title") ?? "").trim(); const parsed = parseForm(form, postInput);
const content = String(form.get("content") ?? ""); if (!parsed.ok) {
if (!title) { errors = parsed.errors;
error = "標題唔可以留空。"; error = Object.values(parsed.errors)[0] ?? "輸入有誤。";
} else { } else {
const status = String(form.get("status") ?? "draft") === "published" ? "published" : "draft"; const data = parsed.data;
const rawSlug = String(form.get("slug") ?? "").trim(); const status = data.status;
const slug = await uniqueSlug(rawSlug ? slugify(rawSlug) : slugify(title), isNew ? undefined : id); const slug = await uniqueSlug(
const excerpt = String(form.get("excerpt") ?? "").trim() || autoExcerpt(content); db,
const metaDescription = String(form.get("metaDescription") ?? "").trim() || excerpt.slice(0, 155); data.slug ? slugify(data.slug) : slugify(data.title),
const coverImage = String(form.get("coverImage") ?? "").trim() || null; isNew ? undefined : id,
const tags = String(form.get("tags") ?? "").trim() || null; );
const excerpt = data.excerpt || autoExcerpt(data.content);
const metaDescription = data.metaDescription || excerpt.slice(0, 155);
const coverImage = data.coverImage || null;
const tags = data.tags || null;
const now = new Date(); const now = new Date();
if (isNew) { if (isNew) {
await db.insert(posts).values({ await db.insert(posts).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
slug, slug,
title, title: data.title,
excerpt, excerpt,
content, content: data.content,
coverImage, coverImage,
tags, tags,
focusKeyword: null,
metaDescription, metaDescription,
status, status,
publishedAt: status === "published" ? now : null, publishedAt: status === "published" ? now : null,
@@ -83,9 +73,9 @@ if (Astro.request.method === "POST") {
.update(posts) .update(posts)
.set({ .set({
slug, slug,
title, title: data.title,
excerpt, excerpt,
content, content: data.content,
coverImage, coverImage,
tags, tags,
metaDescription, metaDescription,
@@ -122,12 +112,15 @@ const post = isNew
<form method="post"> <form method="post">
<label for="title">標題</label> <label for="title">標題</label>
{errors.title && <span class="field-error">{errors.title}</span>}
<input id="title" name="title" type="text" value={post.title} required placeholder="文章標題" /> <input id="title" name="title" type="text" value={post.title} required placeholder="文章標題" />
<label for="slug">網址 slug</label> <label for="slug">網址 slug</label>
{errors.slug && <span class="field-error">{errors.slug}</span>}
<input id="slug" name="slug" type="text" value={post.slug} placeholder="留空會由標題自動產生" /> <input id="slug" name="slug" type="text" value={post.slug} placeholder="留空會由標題自動產生" />
<label for="excerpt">摘要(列表頁顯示)</label> <label for="excerpt">摘要(列表頁顯示)</label>
{errors.excerpt && <span class="field-error">{errors.excerpt}</span>}
<input <input
id="excerpt" id="excerpt"
name="excerpt" name="excerpt"
@@ -137,6 +130,7 @@ const post = isNew
/> />
<label for="coverImage">封面圖片 URL(列表卡片用)</label> <label for="coverImage">封面圖片 URL(列表卡片用)</label>
{errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
<input <input
id="coverImage" id="coverImage"
name="coverImage" name="coverImage"
@@ -146,9 +140,11 @@ const post = isNew
/> />
<label for="tags">標籤(逗號分隔,可選)</label> <label for="tags">標籤(逗號分隔,可選)</label>
{errors.tags && <span class="field-error">{errors.tags}</span>}
<input id="tags" name="tags" type="text" value={post.tags ?? ""} placeholder="村屋,太陽能,指南" /> <input id="tags" name="tags" type="text" value={post.tags ?? ""} placeholder="村屋,太陽能,指南" />
<label for="metaDescription">SEO description</label> <label for="metaDescription">SEO description</label>
{errors.metaDescription && <span class="field-error">{errors.metaDescription}</span>}
<input <input
id="metaDescription" id="metaDescription"
name="metaDescription" name="metaDescription"
@@ -158,6 +154,7 @@ const post = isNew
/> />
<label for="content">內容(支援 Markdown</label> <label for="content">內容(支援 Markdown</label>
{errors.content && <span class="field-error">{errors.content}</span>}
<textarea id="content" name="content" placeholder={"# 標題\n\n寫啲嘢…"}>{post.content}</textarea> <textarea id="content" name="content" placeholder={"# 標題\n\n寫啲嘢…"}>{post.content}</textarea>
<label for="status">狀態</label> <label for="status">狀態</label>
+16 -1
View File
@@ -5,17 +5,29 @@ import { siteSettings } from "../../db/schema";
import { getSettings } from "../../data/content"; import { getSettings } from "../../data/content";
import { getDb } from "../../lib/db"; import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env"; import { getEnv } from "../../lib/env";
import { parseForm } from "../../lib/form";
import { settingsInput } from "../../schemas";
export const prerender = false; export const prerender = false;
const db = getDb(getEnv().DB); const db = getDb(getEnv().DB);
let saved = false; let saved = false;
let error = "";
if (Astro.request.method === "POST") { if (Astro.request.method === "POST") {
const form = await Astro.request.formData(); const form = await Astro.request.formData();
const parsed = parseForm(form, settingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const now = new Date(); const now = new Date();
const submitted = new Set(
[...form.keys()].filter((k) => ALL_SETTING_KEYS.includes(k)),
);
for (const key of ALL_SETTING_KEYS) { for (const key of ALL_SETTING_KEYS) {
const value = String(form.get(key) ?? "").trim(); if (!submitted.has(key)) continue;
const value = String((parsed.data as Record<string, unknown>)[key] ?? "");
await db await db
.insert(siteSettings) .insert(siteSettings)
.values({ key, value, updatedAt: now }) .values({ key, value, updatedAt: now })
@@ -23,6 +35,7 @@ if (Astro.request.method === "POST") {
} }
saved = true; saved = true;
} }
}
const settings = await getSettings(db); const settings = await getSettings(db);
--- ---
@@ -31,6 +44,8 @@ const settings = await getSettings(db);
<h1>網站設定</h1> <h1>網站設定</h1>
<p class="sub">公司資料、聯絡方式、首頁文案同 SEO。儲存後前台會自動更新。</p> <p class="sub">公司資料、聯絡方式、首頁文案同 SEO。儲存後前台會自動更新。</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="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">✓ 已儲存</p>}
<form method="post"> <form method="post">
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
export const aiSettingsInput = z.object({
ai_enabled: z.enum(["1", "0"]).default("1"),
ai_base_url: z.preprocess(
(v) => (v === "" ? undefined : v),
z
.url("Base URL 要係有效網址。")
.default("https://api.deepinfra.com/v1/openai")
.refine((u) => /^https?:\/\//i.test(u), "Base URL 只支援 http/https。"),
),
ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"),
ai_context_prompt: z.string().default(""),
ai_business_context: z.string().default(""),
ai_web_search_enabled: z.enum(["1", "0"]).default("0"),
ai_web_search_max_results: z.preprocess(
(v) => (v === "" ? undefined : v),
z.coerce
.number()
.int()
.min(1, "結果數要介乎 110。")
.max(10, "結果數要介乎 110。")
.default(5),
),
});
export type AiSettingsInput = z.infer<typeof aiSettingsInput>;
export const aiGeneratedPost = z.object({
title: z.string().trim().min(1),
content: z.string().trim().min(1),
metaDescription: z.string().trim().default(""),
excerpt: z.string().trim().default(""),
});
export type AiGeneratedPost = z.infer<typeof aiGeneratedPost>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
import { postStatus } from "./post";
export const caseInput = z.object({
title: z.string().trim().min(1, "標題唔可以留空。"),
location: z.string().trim().default(""),
completedAt: z.string().trim().default(""),
description: z.string().default(""),
imageUrl: z.string().trim().default(""),
status: postStatus.default("published"),
});
export type CaseInput = z.infer<typeof caseInput>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
import { CONTENT_KINDS } from "../db/schema";
import { postStatus } from "./post";
export const contentItemInput = z.object({
kind: z.enum(CONTENT_KINDS),
title: z.string().trim().min(1, "名稱唔可以留空。"),
description: z.string().default(""),
extra: z.string().trim().default(""),
status: postStatus.default("published"),
});
export type ContentItemInput = z.infer<typeof contentItemInput>;
+6
View File
@@ -0,0 +1,6 @@
export * from "./post";
export * from "./content";
export * from "./case";
export * from "./settings";
export * from "./ai";
export * from "./keyword";
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
import { KEYWORD_STATUSES } from "../db/schema";
export const keywordStatus = z.enum(KEYWORD_STATUSES);
export const keywordInput = z.object({
keyword: z.string().trim().min(1, "關鍵字唔可以留空。"),
});
export type KeywordInput = z.infer<typeof keywordInput>;
export type KeywordStatus = z.infer<typeof keywordStatus>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
export const postStatus = z.enum(["draft", "published"]);
export const postInput = z.object({
title: z.string().trim().min(1, "標題唔可以留空。"),
slug: z.string().trim().default(""),
excerpt: z.string().trim().default(""),
content: z.string().default(""),
coverImage: z.string().trim().default(""),
tags: z.string().trim().default(""),
metaDescription: z.string().trim().default(""),
status: postStatus.default("draft"),
});
export type PostInput = z.infer<typeof postInput>;
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { ALL_SETTING_KEYS } from "../data/settings-fields";
export const settingsInput = z.object(
Object.fromEntries(
ALL_SETTING_KEYS.map((key) => [key, z.string().trim().default("")]),
),
);
export type SettingsInput = z.infer<typeof settingsInput>;