# 結構優化 + 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` > **2026-09-12 更新:** AI Blog 提示詞已通用化、預設供應商轉 DeepSeek 官方(`https://api.deepseek.com` / `deepseek-flash`),詳見 `docs/superpowers/specs/2026-09-12-ai-blog-template-deepseek-design.md`。本計劃內舊嘅 `api.deepinfra.com` / `deepseek-ai/DeepSeek-V3-0324` 值已過時。 --- ## 檔案結構 **新增:** ``` 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 加入口連結 scripts/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; ``` - [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; ``` - [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; ``` - [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; ``` - [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; 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; ``` - [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; ``` - [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 = | { ok: true; data: T } | { ok: false; errors: Record }; /** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */ export function parseForm(form: FormData, schema: ZodType): ParseResult { const raw: Record = {}; 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 = {}; 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 { 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 = {}; ``` 將 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 每個 `