Add Zod validation layer and AI blog generation

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

- src/schemas/ holds Zod input schemas for post, content, case,
  settings, AI, and keywords; parseForm() in src/lib/form.ts validates
  FormData and returns per-field errors.
- Migrate all admin POST handlers to parseForm, showing field-level
  errors and only redirecting once validation passes.
- Add blog_keywords table and posts.focus_keyword (migration 0002);
  uniqueSlug() centralised in src/data/content.ts.
- Add src/lib/ai.ts (OpenAI-compatible chat completions + optional
  Tavily research) and /admin/ai for AI settings, keyword queue, and
  draft generation.
- Add scripts/setup.mjs (npm run setup) to provision D1/KV, set
  secrets, and optionally migrate, seed, and deploy.
- Document AI secrets, Workers Builds deploy flow, and new schemas
  across README and AGENTS docs.
This commit is contained in:
2026-09-11 23:49:18 +08:00
parent 5b69bc818a
commit fdee1aabd7
41 changed files with 3240 additions and 296 deletions
+8 -4
View File
@@ -13,6 +13,7 @@
- `config.ts` — build-time 靜態頁用的 `SITE_NAME` / `SITE_TAGLINE`(可管理內容一律放 D1,唔放呢度)。
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`
- `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)。
## Local Contracts
@@ -32,6 +33,7 @@
- **前台動態頁**:讀 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 頁。
- **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`,上線前要改)。
### Admin`pages/admin/`
@@ -39,11 +41,13 @@
- 所有 `/admin` 路由**必須** `export const prerender = false`
- **認證**由 `middleware.ts` 統一處理;登入喺 `admin/login.astro``checkPassword` + `createSession`),登出喺 `logout.ts`
- **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` 實作)。
- **通用內容編輯器**`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。
- **文章**`index.astro` 列表、`post/[id].astro` 新增/編輯(`id === "new"` 為新增);slug 自動 `slugify` 並用 `uniqueSlug` 去重。
- **AI 生成**`ai.astro``/admin/ai`)管 AI 設定 + 關鍵字佇列,同步呼叫 `generateBlogPost`;成功會 redirect 去新草稿 `/admin/post/<id>`。生成按鈕用 inline `onsubmit` 顯示「生成中…」。
- **圖片**:一律以 URL 字串處理(暫無上傳)。
- 前台可見性靠 `status``published` / `draft`);列表頁顯示全部,前台只顯示 published。
@@ -51,8 +55,8 @@
- 改前台視覺先睇 `theme/system.ts` 的 tokens,優先重用語意 token,唔好散落硬編色值。
- 新增 endpoint 或 SSR 頁後,確認 `prerender = false` 同相應快取 header 都有。
- 新增一個內容欄位:先改 `db/schema.ts``npm run db:generate`,再改對應 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)唔會被路由。
- 新增一個內容欄位:先改 `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)唔會被路由。
## Verification
@@ -66,4 +70,4 @@
| `components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 |
| `data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
| `db/AGENTS.md` | Drizzle schemamigration 的唯一來源) |
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
| `lib/AGENTS.md` | auth / env / db / form / ai / markdown 基礎工具 |
+3 -1
View File
@@ -7,13 +7,15 @@ D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`setting
## Ownership
- 擁有 `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
- **查詢集中**:所有前台/Blog 的 D1 查詢寫喺 `content.ts`,唔好散落喺頁面或元件。
- **只回前台可見**`getItems` / `getCases` / `getPublishedPosts` 只回 `status = "published"`,並按 `sortOrder``publishedAt DESC` 排序;`getPostBySlug` 同時要求 published。
- **首頁聚合**`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>`
- **連結 helper**`whatsappHref` / `telHref` / `mailHref` / `digits` / `formatDate`;電話/WhatsApp 一律經呢啲 helper,唔好散寫。
- **設定欄位**`settings-fields.ts``SETTINGS_GROUPS``/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `migrations/seed.sql`
+25 -1
View File
@@ -1,10 +1,12 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, ne } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import {
blogKeywords,
cases,
contentItems,
posts,
siteSettings,
type BlogKeyword,
type CaseStudy,
type ContentItem,
type ContentKind,
@@ -62,6 +64,13 @@ export async function getPostBySlug(db: Db, slug: string): Promise<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> {
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
getSettings(db),
@@ -102,3 +111,18 @@ export function formatDate(d: Date | null | undefined): string {
day: "numeric",
});
}
/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}
+5 -4
View File
@@ -11,12 +11,13 @@ Drizzle schema 定義,係 D1 migration 的唯一來源。
## Local Contracts
- **表**`posts``site_settings``content_items``cases`
- **表**`posts``site_settings``content_items``cases``blog_keywords`
- **可重複內容**`content_items``kind` 區分,`CONTENT_KINDS = ["service", "feature", "step", "faq"]`
- **狀態**`posts` / `content_items` / `cases` 都有 `status``draft` | `published`)。
- **型別**:由 `$inferSelect` 匯出(`Post``ContentItem``CaseStudy``SiteSetting` 等),其他地方重用呢啲型別
- **狀態**`posts` / `content_items` / `cases` 都有 `status``draft` | `published``blog_keywords``KEYWORD_STATUSES = ["pending", "generated", "skipped"]``KeywordStatus`
- **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` 除外)。
- 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)`)。
- 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)``idx_keywords_status(status)`)。
## Work Guidance
+24
View File
@@ -13,6 +13,7 @@ export const posts = sqliteTable(
content: text("content").notNull(),
coverImage: text("cover_image"),
tags: text("tags"),
focusKeyword: text("focus_keyword"),
metaDescription: text("meta_description"),
status: text("status", { enum: ["draft", "published"] })
.notNull()
@@ -90,8 +91,31 @@ export const cases = sqliteTable(
(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 NewPost = typeof posts.$inferInsert;
export type ContentItem = typeof contentItems.$inferSelect;
export type CaseStudy = typeof cases.$inferSelect;
export type SiteSetting = typeof siteSettings.$inferSelect;
export type BlogKeyword = typeof blogKeywords.$inferSelect;
export type NewBlogKeyword = typeof blogKeywords.$inferInsert;
+6 -1
View File
@@ -12,7 +12,10 @@ const links = [
{ href: "/admin/content/faq", label: "常見問題" },
{ href: "/admin/cases", 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>
@@ -33,7 +36,7 @@ const links = [
<nav>
{
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}
</a>
))
@@ -107,6 +110,8 @@ const links = [
.badge.published { background: #e1f5ee; color: #0f6e56; }
.badge.draft { background: #faeeda; color: #854f0b; }
.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; }
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
+11 -3
View File
@@ -2,26 +2,34 @@
## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、Markdown 處理。
跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理。
## Ownership
- 擁有 `src/lib/` 個檔案。
- 擁有 `src/lib/` 個檔案`env.ts``db.ts``auth.ts``form.ts``ai.ts``markdown.ts`
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
## 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`
- **`auth.ts`**HMAC-SHA256 signed cookie 認證。
- 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions`
- Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"`
- 密碼同 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`
## Work Guidance
- 認證相關改動要同時檢查 `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。
## Verification
+224
View File
@@ -0,0 +1,224 @@
import { asc, eq } from "drizzle-orm";
import type { Db } from "../data/content";
import { getSettings, uniqueSlug } from "../data/content";
import { blogKeywords, posts } from "../db/schema";
import type { AppEnv } from "./env";
import { autoExcerpt, slugify } from "./markdown";
import { aiGeneratedPost } from "../schemas";
type AiSettings = {
enabled: boolean;
baseUrl: string;
model: string;
contextPrompt: string;
businessContext: string;
webSearchEnabled: boolean;
webSearchMaxResults: number;
};
export async function getAiSettings(db: Db): Promise<AiSettings> {
const s = await getSettings(db);
return {
enabled: s.ai_enabled !== "0",
baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai",
model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324",
contextPrompt: s.ai_context_prompt ?? "",
businessContext: s.ai_business_context ?? "",
webSearchEnabled: s.ai_web_search_enabled === "1",
webSearchMaxResults: Math.min(10, Math.max(1, Number(s.ai_web_search_max_results) || 5)),
};
}
type TavilyResult = { title?: string; url?: string; content?: string };
/** Tavily 搜尋,失敗回空字串(唔中斷生成)。 */
async function tavilyResearch(query: string, apiKey: string, maxResults: number): Promise<string> {
try {
const res = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return "";
const data = (await res.json()) as { results?: TavilyResult[] };
const results = data.results ?? [];
return results
.map((r, i) => `${i + 1}. ${r.title ?? ""}${r.url ?? ""}\n${r.content ?? ""}`)
.join("\n\n");
} catch {
return "";
}
}
function buildSystemPrompt(opts: {
contextPrompt: string;
businessContext: string;
research: string;
topic: string;
}): string {
const parts: string[] = [];
if (opts.contextPrompt) parts.push(opts.contextPrompt);
if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
主題:${opts.topic}
要求:
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
- 用 ## / ### 分段,段落清晰易讀。
- 開頭先畀一段總結(summary-first),再展開。
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
輸出格式(必須嚴格跟隨,唔要加額外說明):
TITLE: <文章標題>
CONTENT:
<Markdown 正文,唔需要重複標題>
META_DESC: <160 字以內 SEO 描述>`);
return parts.join("\n\n");
}
async function callChat(settings: AiSettings, apiKey: string, systemPrompt: string): Promise<string> {
const res = await fetch(`${settings.baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: settings.model,
messages: [{ role: "user", content: systemPrompt }],
max_tokens: 2200,
temperature: 0.7,
}),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`AI 供應商回應 ${res.status}${body.slice(0, 200)}`);
}
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const text = data.choices?.[0]?.message?.content ?? "";
if (!text) throw new Error("AI 冇回覆內容。");
return text;
}
export type ParsedGenerated =
| { ok: true; data: { title: string; content: string; metaDescription: string; excerpt: string } }
| { ok: false; error: string };
export function parseGeneratedPost(text: string): ParsedGenerated {
const normalized = text
.replace(/```[a-z]*\n?/gi, "")
.replace(/```/g, "")
.replace(/\*\*\s*(TITLE|CONTENT|META_DESC)\s*[:]\s*\*\*/gi, "$1:")
.replace(/__\s*(TITLE|CONTENT|META_DESC)\s*[:]\s*__/gi, "$1:")
.replace(/\*\*(TITLE|CONTENT|META_DESC)\*\*\s*[:]/gi, "$1:");
const titleMatch = normalized.match(/TITLE[:]\s*(.+)/i);
const contentMatch = normalized.match(/CONTENT[:]\s*([\s\S]*?)(?:\nMETA_DESC[:]|$)/i);
const metaMatch = normalized.match(/META_DESC[:]\s*([\s\S]+)/i);
const content = (contentMatch?.[1] ?? "").trim();
const metaDescription = (metaMatch?.[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160);
const candidate = {
title: (titleMatch?.[1] ?? "").trim(),
content,
metaDescription,
excerpt: metaDescription || autoExcerpt(content),
};
const parsed = aiGeneratedPost.safeParse(candidate);
if (!parsed.success) return { ok: false, error: "AI 輸出格式唔正確,請再試。" };
return { ok: true, data: parsed.data };
}
export type GenerateResult = { ok: true; postId: string } | { ok: false; error: string };
export async function generateBlogPost(
db: Db,
env: AppEnv,
input: { mode: "topic"; topic: string } | { mode: "next" },
): Promise<GenerateResult> {
const settings = await getAiSettings(db);
if (!settings.enabled) return { ok: false, error: "AI 生成未啟用。" };
const apiKey = env.AI_API_KEY;
if (!apiKey) return { ok: false, error: "未設定 AI_API_KEY。" };
let topic: string;
let keywordId: string | null = null;
if (input.mode === "next") {
const [next] = await db
.select()
.from(blogKeywords)
.where(eq(blogKeywords.status, "pending"))
.orderBy(asc(blogKeywords.createdAt))
.limit(1);
if (!next) return { ok: false, error: "關鍵字佇列冇 pending 項目。" };
topic = next.keyword;
keywordId = next.id;
} else {
topic = input.topic.trim();
if (!topic) return { ok: false, error: "請輸入主題。" };
}
let research = "";
if (settings.webSearchEnabled && env.TAVILY_API_KEY) {
research = await tavilyResearch(topic, env.TAVILY_API_KEY, settings.webSearchMaxResults);
}
let generated;
try {
const text = await callChat(
settings,
apiKey,
buildSystemPrompt({
contextPrompt: settings.contextPrompt,
businessContext: settings.businessContext,
research,
topic,
}),
);
const parsed = parseGeneratedPost(text);
if (!parsed.ok) return parsed;
generated = parsed.data;
} catch (err) {
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
return { ok: false, error: "AI 供應商回應逾時,請再試。" };
}
return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
}
const now = new Date();
const id = crypto.randomUUID();
const excerpt = generated.excerpt || autoExcerpt(generated.content);
try {
const slug = await uniqueSlug(db, slugify(generated.title));
await db.insert(posts).values({
id,
slug,
title: generated.title,
excerpt,
content: generated.content,
coverImage: null,
tags: null,
focusKeyword: topic,
metaDescription: generated.metaDescription || excerpt.slice(0, 155),
status: "draft",
publishedAt: null,
updatedAt: now,
});
if (keywordId) {
await db
.update(blogKeywords)
.set({ status: "generated", usedAt: now, postId: id })
.where(eq(blogKeywords.id, keywordId));
}
} catch {
return { ok: false, error: "寫入草稿失敗,請再試。" };
}
return { ok: true, postId: id };
}
+2
View File
@@ -4,6 +4,8 @@ export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
};
/**
+23
View File
@@ -0,0 +1,23 @@
import type { ZodType } from "zod";
export type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Record<string, string> };
/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */
export function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T> {
const raw: Record<string, unknown> = {};
for (const [key, value] of form.entries()) {
if (typeof value === "string") raw[key] = value;
}
const result = schema.safeParse(raw);
if (result.success) return { ok: true, data: result.data };
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".") || "_form";
if (!errors[key]) errors[key] = issue.message;
}
return { ok: false, errors };
}
+217
View File
@@ -0,0 +1,217 @@
---
import { eq } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { getKeywords, getSettings } from "../../data/content";
import { blogKeywords, siteSettings } from "../../db/schema";
import { parseForm } from "../../lib/form";
import { aiSettingsInput, keywordInput } from "../../schemas";
import { generateBlogPost } from "../../lib/ai";
export const prerender = false;
const env = getEnv();
const db = getDb(env.DB);
let error = "";
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "save-settings") {
const parsed = parseForm(form, aiSettingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const now = new Date();
for (const [key, value] of Object.entries(parsed.data)) {
const v = String(value ?? "");
await db
.insert(siteSettings)
.values({ key, value: v, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value: v, updatedAt: now } });
}
return Astro.redirect("/admin/ai?ok=settings");
}
} else if (action === "add-keyword") {
const parsed = parseForm(form, keywordInput);
if (!parsed.ok) {
error = parsed.errors.keyword ?? "請輸入關鍵字。";
} else {
await db.insert(blogKeywords).values({
id: crypto.randomUUID(),
keyword: parsed.data.keyword,
status: "pending",
createdAt: new Date(),
});
return Astro.redirect("/admin/ai?ok=keyword");
}
} else if (action === "delete-keyword") {
await db.delete(blogKeywords).where(eq(blogKeywords.id, str(form, "id")));
return Astro.redirect("/admin/ai?ok=deleted");
} else if (action === "skip-keyword") {
await db
.update(blogKeywords)
.set({ status: "skipped" })
.where(eq(blogKeywords.id, str(form, "id")));
return Astro.redirect("/admin/ai?ok=skipped");
} else if (action === "generate-topic" || action === "generate-next") {
const result = await generateBlogPost(
db,
env,
action === "generate-next"
? { mode: "next" }
: { mode: "topic", topic: str(form, "topic") },
);
if (result.ok) return Astro.redirect(`/admin/post/${result.postId}`);
error = result.error;
}
}
const okMessages: Record<string, string> = {
settings: "已儲存 AI 設定。",
keyword: "已加入關鍵字。",
deleted: "已刪除。",
skipped: "已標記略過。",
};
const notice = okMessages[Astro.url.searchParams.get("ok") ?? ""] ?? "";
const settings = await getSettings(db);
const keywords = await getKeywords(db);
const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KEY) };
---
<AdminLayout title="AI 生成">
<h1>AI 生成 Blog</h1>
<p class="sub">設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(生成後會開啟文章編輯頁覆核)。</p>
{error && <p class="err" style="margin-bottom:16px">{error}</p>}
{
notice && (
<p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">
✓ {notice}
</p>
)
}
<div class="card">
<h2>快速生成</h2>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end" onsubmit="this.style.pointerEvents='none'; this.style.opacity='0.65'; if(event.submitter) event.submitter.textContent='生成中…';">
<div style="flex:1;min-width:240px">
<label for="topic">自由輸入主題</label>
<input id="topic" name="topic" type="text" placeholder="例:村屋太陽能回本期點計?" />
</div>
<button type="submit" name="action" value="generate-topic">生成草稿</button>
<button type="submit" name="action" value="generate-next" class="secondary">生成下一篇(佇列)</button>
</form>
<p class="muted" style="margin:10px 0 0">生成需時約 10–40 秒,請耐心等候,唔好重複提交。</p>
</div>
<div class="card">
<h2>AI 設定</h2>
<form method="post">
<input type="hidden" name="action" value="save-settings" />
<div class="grid2">
<div>
<label for="ai_enabled">AI 生成</label>
<select id="ai_enabled" name="ai_enabled">
<option value="1" selected={settings.ai_enabled !== "0"}>啟用</option>
<option value="0" selected={settings.ai_enabled === "0"}>停用</option>
</select>
</div>
<div>
<label for="ai_chat_model">模型</label>
<input id="ai_chat_model" name="ai_chat_model" type="text" value={settings.ai_chat_model ?? "deepseek-ai/DeepSeek-V3-0324"} />
</div>
<div>
<label for="ai_base_url">OpenAI 相容 Base URL</label>
<input id="ai_base_url" name="ai_base_url" type="text" value={settings.ai_base_url ?? "https://api.deepinfra.com/v1/openai"} />
<p class="muted" style="margin:6px 0 0">⚠️ 只填信任嘅供應商;AI_API_KEY 會傳送去呢個網址。</p>
</div>
<div>
<label for="ai_web_search_enabled">上網研究(Tavily</label>
<select id="ai_web_search_enabled" name="ai_web_search_enabled">
<option value="1" selected={settings.ai_web_search_enabled === "1"}>啟用</option>
<option value="0" selected={settings.ai_web_search_enabled !== "1"}>停用</option>
</select>
</div>
<div>
<label for="ai_web_search_max_results">Tavily 結果數(110</label>
<input id="ai_web_search_max_results" name="ai_web_search_max_results" type="text" value={settings.ai_web_search_max_results ?? "5"} />
</div>
<div>
<label>API Key 狀態</label>
<p class="muted" style="margin:10px 0 0">
AI_API_KEY{secrets.ai ? "已設定" : "未設定"} · TAVILY_API_KEY{secrets.tavily ? "已設定" : "未設定"}
</p>
</div>
<div style="grid-column:1/-1">
<label for="ai_context_prompt">寫作風格 / 語氣</label>
<textarea id="ai_context_prompt" name="ai_context_prompt">{settings.ai_context_prompt ?? ""}</textarea>
</div>
<div style="grid-column:1/-1">
<label for="ai_business_context">公司背景資料(grounding</label>
<textarea id="ai_business_context" name="ai_business_context">{settings.ai_business_context ?? ""}</textarea>
</div>
</div>
<div class="actions"><button type="submit">儲存 AI 設定</button></div>
</form>
</div>
<div class="card">
<h2>關鍵字佇列({keywords.length}</h2>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
<input type="hidden" name="action" value="add-keyword" />
<div style="flex:1;min-width:240px">
<label for="keyword">新增關鍵字 / 主題</label>
<input id="keyword" name="keyword" type="text" placeholder="例:太陽能板保養" />
</div>
<button type="submit" class="secondary">+ 加入</button>
</form>
{
keywords.length === 0 ? (
<p class="muted" style="margin-top:14px">暫時未有關鍵字。</p>
) : (
<table style="margin-top:14px">
<thead>
<tr>
<th>關鍵字</th>
<th style="width:100px">狀態</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{keywords.map((k) => (
<tr>
<td>{k.keyword}</td>
<td>
<span class={`badge ${k.status === "generated" ? "published" : "draft"}`}>
{k.status === "pending" ? "待生成" : k.status === "generated" ? "已生成" : "已略過"}
</span>
</td>
<td>
<form method="post" style="display:inline">
<input type="hidden" name="id" value={k.id} />
{k.status === "pending" && (
<button class="secondary mini" name="action" value="skip-keyword">略過</button>
)}
<button class="danger mini" name="action" value="delete-keyword" onclick="return confirm('確定刪除?')">
刪除
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)
}
</div>
</AdminLayout>
+38 -24
View File
@@ -4,11 +4,17 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
import { cases } from "../../db/schema";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { parseForm } from "../../lib/form";
import { caseInput } from "../../schemas";
export const prerender = false;
const db = getDb(getEnv().DB);
let errors: Record<string, string> = {};
let failedId = "";
let failedAdd = false;
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
@@ -17,36 +23,40 @@ if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
if (action === "add" || action === "save") {
const parsed = parseForm(form, caseInput);
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);
await db.insert(cases).values({
id: crypto.randomUUID(),
title,
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
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: str(form, "status") === "draft" ? "draft" : "published",
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 === "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") {
await db.delete(cases).where(eq(cases.id, str(form, "id")));
} 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));
@@ -85,6 +97,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
<div class="grid2">
<div>
<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="例:大埔上碗窯 — 雙玻光伏板" />
</div>
<div>
@@ -119,6 +132,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
<div class="grid2">
<div>
<label>標題</label>
{failedId === item.id && errors.title && <span class="field-error">{errors.title}</span>}
<input name="title" type="text" value={item.title} required />
</div>
<div>
+35 -20
View File
@@ -4,6 +4,8 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
import { contentItems, CONTENT_KINDS, type ContentKind } from "../../../db/schema";
import { getDb } from "../../../lib/db";
import { getEnv } from "../../../lib/env";
import { parseForm } from "../../../lib/form";
import { contentItemInput } from "../../../schemas";
export const prerender = false;
@@ -50,6 +52,10 @@ const META: Record<
const meta = META[kind];
let errors: Record<string, string> = {};
let failedId = "";
let failedAdd = false;
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
@@ -58,9 +64,14 @@ if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
if (action === "add" || action === "save") {
form.set("kind", kind);
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
.select({ m: max(contentItems.sortOrder) })
.from(contentItems)
@@ -68,26 +79,26 @@ if (Astro.request.method === "POST") {
await db.insert(contentItems).values({
id: crypto.randomUUID(),
kind,
title,
description: str(form, "description"),
extra: str(form, "extra") || null,
title: parsed.data.title,
description: parsed.data.description,
extra: parsed.data.extra || null,
sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published",
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 === "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") {
await db
.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
@@ -137,6 +150,7 @@ const items = await db
<div class="grid2">
<div>
<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 />
</div>
{
@@ -169,6 +183,7 @@ const items = await db
<div class="grid2">
<div>
<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 />
</div>
{meta.extraLabel && (
+1
View File
@@ -31,6 +31,7 @@ const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
<a class="btn secondary" href="/admin/content/faq">常見問題({countOf("faq")}</a>
<a class="btn secondary" href="/admin/cases">完成案例({caseCount?.n ?? 0}</a>
<a class="btn secondary" href="/admin/settings">網站設定</a>
<a class="btn secondary" href="/admin/ai">AI 生成</a>
</div>
</div>
+32 -35
View File
@@ -1,10 +1,13 @@
---
import { and, eq, ne } from "drizzle-orm";
import { eq } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { 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";
export const prerender = false;
@@ -13,6 +16,7 @@ const isNew = id === "new";
const db = getDb(getEnv().DB);
let error = "";
let errors: Record<string, string> = {};
const [existing] = isNew
? []
@@ -22,25 +26,6 @@ if (!isNew && !existing) {
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") {
const form = await Astro.request.formData();
const action = String(form.get("action") ?? "save");
@@ -50,29 +35,34 @@ if (Astro.request.method === "POST") {
return Astro.redirect("/admin");
}
const title = String(form.get("title") ?? "").trim();
const content = String(form.get("content") ?? "");
if (!title) {
error = "標題唔可以留空。";
const parsed = parseForm(form, postInput);
if (!parsed.ok) {
errors = parsed.errors;
error = Object.values(parsed.errors)[0] ?? "輸入有誤。";
} else {
const status = String(form.get("status") ?? "draft") === "published" ? "published" : "draft";
const rawSlug = String(form.get("slug") ?? "").trim();
const slug = await uniqueSlug(rawSlug ? slugify(rawSlug) : slugify(title), isNew ? undefined : id);
const excerpt = String(form.get("excerpt") ?? "").trim() || autoExcerpt(content);
const metaDescription = String(form.get("metaDescription") ?? "").trim() || excerpt.slice(0, 155);
const coverImage = String(form.get("coverImage") ?? "").trim() || null;
const tags = String(form.get("tags") ?? "").trim() || null;
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,
title: data.title,
excerpt,
content,
content: data.content,
coverImage,
tags,
focusKeyword: null,
metaDescription,
status,
publishedAt: status === "published" ? now : null,
@@ -83,9 +73,9 @@ if (Astro.request.method === "POST") {
.update(posts)
.set({
slug,
title,
title: data.title,
excerpt,
content,
content: data.content,
coverImage,
tags,
metaDescription,
@@ -122,12 +112,15 @@ const post = isNew
<form method="post">
<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="文章標題" />
<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="留空會由標題自動產生" />
<label for="excerpt">摘要(列表頁顯示)</label>
{errors.excerpt && <span class="field-error">{errors.excerpt}</span>}
<input
id="excerpt"
name="excerpt"
@@ -137,6 +130,7 @@ const post = isNew
/>
<label for="coverImage">封面圖片 URL(列表卡片用)</label>
{errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
<input
id="coverImage"
name="coverImage"
@@ -146,9 +140,11 @@ const post = isNew
/>
<label for="tags">標籤(逗號分隔,可選)</label>
{errors.tags && <span class="field-error">{errors.tags}</span>}
<input id="tags" name="tags" type="text" value={post.tags ?? ""} placeholder="村屋,太陽能,指南" />
<label for="metaDescription">SEO description</label>
{errors.metaDescription && <span class="field-error">{errors.metaDescription}</span>}
<input
id="metaDescription"
name="metaDescription"
@@ -158,6 +154,7 @@ const post = isNew
/>
<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>
<label for="status">狀態</label>
+23 -8
View File
@@ -5,23 +5,36 @@ import { siteSettings } from "../../db/schema";
import { getSettings } from "../../data/content";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { parseForm } from "../../lib/form";
import { settingsInput } from "../../schemas";
export const prerender = false;
const db = getDb(getEnv().DB);
let saved = false;
let error = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const now = new Date();
for (const key of ALL_SETTING_KEYS) {
const value = String(form.get(key) ?? "").trim();
await db
.insert(siteSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
const parsed = parseForm(form, settingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const now = new Date();
const submitted = new Set(
[...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);
@@ -31,6 +44,8 @@ const settings = await getSettings(db);
<h1>網站設定</h1>
<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>}
<form method="post">
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
export const aiSettingsInput = z.object({
ai_enabled: z.enum(["1", "0"]).default("1"),
ai_base_url: z.preprocess(
(v) => (v === "" ? undefined : v),
z
.url("Base URL 要係有效網址。")
.default("https://api.deepinfra.com/v1/openai")
.refine((u) => /^https?:\/\//i.test(u), "Base URL 只支援 http/https。"),
),
ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"),
ai_context_prompt: z.string().default(""),
ai_business_context: z.string().default(""),
ai_web_search_enabled: z.enum(["1", "0"]).default("0"),
ai_web_search_max_results: z.preprocess(
(v) => (v === "" ? undefined : v),
z.coerce
.number()
.int()
.min(1, "結果數要介乎 110。")
.max(10, "結果數要介乎 110。")
.default(5),
),
});
export type AiSettingsInput = z.infer<typeof aiSettingsInput>;
export const aiGeneratedPost = z.object({
title: z.string().trim().min(1),
content: z.string().trim().min(1),
metaDescription: z.string().trim().default(""),
excerpt: z.string().trim().default(""),
});
export type AiGeneratedPost = z.infer<typeof aiGeneratedPost>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
import { postStatus } from "./post";
export const caseInput = z.object({
title: z.string().trim().min(1, "標題唔可以留空。"),
location: z.string().trim().default(""),
completedAt: z.string().trim().default(""),
description: z.string().default(""),
imageUrl: z.string().trim().default(""),
status: postStatus.default("published"),
});
export type CaseInput = z.infer<typeof caseInput>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
import { CONTENT_KINDS } from "../db/schema";
import { postStatus } from "./post";
export const contentItemInput = z.object({
kind: z.enum(CONTENT_KINDS),
title: z.string().trim().min(1, "名稱唔可以留空。"),
description: z.string().default(""),
extra: z.string().trim().default(""),
status: postStatus.default("published"),
});
export type ContentItemInput = z.infer<typeof contentItemInput>;
+6
View File
@@ -0,0 +1,6 @@
export * from "./post";
export * from "./content";
export * from "./case";
export * from "./settings";
export * from "./ai";
export * from "./keyword";
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
import { KEYWORD_STATUSES } from "../db/schema";
export const keywordStatus = z.enum(KEYWORD_STATUSES);
export const keywordInput = z.object({
keyword: z.string().trim().min(1, "關鍵字唔可以留空。"),
});
export type KeywordInput = z.infer<typeof keywordInput>;
export type KeywordStatus = z.infer<typeof keywordStatus>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
export const postStatus = z.enum(["draft", "published"]);
export const postInput = z.object({
title: z.string().trim().min(1, "標題唔可以留空。"),
slug: z.string().trim().default(""),
excerpt: z.string().trim().default(""),
content: z.string().default(""),
coverImage: z.string().trim().default(""),
tags: z.string().trim().default(""),
metaDescription: z.string().trim().default(""),
status: postStatus.default("draft"),
});
export type PostInput = z.infer<typeof postInput>;
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { ALL_SETTING_KEYS } from "../data/settings-fields";
export const settingsInput = z.object(
Object.fromEntries(
ALL_SETTING_KEYS.map((key) => [key, z.string().trim().default("")]),
),
);
export type SettingsInput = z.infer<typeof settingsInput>;