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:
Vendored
-159
@@ -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
@@ -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,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1789106959098
|
"lastUpdateCheck": 1789116553811
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+1
-2
@@ -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" />
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
ADMIN_PASSWORD=改成你自己嘅密碼
|
ADMIN_PASSWORD=改成你自己嘅密碼
|
||||||
|
AI_API_KEY=
|
||||||
|
TAVILY_API_KEY=
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ dist/
|
|||||||
.dev.vars
|
.dev.vars
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -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 schema(migration 的唯一來源) |
|
| `src/db/AGENTS.md` | Drizzle schema(migration 的唯一來源) |
|
||||||
| `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/`。
|
||||||
|
|||||||
@@ -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 D1(SQLite) |
|
| DB | Cloudflare D1(SQLite) |
|
||||||
| ORM | Drizzle |
|
| ORM | Drizzle |
|
||||||
@@ -32,45 +33,66 @@
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 建本機 D1 + 套 migration(migration 會連 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)。
|
||||||
|
|
||||||
想模擬 production(wrangler):`npx wrangler dev`。
|
想模擬 production(wrangler):`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 schema(posts / site_settings / content_items / cases)
|
├─ db/schema.ts Drizzle schema(posts / 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 Builds(Git 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 contentItemInput(kind 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 keyword,mark 佢 "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 completions(OpenAI 相容 /chat/completions)
|
||||||
|
6. 解析輸出(TITLE / CONTENT / META_DESC),用 Zod 驗證格式;失敗回錯誤
|
||||||
|
7. slugify + uniqueSlug;autoExcerpt 補 excerpt
|
||||||
|
8. 插入 posts(status = "draft"、focusKeyword = topic)→ 回 post id
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
redirect 去 /admin/post/<id> 覆核
|
||||||
|
```
|
||||||
|
|
||||||
|
**執行方式:同步 await。** POST handler 直接等生成完成(約 10–40 秒)再 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 字、GEO(summary-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 + seed(remote)。
|
||||||
|
7. 可選:`npm run build` + `npx wrangler deploy`。
|
||||||
|
|
||||||
|
- 每一步 idempotent(重跑唔會整爛已設定嘅嘢)。
|
||||||
|
- 唔會自動改 `astro.config.mjs` 嘅 `site`(提示用戶手動改)。
|
||||||
|
|
||||||
|
### 4.2 Cloudflare Workers Builds(Git 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` 可完成 provision;README 更新 |
|
||||||
|
|
||||||
|
三階段相對獨立,逐階段完成並 `npm run build`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 風險與對策
|
||||||
|
|
||||||
|
| 風險 | 對策 |
|
||||||
|
|---|---|
|
||||||
|
| Zod 加入 admin POST 改動面大 | 逐個 admin POST handler 遷移;`parseForm` 提供一致 fallback,build 驗證 |
|
||||||
|
| LLM 輸出格式唔穩定 | 用容錯分隔格式 + Zod 驗證,失敗回明確錯誤、唔插入壞資料 |
|
||||||
|
| 同步生成請求時間長 | Workers I/O 等待唔計 CPU;若超時先改 `cfContext.waitUntil` + 狀態欄 |
|
||||||
|
| `wrangler.jsonc` 有註解,程式改寫易壞 | 只做精準字串替換,唔 parse 整個檔 |
|
||||||
|
| Workers Builds 跑 migration 風險 | 明確唔放 CI,migration 手動執行 |
|
||||||
|
| 外部 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`(若用上網研究)。
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 點你一定要知。
|
||||||
|
|||||||
Generated
+5
-4
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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")同 TOML(key = "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
@@ -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 schema(migration 的唯一來源) |
|
| `db/AGENTS.md` | Drizzle schema(migration 的唯一來源) |
|
||||||
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
|
| `lib/AGENTS.md` | auth / env / db / form / ai / markdown 基礎工具 |
|
||||||
|
|||||||
+3
-1
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 結果數(1–10)</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>
|
||||||
+38
-24
@@ -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 {
|
||||||
|
const id = str(form, "id");
|
||||||
|
await db
|
||||||
|
.update(cases)
|
||||||
|
.set({
|
||||||
|
title: parsed.data.title,
|
||||||
|
location: parsed.data.location || null,
|
||||||
|
completedAt: parsed.data.completedAt || null,
|
||||||
|
description: parsed.data.description || null,
|
||||||
|
imageUrl: parsed.data.imageUrl || null,
|
||||||
|
status: parsed.data.status,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(cases.id, id));
|
||||||
}
|
}
|
||||||
} else if (action === "save") {
|
|
||||||
const id = str(form, "id");
|
|
||||||
await db
|
|
||||||
.update(cases)
|
|
||||||
.set({
|
|
||||||
title: str(form, "title"),
|
|
||||||
location: str(form, "location") || null,
|
|
||||||
completedAt: str(form, "completedAt") || null,
|
|
||||||
description: str(form, "description") || null,
|
|
||||||
imageUrl: str(form, "imageUrl") || null,
|
|
||||||
status: str(form, "status") === "draft" ? "draft" : "published",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.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,7 +78,9 @@ if (Astro.request.method === "POST") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Astro.redirect("/admin/cases");
|
if (Object.keys(errors).length === 0) {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
const id = str(form, "id");
|
||||||
|
await db
|
||||||
|
.update(contentItems)
|
||||||
|
.set({
|
||||||
|
title: parsed.data.title,
|
||||||
|
description: parsed.data.description,
|
||||||
|
extra: parsed.data.extra || null,
|
||||||
|
status: parsed.data.status,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(contentItems.id, id), eq(contentItems.kind, kind)));
|
||||||
}
|
}
|
||||||
} else if (action === "save") {
|
|
||||||
const id = str(form, "id");
|
|
||||||
await db
|
|
||||||
.update(contentItems)
|
|
||||||
.set({
|
|
||||||
title: str(form, "title"),
|
|
||||||
description: str(form, "description"),
|
|
||||||
extra: str(form, "extra") || null,
|
|
||||||
status: str(form, "status") === "draft" ? "draft" : "published",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.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,7 +127,9 @@ if (Astro.request.method === "POST") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Astro.redirect(`/admin/content/${kind}`);
|
if (Object.keys(errors).length === 0) {
|
||||||
|
return Astro.redirect(`/admin/content/${kind}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await db
|
const items = await db
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,23 +5,36 @@ 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 now = new Date();
|
const parsed = parseForm(form, settingsInput);
|
||||||
for (const key of ALL_SETTING_KEYS) {
|
if (!parsed.ok) {
|
||||||
const value = String(form.get(key) ?? "").trim();
|
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
|
||||||
await db
|
} else {
|
||||||
.insert(siteSettings)
|
const now = new Date();
|
||||||
.values({ key, value, updatedAt: now })
|
const submitted = new Set(
|
||||||
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
|
[...form.keys()].filter((k) => ALL_SETTING_KEYS.includes(k)),
|
||||||
|
);
|
||||||
|
for (const key of ALL_SETTING_KEYS) {
|
||||||
|
if (!submitted.has(key)) continue;
|
||||||
|
const value = String((parsed.data as Record<string, unknown>)[key] ?? "");
|
||||||
|
await db
|
||||||
|
.insert(siteSettings)
|
||||||
|
.values({ key, value, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
|||||||
@@ -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, "結果數要介乎 1–10。")
|
||||||
|
.max(10, "結果數要介乎 1–10。")
|
||||||
|
.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>;
|
||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./post";
|
||||||
|
export * from "./content";
|
||||||
|
export * from "./case";
|
||||||
|
export * from "./settings";
|
||||||
|
export * from "./ai";
|
||||||
|
export * from "./keyword";
|
||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user