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.
1557 lines
48 KiB
Markdown
1557 lines
48 KiB
Markdown
# 結構優化 + AI Blog + 部署 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 為盈豐太陽能官網加入 Zod schema-first 驗證層、AI 生成 Blog(DeepSeek + Tavily + 關鍵字佇列),同簡化 Cloudflare 部署。
|
||
|
||
**Architecture:** 沿用 Astro 7 SSR + D1 + Drizzle 單 Worker 架構。Phase 1 加 `src/schemas/` + `parseForm` helper,將 admin 表單驗證集中。Phase 2 加 `blog_keywords` 表 + `src/lib/ai.ts`(OpenAI 相容 chat completions + Tavily),新 `/admin/ai` 頁同步生成草稿。Phase 3 加 `scripts/setup.mjs` 一鍵 provision,並用 Cloudflare Workers Builds 做 Git push 自動 deploy。
|
||
|
||
**Tech Stack:** Astro 7 / React 19 / Chakra UI v3(前台)/ Drizzle ORM / Cloudflare D1 + KV / Zod v4 / marked / DeepSeek(OpenAI 相容)/ Tavily。
|
||
|
||
**驗證方式:** 本 repo **冇** test / lint / typecheck script,`npm run build` 係唯一 build/type 檢查(見 `AGENTS.md`)。以下每個 Task 以 `npm run build` 收尾,需要時加 `npm run dev` 目測。**本 repo 預設唔自動 commit**;如要 commit,喺每個 Task 最後自行執行(訊息格式見範例)。
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-11-structure-ai-deploy-design.md`
|
||
|
||
---
|
||
|
||
## 檔案結構
|
||
|
||
**新增:**
|
||
```
|
||
src/schemas/post.ts postInput
|
||
src/schemas/content.ts contentItemInput
|
||
src/schemas/case.ts caseInput
|
||
src/schemas/settings.ts settingsInput(由 SETTINGS_GROUPS 動態砌)
|
||
src/schemas/ai.ts aiSettingsInput + aiGeneratedPost
|
||
src/schemas/keyword.ts keywordInput + keywordStatus
|
||
src/schemas/index.ts barrel
|
||
src/lib/form.ts parseForm(formData, schema)
|
||
src/lib/ai.ts AI 生成流程(Tavily + DeepSeek + 解析)
|
||
src/pages/admin/ai.astro AI 設定 + 關鍵字佇列 + 生成
|
||
scripts/setup.mjs 一鍵 Cloudflare provision
|
||
```
|
||
|
||
**修改:**
|
||
```
|
||
package.json + zod、+ "setup" script
|
||
src/db/schema.ts posts.focusKeyword + blog_keywords 表
|
||
src/lib/env.ts + AI_API_KEY / TAVILY_API_KEY
|
||
src/data/content.ts + getKeywords / uniqueSlug
|
||
src/layouts/AdminLayout.astro + "AI" 導覽、+ .field-error CSS
|
||
src/pages/admin/post/[id].astro 用 parseForm + uniqueSlug
|
||
src/pages/admin/content/[kind].astro 用 parseForm
|
||
src/pages/admin/cases.astro 用 parseForm
|
||
src/pages/admin/settings.astro 用 parseForm
|
||
src/pages/admin/index.astro 加入口連結
|
||
migrations/seed.sql 加 AI 設定 + 示範關鍵字
|
||
.dev.vars.example 加 AI_API_KEY / TAVILY_API_KEY
|
||
README.md / AGENTS.md 各層 同步文件
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 1 — Zod schema-first 驗證層
|
||
|
||
## Task 1: 加 zod 同建立 schemas
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
- Create: `src/schemas/post.ts`、`content.ts`、`case.ts`、`settings.ts`、`ai.ts`、`keyword.ts`、`index.ts`
|
||
- Create: `src/lib/form.ts`
|
||
|
||
- [x] **Step 1: 加 zod 依賴**
|
||
|
||
```bash
|
||
npm install zod@^4.6.1
|
||
```
|
||
|
||
Expected: `package.json` dependencies 加入 `"zod": "^4.6.1"`(現時 zod 只係 astro 嘅 transitive dep)。
|
||
|
||
- [x] **Step 2: 建 `src/schemas/post.ts`**
|
||
|
||
```ts
|
||
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>;
|
||
```
|
||
|
||
- [x] **Step 3: 建 `src/schemas/content.ts`**
|
||
|
||
```ts
|
||
import { z } from "zod";
|
||
import { CONTENT_KINDS } from "../db/schema";
|
||
|
||
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: z.enum(["draft", "published"]).default("published"),
|
||
});
|
||
|
||
export type ContentItemInput = z.infer<typeof contentItemInput>;
|
||
```
|
||
|
||
- [x] **Step 4: 建 `src/schemas/case.ts`**
|
||
|
||
```ts
|
||
import { z } from "zod";
|
||
|
||
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: z.enum(["draft", "published"]).default("published"),
|
||
});
|
||
|
||
export type CaseInput = z.infer<typeof caseInput>;
|
||
```
|
||
|
||
- [x] **Step 5: 建 `src/schemas/settings.ts`**
|
||
|
||
```ts
|
||
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>;
|
||
```
|
||
|
||
- [x] **Step 6: 建 `src/schemas/ai.ts`**
|
||
|
||
```ts
|
||
import { z } from "zod";
|
||
|
||
export const aiSettingsInput = z.object({
|
||
ai_enabled: z.enum(["1", "0"]).default("1"),
|
||
ai_base_url: z.url("Base URL 要係有效網址。").default("https://api.deepinfra.com/v1/openai"),
|
||
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.coerce.number().int().min(1).max(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>;
|
||
```
|
||
|
||
- [x] **Step 7: 建 `src/schemas/keyword.ts`**
|
||
|
||
```ts
|
||
import { z } from "zod";
|
||
|
||
export const keywordStatus = z.enum(["pending", "generated", "skipped"]);
|
||
|
||
export const keywordInput = z.object({
|
||
keyword: z.string().trim().min(1, "關鍵字唔可以留空。"),
|
||
});
|
||
|
||
export type KeywordInput = z.infer<typeof keywordInput>;
|
||
```
|
||
|
||
- [x] **Step 8: 建 `src/schemas/index.ts`**
|
||
|
||
```ts
|
||
export * from "./post";
|
||
export * from "./content";
|
||
export * from "./case";
|
||
export * from "./settings";
|
||
export * from "./ai";
|
||
export * from "./keyword";
|
||
```
|
||
|
||
- [x] **Step 9: 建 `src/lib/form.ts`**
|
||
|
||
```ts
|
||
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 };
|
||
}
|
||
```
|
||
|
||
- [x] **Step 10: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: build 成功(schemas 未有 callers,理應無 type error)。
|
||
|
||
---
|
||
|
||
## Task 2: 抽出 `uniqueSlug` + 遷移文章編輯頁
|
||
|
||
**Files:**
|
||
- Modify: `src/data/content.ts`(加 `uniqueSlug`)
|
||
- Modify: `src/pages/admin/post/[id].astro`
|
||
|
||
- [x] **Step 1: 喺 `src/data/content.ts` 尾加 `uniqueSlug`**
|
||
|
||
改 import 行(第 1 行)加入 `ne`:
|
||
|
||
```ts
|
||
import { and, asc, desc, eq, ne } from "drizzle-orm";
|
||
```
|
||
|
||
喺檔案最後加:
|
||
|
||
```ts
|
||
/** 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()}`;
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: 改 `src/pages/admin/post/[id].astro` 的 imports**
|
||
|
||
將第 2–7 行改成:
|
||
|
||
```ts
|
||
import { eq } from "drizzle-orm";
|
||
import AdminLayout from "../../../layouts/AdminLayout.astro";
|
||
import { getDb } from "../../../lib/db";
|
||
import { posts } from "../../../db/schema";
|
||
import { slugify, autoExcerpt } from "../../../lib/markdown";
|
||
import { getEnv } from "../../../lib/env";
|
||
import { uniqueSlug } from "../../../data/content";
|
||
import { parseForm } from "../../../lib/form";
|
||
import { postInput } from "../../../schemas";
|
||
```
|
||
|
||
(刪走原本嘅 `and, ne` 同本頁 local `uniqueSlug` 函式。)
|
||
|
||
- [x] **Step 3: 刪走本頁 local `uniqueSlug`**
|
||
|
||
移除原本第 25–42 行(`/** slug 撞咗就加 -2、-3… */ async function uniqueSlug(...)`)整個函式,改用 Task 2 Step 1 嘅共用版本(呼叫方式不變:`uniqueSlug(db, base, selfId)`)。
|
||
|
||
- [x] **Step 4: 改 error 變數同 POST 驗證邏輯**
|
||
|
||
將 `let error = "";` 改成:
|
||
|
||
```ts
|
||
let error = "";
|
||
let errors: Record<string, string> = {};
|
||
```
|
||
|
||
將 POST 內 `if (action === "delete" ...)` 之後嘅整段驗證(原本第 53–100 行,由 `const title = ...` 到 redirect 前)換成:
|
||
|
||
```ts
|
||
const parsed = parseForm(form, postInput);
|
||
if (!parsed.ok) {
|
||
errors = parsed.errors;
|
||
error = Object.values(parsed.errors)[0] ?? "輸入有誤。";
|
||
} else {
|
||
const data = parsed.data;
|
||
const status = data.status;
|
||
const slug = await uniqueSlug(
|
||
db,
|
||
data.slug ? slugify(data.slug) : slugify(data.title),
|
||
isNew ? undefined : id,
|
||
);
|
||
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();
|
||
|
||
if (isNew) {
|
||
await db.insert(posts).values({
|
||
id: crypto.randomUUID(),
|
||
slug,
|
||
title: data.title,
|
||
excerpt,
|
||
content: data.content,
|
||
coverImage,
|
||
tags,
|
||
metaDescription,
|
||
focusKeyword: null,
|
||
status,
|
||
publishedAt: status === "published" ? now : null,
|
||
updatedAt: now,
|
||
});
|
||
} else {
|
||
await db
|
||
.update(posts)
|
||
.set({
|
||
slug,
|
||
title: data.title,
|
||
excerpt,
|
||
content: data.content,
|
||
coverImage,
|
||
tags,
|
||
metaDescription,
|
||
status,
|
||
publishedAt: existing!.publishedAt ?? (status === "published" ? now : null),
|
||
updatedAt: now,
|
||
})
|
||
.where(eq(posts.id, id!));
|
||
}
|
||
|
||
return Astro.redirect("/admin");
|
||
}
|
||
```
|
||
|
||
> 注意:`focusKeyword: null` 呢個欄位喺 Phase 2 Task 7 加;Phase 1 執行時先**唔好加**呢行,等 Phase 2 加咗 schema 先補返。Phase 1 版本省略 `focusKeyword`。
|
||
|
||
- [x] **Step 5: 表單加 per-field 錯誤顯示**
|
||
|
||
將標題、slug、excerpt、coverImage、tags、metaDescription、content 每個 `<label>` 之後加(以標題為例):
|
||
|
||
```astro
|
||
<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="文章標題" />
|
||
```
|
||
|
||
其餘欄位用對應 key(`errors.slug`、`errors.excerpt`、`errors.coverImage`、`errors.tags`、`errors.metaDescription`、`errors.content`)。
|
||
|
||
- [x] **Step 6: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。再 `npm run db:migrate:local && npm run db:seed:local && npm run dev`,開 `/admin/post/post-001` 改標題儲存、新增一篇、測標題留空睇 error。
|
||
|
||
---
|
||
|
||
## Task 3: 遷移內容通用編輯器
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/admin/content/[kind].astro`
|
||
|
||
- [x] **Step 1: 加 imports**
|
||
|
||
喺第 5 行後加:
|
||
|
||
```ts
|
||
import { parseForm } from "../../../lib/form";
|
||
import { contentItemInput } from "../../../schemas";
|
||
```
|
||
|
||
- [x] **Step 2: 加 errors 變數**
|
||
|
||
喺 `const meta = META[kind];` 之後加:
|
||
|
||
```ts
|
||
let errors: Record<string, string> = {};
|
||
```
|
||
|
||
- [x] **Step 3: 改 add / save 分支**
|
||
|
||
將 `if (action === "add") { ... } else if (action === "save") { ... }` 兩段換成:
|
||
|
||
```ts
|
||
if (action === "add" || action === "save") {
|
||
form.set("kind", kind);
|
||
const parsed = parseForm(form, contentItemInput);
|
||
if (!parsed.ok) {
|
||
errors = parsed.errors;
|
||
} else if (action === "add") {
|
||
const [row] = await db
|
||
.select({ m: max(contentItems.sortOrder) })
|
||
.from(contentItems)
|
||
.where(eq(contentItems.kind, kind));
|
||
await db.insert(contentItems).values({
|
||
id: crypto.randomUUID(),
|
||
kind,
|
||
title: parsed.data.title,
|
||
description: parsed.data.description,
|
||
extra: parsed.data.extra || null,
|
||
sortOrder: (row?.m ?? 0) + 1,
|
||
status: parsed.data.status,
|
||
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 === "delete") {
|
||
```
|
||
|
||
(`delete` / `up` / `down` 分支維持原狀。)
|
||
|
||
- [x] **Step 4: 新增與編輯表單加錯誤顯示**
|
||
|
||
喺新增表單 `{meta.titleLabel}` label 後加 `{errors.title && <span class="field-error">{errors.title}</span>}`;編輯表單同樣喺標題 label 後加。
|
||
|
||
- [x] **Step 5: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。`npm run dev` → `/admin/content/service` 新增、編輯、排序、刪除。
|
||
|
||
---
|
||
|
||
## Task 4: 遷移完成案例頁
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/admin/cases.astro`
|
||
|
||
- [x] **Step 1: 加 imports**
|
||
|
||
喺第 6 行後加:
|
||
|
||
```ts
|
||
import { parseForm } from "../../lib/form";
|
||
import { caseInput } from "../../schemas";
|
||
```
|
||
|
||
- [x] **Step 2: 加 errors 變數**
|
||
|
||
喺 `const db = getDb(getEnv().DB);` 之後加:
|
||
|
||
```ts
|
||
let errors: Record<string, string> = {};
|
||
```
|
||
|
||
- [x] **Step 3: 改 add / save 分支**
|
||
|
||
將 `if (action === "add") { ... } else if (action === "save") { ... }` 換成:
|
||
|
||
```ts
|
||
if (action === "add" || action === "save") {
|
||
const parsed = parseForm(form, caseInput);
|
||
if (!parsed.ok) {
|
||
errors = parsed.errors;
|
||
} else if (action === "add") {
|
||
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
|
||
await db.insert(cases).values({
|
||
id: crypto.randomUUID(),
|
||
title: parsed.data.title,
|
||
location: parsed.data.location || null,
|
||
completedAt: parsed.data.completedAt || null,
|
||
description: parsed.data.description || null,
|
||
imageUrl: parsed.data.imageUrl || null,
|
||
sortOrder: (row?.m ?? 0) + 1,
|
||
status: parsed.data.status,
|
||
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 === "delete") {
|
||
```
|
||
|
||
(`delete` / `up` / `down` 維持。)
|
||
|
||
- [x] **Step 4: 新增與編輯表單加錯誤顯示**
|
||
|
||
喺新增表單標題 label 後加 `{errors.title && <span class="field-error">{errors.title}</span>}`;編輯表單同樣。
|
||
|
||
- [x] **Step 5: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。`npm run dev` → `/admin/cases` 新增、編輯、排序、刪除。
|
||
|
||
---
|
||
|
||
## Task 5: 遷移網站設定頁
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/admin/settings.astro`
|
||
|
||
- [x] **Step 1: 加 imports**
|
||
|
||
喺第 7 行後加:
|
||
|
||
```ts
|
||
import { parseForm } from "../../lib/form";
|
||
import { settingsInput } from "../../schemas";
|
||
```
|
||
|
||
- [x] **Step 2: 改 POST 邏輯**
|
||
|
||
將第 13–25 行(`let saved = false;` 至 POST 區塊完)換成:
|
||
|
||
```ts
|
||
let saved = false;
|
||
let error = "";
|
||
|
||
if (Astro.request.method === "POST") {
|
||
const form = await Astro.request.formData();
|
||
const parsed = parseForm(form, settingsInput);
|
||
if (!parsed.ok) {
|
||
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
|
||
} else {
|
||
const now = new Date();
|
||
for (const key of ALL_SETTING_KEYS) {
|
||
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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [x] **Step 3: 顯示 error**
|
||
|
||
喺 `{saved && ...}` 之前加:
|
||
|
||
```astro
|
||
{error && <p class="field-error" style="margin-bottom:16px">{error}</p>}
|
||
```
|
||
|
||
- [x] **Step 4: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。`npm run dev` → `/admin/settings` 儲存後前台見到更新。
|
||
|
||
---
|
||
|
||
## Task 6: AdminLayout 加 `.field-error` 樣式
|
||
|
||
**Files:**
|
||
- Modify: `src/layouts/AdminLayout.astro`
|
||
|
||
- [x] **Step 1: 加 CSS**
|
||
|
||
喺 `.muted { ... }` 之後(約第 109 行)加:
|
||
|
||
```css
|
||
.field-error { color: #a32d2d; font-size: 12.5px; font-weight: 600; margin-left: 8px; }
|
||
```
|
||
|
||
- [x] **Step 2: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。`npm run dev` → 提交一個空標題,見到紅色錯誤字。
|
||
|
||
---
|
||
|
||
# Phase 2 — AI Blog + 關鍵字佇列
|
||
|
||
## Task 7: DB schema(`posts.focusKeyword` + `blog_keywords`)
|
||
|
||
**Files:**
|
||
- Modify: `src/db/schema.ts`
|
||
- Modify: `migrations/seed.sql`
|
||
- Generate: `migrations/0002_*.sql`(由 drizzle-kit)
|
||
|
||
- [x] **Step 1: `posts` 加 `focusKeyword`**
|
||
|
||
喺 `posts` 定義 `tags` 之後加:
|
||
|
||
```ts
|
||
focusKeyword: text("focus_keyword"),
|
||
```
|
||
|
||
- [x] **Step 2: 加 `blog_keywords` 表**
|
||
|
||
喺 `cases` 定義之後、`export type Post ...` 之前加:
|
||
|
||
```ts
|
||
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)],
|
||
);
|
||
```
|
||
|
||
- [x] **Step 3: 加型別匯出**
|
||
|
||
喺檔案最後加:
|
||
|
||
```ts
|
||
export type BlogKeyword = typeof blogKeywords.$inferSelect;
|
||
export type NewBlogKeyword = typeof blogKeywords.$inferInsert;
|
||
```
|
||
|
||
- [x] **Step 4: 產生 migration**
|
||
|
||
```bash
|
||
npm run db:generate
|
||
```
|
||
|
||
Expected: `migrations/` 出現 `0002_*.sql`,內含 `ALTER TABLE posts ADD COLUMN focus_keyword` 同 `CREATE TABLE blog_keywords`。
|
||
|
||
- [x] **Step 5: 套用到本機**
|
||
|
||
```bash
|
||
npm run db:migrate:local
|
||
```
|
||
|
||
Expected: 成功,無 error。
|
||
|
||
- [x] **Step 6: `seed.sql` 加 AI 設定**
|
||
|
||
喺第一個 `INSERT OR REPLACE INTO site_settings ...;`(第 22 行 `og_image`)之後、`content_items` 之前,加:
|
||
|
||
```sql
|
||
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);
|
||
```
|
||
|
||
- [x] **Step 7: `seed.sql` 加示範關鍵字**
|
||
|
||
喺 `cases` 嘅 insert(第 55 行)之後加:
|
||
|
||
```sql
|
||
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);
|
||
```
|
||
|
||
- [x] **Step 8: 補返 post 頁嘅 `focusKeyword`**
|
||
|
||
喺 Task 2 Step 4 嘅 `insert(posts).values({...})` 入面,`tags,` 之後加返:
|
||
|
||
```ts
|
||
focusKeyword: null,
|
||
```
|
||
|
||
- [x] **Step 9: 匯入種子並驗證**
|
||
|
||
```bash
|
||
npm run db:seed:local && npm run build
|
||
```
|
||
|
||
Expected: seed 成功;build PASS。
|
||
|
||
---
|
||
|
||
## Task 8: env 加 AI secrets
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/env.ts`
|
||
- Modify: `.dev.vars.example`
|
||
|
||
- [x] **Step 1: 改 `AppEnv`**
|
||
|
||
```ts
|
||
export type AppEnv = {
|
||
DB: D1Database;
|
||
CACHE: KVNamespace;
|
||
ADMIN_PASSWORD?: string;
|
||
AI_API_KEY?: string;
|
||
TAVILY_API_KEY?: string;
|
||
};
|
||
```
|
||
|
||
- [x] **Step 2: 更新 `.dev.vars.example`**
|
||
|
||
```
|
||
ADMIN_PASSWORD=改成你自己嘅密碼
|
||
AI_API_KEY=
|
||
TAVILY_API_KEY=
|
||
```
|
||
|
||
- [x] **Step 3: 本機 `.dev.vars` 補 key**
|
||
|
||
喺本機 `.dev.vars`(已 gitignore)加 `AI_API_KEY=...`(測試生成先需要)。
|
||
|
||
- [x] **Step 4: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。
|
||
|
||
---
|
||
|
||
## Task 9: `src/lib/ai.ts`
|
||
|
||
**Files:**
|
||
- Create: `src/lib/ai.ts`
|
||
|
||
- [x] **Step 1: 寫檔案**
|
||
|
||
```ts
|
||
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: 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" },
|
||
body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
|
||
});
|
||
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: <150 字以內 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,
|
||
}),
|
||
});
|
||
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 titleMatch = text.match(/TITLE:\s*(.+)/i);
|
||
const contentMatch = text.match(/CONTENT:\s*([\s\S]*?)(?:\nMETA_DESC:|$)/i);
|
||
const metaMatch = text.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) {
|
||
return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
|
||
}
|
||
|
||
const now = new Date();
|
||
const slug = await uniqueSlug(db, slugify(generated.title));
|
||
const id = crypto.randomUUID();
|
||
const excerpt = generated.excerpt || autoExcerpt(generated.content);
|
||
|
||
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));
|
||
}
|
||
|
||
return { ok: true, postId: id };
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS(`ai.ts` 未接 UI,但要通過 type check)。
|
||
|
||
---
|
||
|
||
## Task 10: data 層加關鍵字查詢
|
||
|
||
**Files:**
|
||
- Modify: `src/data/content.ts`
|
||
|
||
- [x] **Step 1: 加 `getKeywords`**
|
||
|
||
改 schema import(第 3–12 行)加入 `blogKeywords` 同 `type BlogKeyword`:
|
||
|
||
```ts
|
||
import {
|
||
blogKeywords,
|
||
cases,
|
||
contentItems,
|
||
posts,
|
||
siteSettings,
|
||
type BlogKeyword,
|
||
type CaseStudy,
|
||
type ContentItem,
|
||
type ContentKind,
|
||
type Post,
|
||
} from "../db/schema";
|
||
```
|
||
|
||
喺 `getPostBySlug` 之後加:
|
||
|
||
```ts
|
||
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
|
||
return db.select().from(blogKeywords).orderBy(asc(blogKeywords.createdAt));
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。
|
||
|
||
---
|
||
|
||
## Task 11: 新 `/admin/ai` 頁
|
||
|
||
**Files:**
|
||
- Create: `src/pages/admin/ai.astro`
|
||
|
||
- [x] **Step 1: 寫檔案**
|
||
|
||
```astro
|
||
---
|
||
import { asc, 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 = "";
|
||
let notice = "";
|
||
|
||
if (Astro.request.method === "POST") {
|
||
const form = await Astro.request.formData();
|
||
const action = String(form.get("action") ?? "");
|
||
|
||
if (action === "save-settings") {
|
||
form.set("ai_enabled", form.get("ai_enabled") ? "1" : "0");
|
||
form.set("ai_web_search_enabled", form.get("ai_web_search_enabled") ? "1" : "0");
|
||
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 } });
|
||
}
|
||
notice = "已儲存 AI 設定。";
|
||
}
|
||
} 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(),
|
||
});
|
||
notice = "已加入關鍵字。";
|
||
}
|
||
} else if (action === "delete-keyword") {
|
||
await db.delete(blogKeywords).where(eq(blogKeywords.id, String(form.get("id") ?? "")));
|
||
notice = "已刪除。";
|
||
} else if (action === "skip-keyword") {
|
||
await db
|
||
.update(blogKeywords)
|
||
.set({ status: "skipped" })
|
||
.where(eq(blogKeywords.id, String(form.get("id") ?? "")));
|
||
notice = "已標記略過。";
|
||
} else if (action === "generate-topic" || action === "generate-next") {
|
||
const result = await generateBlogPost(
|
||
db,
|
||
env,
|
||
action === "generate-next"
|
||
? { mode: "next" }
|
||
: { mode: "topic", topic: String(form.get("topic") ?? "") },
|
||
);
|
||
if (result.ok) return Astro.redirect(`/admin/post/${result.postId}`);
|
||
error = result.error;
|
||
}
|
||
}
|
||
|
||
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="field-error" style="display:block;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">
|
||
<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"} />
|
||
</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>
|
||
```
|
||
|
||
- [x] **Step 2: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。
|
||
|
||
- [x] **Step 3: 手動測試生成**
|
||
|
||
本機 `.dev.vars` 設定 `AI_API_KEY` 後:
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
開 `/admin/ai` → 輸入主題 → 「生成草稿」→ 應 redirect 去新草稿編輯頁。若 key 未設定,應顯示明確錯誤訊息。
|
||
|
||
---
|
||
|
||
## Task 12: Admin 導覽入口
|
||
|
||
**Files:**
|
||
- Modify: `src/layouts/AdminLayout.astro`
|
||
- Modify: `src/pages/admin/index.astro`
|
||
|
||
- [x] **Step 1: 加導覽連結**
|
||
|
||
喺 `links` 陣列 `{ href: "/admin/settings", label: "設定" },` 之後加:
|
||
|
||
```ts
|
||
{ href: "/admin/ai", label: "AI 生成" },
|
||
```
|
||
|
||
- [x] **Step 2: 喺 `/admin` 內容總覽加入口**
|
||
|
||
喺 `首頁內容` 卡片嘅 actions 入面,`網站設定` 連結之後加:
|
||
|
||
```astro
|
||
<a class="btn secondary" href="/admin/ai">AI 生成</a>
|
||
```
|
||
|
||
- [x] **Step 3: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。`npm run dev` → 後台導覽見到「AI 生成」,撳到去 `/admin/ai`。
|
||
|
||
---
|
||
|
||
# Phase 3 — 部署便利
|
||
|
||
## Task 13: 一鍵本機 setup script
|
||
|
||
**Files:**
|
||
- Create: `scripts/setup.mjs`
|
||
- Modify: `package.json`
|
||
|
||
- [x] **Step 1: 建 `scripts/setup.mjs`**
|
||
|
||
```js
|
||
#!/usr/bin/env node
|
||
import { execSync } from "node:child_process";
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { createInterface } from "node:readline/promises";
|
||
import { stdin as input, stdout as output } from "node:process";
|
||
|
||
const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
|
||
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
|
||
const WRANGLER = "wrangler.jsonc";
|
||
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) };
|
||
} catch (err) {
|
||
return { ok: false, out: err.stdout ?? "" };
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
console.log("\n=== 盈豐太陽能 — Cloudflare 一鍵設定 ===\n");
|
||
|
||
console.log("檢查 wrangler 登入狀態…");
|
||
const who = runSoft("npx wrangler whoami");
|
||
if (!who.ok) {
|
||
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}`);
|
||
const out = res.out ?? "";
|
||
const match = out.match(/database_id\s*=\s*"([0-9a-f-]+)"/i);
|
||
if (!match) {
|
||
console.error("成功建立但解析唔到 database_id。請手動將 id 填入 wrangler.jsonc。");
|
||
console.error(out);
|
||
process.exit(1);
|
||
}
|
||
config = config.replace(D1_PLACEHOLDER, match[1]);
|
||
console.log(` database_id = ${match[1]}`);
|
||
} else {
|
||
console.log("D1 id 已設定,略過。");
|
||
}
|
||
|
||
if (config.includes(KV_PLACEHOLDER)) {
|
||
console.log("建立 KV namespace「CACHE」…");
|
||
const res = runSoft("npx wrangler kv namespace create CACHE");
|
||
const out = res.out ?? "";
|
||
const match = out.match(/id\s*=\s*"([0-9a-f]+)"/i);
|
||
if (!match) {
|
||
console.error("成功建立但解析唔到 KV id。請手動將 id 填入 wrangler.jsonc。");
|
||
console.error(out);
|
||
process.exit(1);
|
||
}
|
||
config = config.replace(KV_PLACEHOLDER, match[1]);
|
||
console.log(` kv id = ${match[1]}`);
|
||
} else {
|
||
console.log("KV id 已設定,略過。");
|
||
}
|
||
|
||
writeFileSync(WRANGLER, config, "utf8");
|
||
console.log("已更新 wrangler.jsonc。\n");
|
||
|
||
const password = await rl.question("設定後台密碼 ADMIN_PASSWORD(留空略過):");
|
||
if (password.trim()) {
|
||
execSync("npx wrangler secret put ADMIN_PASSWORD", { input: password + "\n", stdio: ["pipe", "inherit", "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=./migrations/seed.sql`, { stdio: "inherit" });
|
||
}
|
||
|
||
console.log("\n提示:上線前記得改 astro.config.mjs 嘅 site 做真域名。");
|
||
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);
|
||
});
|
||
```
|
||
|
||
- [x] **Step 2: 加 `package.json` script**
|
||
|
||
喺 `"types": "wrangler types"` 之後加:
|
||
|
||
```json
|
||
"setup": "node scripts/setup.mjs"
|
||
```
|
||
|
||
(記得前一項加逗號。)
|
||
|
||
- [x] **Step 3: 驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。`node --check scripts/setup.mjs` 無語法錯誤(`node --check` 只檢查語法)。
|
||
|
||
---
|
||
|
||
## Task 14: README + Workers Builds / AGENTS 文件同步
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
- Modify: `AGENTS.md`(root)
|
||
- Modify: `src/AGENTS.md`
|
||
- Modify: `src/db/AGENTS.md`
|
||
- Modify: `src/data/AGENTS.md`
|
||
- Modify: `src/lib/AGENTS.md`
|
||
- Modify: `migrations/AGENTS.md`
|
||
|
||
- [x] **Step 1: README 加 AI + setup + Workers Builds**
|
||
|
||
更新「部署到 Cloudflare」一節,改成:
|
||
|
||
````markdown
|
||
## 部署到 Cloudflare
|
||
|
||
### 首次設定(一鍵)
|
||
|
||
```bash
|
||
npm run login # 瀏覽器授權
|
||
npm run setup # 建立 D1 / KV、寫入 wrangler.jsonc、設 secret、套 migration + seed
|
||
```
|
||
|
||
`npm run setup` 會逐步問你:後台密碼、要唔要套 migration/seed、要唔要 build + deploy。
|
||
|
||
### 之後更新
|
||
|
||
改完 `astro.config.mjs` 嘅 `site` 做真域名後:
|
||
|
||
```bash
|
||
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
|
||
- 生成結果一律存為**草稿**,去文章編輯頁覆核後先發布
|
||
- 需要 secret:`AI_API_KEY`(必需)、`TAVILY_API_KEY`(如開上網研究)
|
||
````
|
||
|
||
- [x] **Step 2: root `AGENTS.md` 更新**
|
||
|
||
- 「指令」一節加 `npm run setup`。
|
||
- 「關鍵慣例與陷阱」加:AI 生成用 `AI_API_KEY` / `TAVILY_API_KEY` secret;Zod schema 喺 `src/schemas/`;Workers Builds 部署但 migration 手動。
|
||
- Child DOX Index 加 `src/schemas/`?—— schemas 由 `src/AGENTS.md` 覆蓋,將 `src/AGENTS.md` 嘅 ownership 描述更新即可。
|
||
|
||
- [x] **Step 3: `src/AGENTS.md` 更新**
|
||
|
||
- Ownership 加入 `src/schemas/`(Zod 驗證層)同 `src/lib/form.ts`、`src/lib/ai.ts`。
|
||
- Routes & SSR 加入 `/admin/ai`。
|
||
- 說明 AI 生成係同步 await、`Astro.locals.cfContext` 為 ExecutionContext。
|
||
|
||
- [x] **Step 4: `src/db/AGENTS.md` 更新**
|
||
|
||
- 表清單加 `blog_keywords`;`KEYWORD_STATUSES`。
|
||
- 說明 `posts.focusKeyword`。
|
||
|
||
- [x] **Step 5: `src/data/AGENTS.md` 更新**
|
||
|
||
- 加 `getKeywords`、`uniqueSlug`。
|
||
- 型別來源改為「由 `src/schemas/` re-export / `z.infer`」。
|
||
|
||
- [x] **Step 6: `src/lib/AGENTS.md` 更新**
|
||
|
||
- 加 `ai.ts`(Tavily + DeepSeek + 解析)、`form.ts`(parseForm)。
|
||
- `env.ts` 加 `AI_API_KEY` / `TAVILY_API_KEY`。
|
||
|
||
- [x] **Step 7: `migrations/AGENTS.md` 更新**
|
||
|
||
- Seed 內容加 AI 設定 keys 同示範關鍵字。
|
||
- 說明 migration 唔入 CI。
|
||
|
||
- [x] **Step 8: 最終驗證**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS。人手對照 spec §2–§4 同實作。
|
||
|
||
---
|
||
|
||
## Self-Review 摘要
|
||
|
||
- **Spec coverage**:§2(Zod)→ Task 1–6;§3(AI)→ Task 7–12;§4(部署)→ Task 13–14。§8 文件同步 → Task 14。全部有對應 task。
|
||
- **Placeholder**:無 TBD / TODO;每個 code step 都有完整代碼。
|
||
- **Type 一致性**:`uniqueSlug(db, base, selfId?)`、`parseForm`、`getAiSettings`、`generateBlogPost`、`getKeywords`、`aiGeneratedPost` 前後一致。
|
||
- **已知取捨**:同步生成(非背景);`focusKeyword` 喺 Task 7 先加(Task 2/4 執行時唔好加);`settings.astro` 用 `as Record<string, unknown>` 索引係為咗動態 key。
|
||
|
||
---
|
||
|
||
## 實作偏差(review 後修正,實作為準)
|
||
|
||
- **Admin 驗證錯誤顯示**:`content/[kind].astro` / `cases.astro` 原本尾段無條件 `redirect` 會蓋過錯誤;改為只喺 `errors` 為空時 redirect,並用 `failedId` / `failedAdd` 分辨係新增定邊一行出錯(避免同一訊息顯示 N 次)。
|
||
- **`settings.astro` 只寫有提交嘅 key**(`submitted` set),防止未提交欄位被 `""` 蓋走。
|
||
- **`/admin/ai` 用 PRG**:`save-settings` / `add-keyword` / `delete-keyword` / `skip-keyword` 成功後 `redirect("/admin/ai?ok=…")`,避免 refresh 重複提交;驗證失敗照樣 render 錯誤。
|
||
- **「生成中…」**:生成表單用 inline `onsubmit` 設 `pointer-events:none` + 改按鈕文字;**唔用 `disabled`**(disabled 會令提交唔帶 `action` 值)。
|
||
- **`ai.ts`**:`parseGeneratedPost` 先正規化(去 code fence、全/半角冒號、粗體標籤);Tavily 用 `Authorization: Bearer`;兩個 fetch 加 `AbortSignal.timeout`(chat 90s、Tavily 15s);DB 寫入包 try/catch;`ai_base_url` 限制 http/https 並喺 UI 加憑證警告。
|
||
- **`scripts/setup.mjs`**:id 解析同時支援 JSON / TOML;登入檢查用 `whoami --json`;建立失敗會查 `d1 list` / `kv namespace list` 回收現有 id(idempotent);密碼交畀 `wrangler secret put`(唔回顯);`site` 仍係 `example.com` 時跳過 deploy。
|
||
- **`.gitignore`**:加入 `.playwright-mcp/`。
|
||
|