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:
+8
-4
@@ -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 schema(migration 的唯一來源) |
|
||||
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
|
||||
| `lib/AGENTS.md` | auth / env / db / form / ai / markdown 基礎工具 |
|
||||
|
||||
+3
-1
@@ -7,13 +7,15 @@ D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`setting
|
||||
## Ownership
|
||||
|
||||
- 擁有 `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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,224 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { Db } from "../data/content";
|
||||
import { getSettings, uniqueSlug } from "../data/content";
|
||||
import { blogKeywords, posts } from "../db/schema";
|
||||
import type { AppEnv } from "./env";
|
||||
import { autoExcerpt, slugify } from "./markdown";
|
||||
import { aiGeneratedPost } from "../schemas";
|
||||
|
||||
type AiSettings = {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
contextPrompt: string;
|
||||
businessContext: string;
|
||||
webSearchEnabled: boolean;
|
||||
webSearchMaxResults: number;
|
||||
};
|
||||
|
||||
export async function getAiSettings(db: Db): Promise<AiSettings> {
|
||||
const s = await getSettings(db);
|
||||
return {
|
||||
enabled: s.ai_enabled !== "0",
|
||||
baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai",
|
||||
model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324",
|
||||
contextPrompt: s.ai_context_prompt ?? "",
|
||||
businessContext: s.ai_business_context ?? "",
|
||||
webSearchEnabled: s.ai_web_search_enabled === "1",
|
||||
webSearchMaxResults: Math.min(10, Math.max(1, Number(s.ai_web_search_max_results) || 5)),
|
||||
};
|
||||
}
|
||||
|
||||
type TavilyResult = { title?: string; url?: string; content?: string };
|
||||
|
||||
/** Tavily 搜尋,失敗回空字串(唔中斷生成)。 */
|
||||
async function tavilyResearch(query: string, apiKey: string, maxResults: number): Promise<string> {
|
||||
try {
|
||||
const res = await fetch("https://api.tavily.com/search", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
const data = (await res.json()) as { results?: TavilyResult[] };
|
||||
const results = data.results ?? [];
|
||||
return results
|
||||
.map((r, i) => `${i + 1}. ${r.title ?? ""}(${r.url ?? ""})\n${r.content ?? ""}`)
|
||||
.join("\n\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(opts: {
|
||||
contextPrompt: string;
|
||||
businessContext: string;
|
||||
research: string;
|
||||
topic: string;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (opts.contextPrompt) parts.push(opts.contextPrompt);
|
||||
if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
|
||||
if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
|
||||
parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
|
||||
|
||||
主題:${opts.topic}
|
||||
|
||||
要求:
|
||||
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
|
||||
- 用 ## / ### 分段,段落清晰易讀。
|
||||
- 開頭先畀一段總結(summary-first),再展開。
|
||||
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
|
||||
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
|
||||
|
||||
輸出格式(必須嚴格跟隨,唔要加額外說明):
|
||||
TITLE: <文章標題>
|
||||
CONTENT:
|
||||
<Markdown 正文,唔需要重複標題>
|
||||
META_DESC: <160 字以內 SEO 描述>`);
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
async function callChat(settings: AiSettings, apiKey: string, systemPrompt: string): Promise<string> {
|
||||
const res = await fetch(`${settings.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: settings.model,
|
||||
messages: [{ role: "user", content: systemPrompt }],
|
||||
max_tokens: 2200,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`AI 供應商回應 ${res.status}:${body.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const text = data.choices?.[0]?.message?.content ?? "";
|
||||
if (!text) throw new Error("AI 冇回覆內容。");
|
||||
return text;
|
||||
}
|
||||
|
||||
export type ParsedGenerated =
|
||||
| { ok: true; data: { title: string; content: string; metaDescription: string; excerpt: string } }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function parseGeneratedPost(text: string): ParsedGenerated {
|
||||
const normalized = text
|
||||
.replace(/```[a-z]*\n?/gi, "")
|
||||
.replace(/```/g, "")
|
||||
.replace(/\*\*\s*(TITLE|CONTENT|META_DESC)\s*[::]\s*\*\*/gi, "$1:")
|
||||
.replace(/__\s*(TITLE|CONTENT|META_DESC)\s*[::]\s*__/gi, "$1:")
|
||||
.replace(/\*\*(TITLE|CONTENT|META_DESC)\*\*\s*[::]/gi, "$1:");
|
||||
|
||||
const titleMatch = normalized.match(/TITLE[::]\s*(.+)/i);
|
||||
const contentMatch = normalized.match(/CONTENT[::]\s*([\s\S]*?)(?:\nMETA_DESC[::]|$)/i);
|
||||
const metaMatch = normalized.match(/META_DESC[::]\s*([\s\S]+)/i);
|
||||
|
||||
const content = (contentMatch?.[1] ?? "").trim();
|
||||
const metaDescription = (metaMatch?.[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160);
|
||||
const candidate = {
|
||||
title: (titleMatch?.[1] ?? "").trim(),
|
||||
content,
|
||||
metaDescription,
|
||||
excerpt: metaDescription || autoExcerpt(content),
|
||||
};
|
||||
|
||||
const parsed = aiGeneratedPost.safeParse(candidate);
|
||||
if (!parsed.success) return { ok: false, error: "AI 輸出格式唔正確,請再試。" };
|
||||
return { ok: true, data: parsed.data };
|
||||
}
|
||||
|
||||
export type GenerateResult = { ok: true; postId: string } | { ok: false; error: string };
|
||||
|
||||
export async function generateBlogPost(
|
||||
db: Db,
|
||||
env: AppEnv,
|
||||
input: { mode: "topic"; topic: string } | { mode: "next" },
|
||||
): Promise<GenerateResult> {
|
||||
const settings = await getAiSettings(db);
|
||||
if (!settings.enabled) return { ok: false, error: "AI 生成未啟用。" };
|
||||
const apiKey = env.AI_API_KEY;
|
||||
if (!apiKey) return { ok: false, error: "未設定 AI_API_KEY。" };
|
||||
|
||||
let topic: string;
|
||||
let keywordId: string | null = null;
|
||||
if (input.mode === "next") {
|
||||
const [next] = await db
|
||||
.select()
|
||||
.from(blogKeywords)
|
||||
.where(eq(blogKeywords.status, "pending"))
|
||||
.orderBy(asc(blogKeywords.createdAt))
|
||||
.limit(1);
|
||||
if (!next) return { ok: false, error: "關鍵字佇列冇 pending 項目。" };
|
||||
topic = next.keyword;
|
||||
keywordId = next.id;
|
||||
} else {
|
||||
topic = input.topic.trim();
|
||||
if (!topic) return { ok: false, error: "請輸入主題。" };
|
||||
}
|
||||
|
||||
let research = "";
|
||||
if (settings.webSearchEnabled && env.TAVILY_API_KEY) {
|
||||
research = await tavilyResearch(topic, env.TAVILY_API_KEY, settings.webSearchMaxResults);
|
||||
}
|
||||
|
||||
let generated;
|
||||
try {
|
||||
const text = await callChat(
|
||||
settings,
|
||||
apiKey,
|
||||
buildSystemPrompt({
|
||||
contextPrompt: settings.contextPrompt,
|
||||
businessContext: settings.businessContext,
|
||||
research,
|
||||
topic,
|
||||
}),
|
||||
);
|
||||
const parsed = parseGeneratedPost(text);
|
||||
if (!parsed.ok) return parsed;
|
||||
generated = parsed.data;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
||||
return { ok: false, error: "AI 供應商回應逾時,請再試。" };
|
||||
}
|
||||
return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const id = crypto.randomUUID();
|
||||
const excerpt = generated.excerpt || autoExcerpt(generated.content);
|
||||
|
||||
try {
|
||||
const slug = await uniqueSlug(db, slugify(generated.title));
|
||||
|
||||
await db.insert(posts).values({
|
||||
id,
|
||||
slug,
|
||||
title: generated.title,
|
||||
excerpt,
|
||||
content: generated.content,
|
||||
coverImage: null,
|
||||
tags: null,
|
||||
focusKeyword: topic,
|
||||
metaDescription: generated.metaDescription || excerpt.slice(0, 155),
|
||||
status: "draft",
|
||||
publishedAt: null,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (keywordId) {
|
||||
await db
|
||||
.update(blogKeywords)
|
||||
.set({ status: "generated", usedAt: now, postId: id })
|
||||
.where(eq(blogKeywords.id, keywordId));
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "寫入草稿失敗,請再試。" };
|
||||
}
|
||||
|
||||
return { ok: true, postId: id };
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export type AppEnv = {
|
||||
DB: D1Database;
|
||||
CACHE: KVNamespace;
|
||||
ADMIN_PASSWORD?: string;
|
||||
AI_API_KEY?: string;
|
||||
TAVILY_API_KEY?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ZodType } from "zod";
|
||||
|
||||
export type ParseResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; errors: Record<string, string> };
|
||||
|
||||
/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */
|
||||
export function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T> {
|
||||
const raw: Record<string, unknown> = {};
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (typeof value === "string") raw[key] = value;
|
||||
}
|
||||
|
||||
const result = schema.safeParse(raw);
|
||||
if (result.success) return { ok: true, data: result.data };
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path.join(".") || "_form";
|
||||
if (!errors[key]) errors[key] = issue.message;
|
||||
}
|
||||
return { ok: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
import { eq } from "drizzle-orm";
|
||||
import AdminLayout from "../../layouts/AdminLayout.astro";
|
||||
import { getDb } from "../../lib/db";
|
||||
import { getEnv } from "../../lib/env";
|
||||
import { getKeywords, getSettings } from "../../data/content";
|
||||
import { blogKeywords, siteSettings } from "../../db/schema";
|
||||
import { parseForm } from "../../lib/form";
|
||||
import { aiSettingsInput, keywordInput } from "../../schemas";
|
||||
import { generateBlogPost } from "../../lib/ai";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const env = getEnv();
|
||||
const db = getDb(env.DB);
|
||||
|
||||
let error = "";
|
||||
|
||||
function str(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
if (Astro.request.method === "POST") {
|
||||
const form = await Astro.request.formData();
|
||||
const action = str(form, "action");
|
||||
|
||||
if (action === "save-settings") {
|
||||
const parsed = parseForm(form, aiSettingsInput);
|
||||
if (!parsed.ok) {
|
||||
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
|
||||
} else {
|
||||
const now = new Date();
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
const v = String(value ?? "");
|
||||
await db
|
||||
.insert(siteSettings)
|
||||
.values({ key, value: v, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: siteSettings.key, set: { value: v, updatedAt: now } });
|
||||
}
|
||||
return Astro.redirect("/admin/ai?ok=settings");
|
||||
}
|
||||
} else if (action === "add-keyword") {
|
||||
const parsed = parseForm(form, keywordInput);
|
||||
if (!parsed.ok) {
|
||||
error = parsed.errors.keyword ?? "請輸入關鍵字。";
|
||||
} else {
|
||||
await db.insert(blogKeywords).values({
|
||||
id: crypto.randomUUID(),
|
||||
keyword: parsed.data.keyword,
|
||||
status: "pending",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
return Astro.redirect("/admin/ai?ok=keyword");
|
||||
}
|
||||
} else if (action === "delete-keyword") {
|
||||
await db.delete(blogKeywords).where(eq(blogKeywords.id, str(form, "id")));
|
||||
return Astro.redirect("/admin/ai?ok=deleted");
|
||||
} else if (action === "skip-keyword") {
|
||||
await db
|
||||
.update(blogKeywords)
|
||||
.set({ status: "skipped" })
|
||||
.where(eq(blogKeywords.id, str(form, "id")));
|
||||
return Astro.redirect("/admin/ai?ok=skipped");
|
||||
} else if (action === "generate-topic" || action === "generate-next") {
|
||||
const result = await generateBlogPost(
|
||||
db,
|
||||
env,
|
||||
action === "generate-next"
|
||||
? { mode: "next" }
|
||||
: { mode: "topic", topic: str(form, "topic") },
|
||||
);
|
||||
if (result.ok) return Astro.redirect(`/admin/post/${result.postId}`);
|
||||
error = result.error;
|
||||
}
|
||||
}
|
||||
|
||||
const okMessages: Record<string, string> = {
|
||||
settings: "已儲存 AI 設定。",
|
||||
keyword: "已加入關鍵字。",
|
||||
deleted: "已刪除。",
|
||||
skipped: "已標記略過。",
|
||||
};
|
||||
const notice = okMessages[Astro.url.searchParams.get("ok") ?? ""] ?? "";
|
||||
|
||||
const settings = await getSettings(db);
|
||||
const keywords = await getKeywords(db);
|
||||
const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KEY) };
|
||||
---
|
||||
|
||||
<AdminLayout title="AI 生成">
|
||||
<h1>AI 生成 Blog</h1>
|
||||
<p class="sub">設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(生成後會開啟文章編輯頁覆核)。</p>
|
||||
|
||||
{error && <p class="err" style="margin-bottom:16px">{error}</p>}
|
||||
{
|
||||
notice && (
|
||||
<p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">
|
||||
✓ {notice}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="card">
|
||||
<h2>快速生成</h2>
|
||||
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end" onsubmit="this.style.pointerEvents='none'; this.style.opacity='0.65'; if(event.submitter) event.submitter.textContent='生成中…';">
|
||||
<div style="flex:1;min-width:240px">
|
||||
<label for="topic">自由輸入主題</label>
|
||||
<input id="topic" name="topic" type="text" placeholder="例:村屋太陽能回本期點計?" />
|
||||
</div>
|
||||
<button type="submit" name="action" value="generate-topic">生成草稿</button>
|
||||
<button type="submit" name="action" value="generate-next" class="secondary">生成下一篇(佇列)</button>
|
||||
</form>
|
||||
<p class="muted" style="margin:10px 0 0">生成需時約 10–40 秒,請耐心等候,唔好重複提交。</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>AI 設定</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="save-settings" />
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label for="ai_enabled">AI 生成</label>
|
||||
<select id="ai_enabled" name="ai_enabled">
|
||||
<option value="1" selected={settings.ai_enabled !== "0"}>啟用</option>
|
||||
<option value="0" selected={settings.ai_enabled === "0"}>停用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ai_chat_model">模型</label>
|
||||
<input id="ai_chat_model" name="ai_chat_model" type="text" value={settings.ai_chat_model ?? "deepseek-ai/DeepSeek-V3-0324"} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ai_base_url">OpenAI 相容 Base URL</label>
|
||||
<input id="ai_base_url" name="ai_base_url" type="text" value={settings.ai_base_url ?? "https://api.deepinfra.com/v1/openai"} />
|
||||
<p class="muted" style="margin:6px 0 0">⚠️ 只填信任嘅供應商;AI_API_KEY 會傳送去呢個網址。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ai_web_search_enabled">上網研究(Tavily)</label>
|
||||
<select id="ai_web_search_enabled" name="ai_web_search_enabled">
|
||||
<option value="1" selected={settings.ai_web_search_enabled === "1"}>啟用</option>
|
||||
<option value="0" selected={settings.ai_web_search_enabled !== "1"}>停用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ai_web_search_max_results">Tavily 結果數(1–10)</label>
|
||||
<input id="ai_web_search_max_results" name="ai_web_search_max_results" type="text" value={settings.ai_web_search_max_results ?? "5"} />
|
||||
</div>
|
||||
<div>
|
||||
<label>API Key 狀態</label>
|
||||
<p class="muted" style="margin:10px 0 0">
|
||||
AI_API_KEY:{secrets.ai ? "已設定" : "未設定"} · TAVILY_API_KEY:{secrets.tavily ? "已設定" : "未設定"}
|
||||
</p>
|
||||
</div>
|
||||
<div style="grid-column:1/-1">
|
||||
<label for="ai_context_prompt">寫作風格 / 語氣</label>
|
||||
<textarea id="ai_context_prompt" name="ai_context_prompt">{settings.ai_context_prompt ?? ""}</textarea>
|
||||
</div>
|
||||
<div style="grid-column:1/-1">
|
||||
<label for="ai_business_context">公司背景資料(grounding)</label>
|
||||
<textarea id="ai_business_context" name="ai_business_context">{settings.ai_business_context ?? ""}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">儲存 AI 設定</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>關鍵字佇列({keywords.length})</h2>
|
||||
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
|
||||
<input type="hidden" name="action" value="add-keyword" />
|
||||
<div style="flex:1;min-width:240px">
|
||||
<label for="keyword">新增關鍵字 / 主題</label>
|
||||
<input id="keyword" name="keyword" type="text" placeholder="例:太陽能板保養" />
|
||||
</div>
|
||||
<button type="submit" class="secondary">+ 加入</button>
|
||||
</form>
|
||||
|
||||
{
|
||||
keywords.length === 0 ? (
|
||||
<p class="muted" style="margin-top:14px">暫時未有關鍵字。</p>
|
||||
) : (
|
||||
<table style="margin-top:14px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>關鍵字</th>
|
||||
<th style="width:100px">狀態</th>
|
||||
<th style="width:120px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keywords.map((k) => (
|
||||
<tr>
|
||||
<td>{k.keyword}</td>
|
||||
<td>
|
||||
<span class={`badge ${k.status === "generated" ? "published" : "draft"}`}>
|
||||
{k.status === "pending" ? "待生成" : k.status === "generated" ? "已生成" : "已略過"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="id" value={k.id} />
|
||||
{k.status === "pending" && (
|
||||
<button class="secondary mini" name="action" value="skip-keyword">略過</button>
|
||||
)}
|
||||
<button class="danger mini" name="action" value="delete-keyword" onclick="return confirm('確定刪除?')">
|
||||
刪除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
+38
-24
@@ -4,11 +4,17 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
|
||||
import { cases } from "../../db/schema";
|
||||
import { 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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const aiSettingsInput = z.object({
|
||||
ai_enabled: z.enum(["1", "0"]).default("1"),
|
||||
ai_base_url: z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z
|
||||
.url("Base URL 要係有效網址。")
|
||||
.default("https://api.deepinfra.com/v1/openai")
|
||||
.refine((u) => /^https?:\/\//i.test(u), "Base URL 只支援 http/https。"),
|
||||
),
|
||||
ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"),
|
||||
ai_context_prompt: z.string().default(""),
|
||||
ai_business_context: z.string().default(""),
|
||||
ai_web_search_enabled: z.enum(["1", "0"]).default("0"),
|
||||
ai_web_search_max_results: z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "結果數要介乎 1–10。")
|
||||
.max(10, "結果數要介乎 1–10。")
|
||||
.default(5),
|
||||
),
|
||||
});
|
||||
|
||||
export type AiSettingsInput = z.infer<typeof aiSettingsInput>;
|
||||
|
||||
export const aiGeneratedPost = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
content: z.string().trim().min(1),
|
||||
metaDescription: z.string().trim().default(""),
|
||||
excerpt: z.string().trim().default(""),
|
||||
});
|
||||
|
||||
export type AiGeneratedPost = z.infer<typeof aiGeneratedPost>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { postStatus } from "./post";
|
||||
|
||||
export const caseInput = z.object({
|
||||
title: z.string().trim().min(1, "標題唔可以留空。"),
|
||||
location: z.string().trim().default(""),
|
||||
completedAt: z.string().trim().default(""),
|
||||
description: z.string().default(""),
|
||||
imageUrl: z.string().trim().default(""),
|
||||
status: postStatus.default("published"),
|
||||
});
|
||||
|
||||
export type CaseInput = z.infer<typeof caseInput>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { CONTENT_KINDS } from "../db/schema";
|
||||
import { postStatus } from "./post";
|
||||
|
||||
export const contentItemInput = z.object({
|
||||
kind: z.enum(CONTENT_KINDS),
|
||||
title: z.string().trim().min(1, "名稱唔可以留空。"),
|
||||
description: z.string().default(""),
|
||||
extra: z.string().trim().default(""),
|
||||
status: postStatus.default("published"),
|
||||
});
|
||||
|
||||
export type ContentItemInput = z.infer<typeof contentItemInput>;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./post";
|
||||
export * from "./content";
|
||||
export * from "./case";
|
||||
export * from "./settings";
|
||||
export * from "./ai";
|
||||
export * from "./keyword";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { KEYWORD_STATUSES } from "../db/schema";
|
||||
|
||||
export const keywordStatus = z.enum(KEYWORD_STATUSES);
|
||||
|
||||
export const keywordInput = z.object({
|
||||
keyword: z.string().trim().min(1, "關鍵字唔可以留空。"),
|
||||
});
|
||||
|
||||
export type KeywordInput = z.infer<typeof keywordInput>;
|
||||
export type KeywordStatus = z.infer<typeof keywordStatus>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const postStatus = z.enum(["draft", "published"]);
|
||||
|
||||
export const postInput = z.object({
|
||||
title: z.string().trim().min(1, "標題唔可以留空。"),
|
||||
slug: z.string().trim().default(""),
|
||||
excerpt: z.string().trim().default(""),
|
||||
content: z.string().default(""),
|
||||
coverImage: z.string().trim().default(""),
|
||||
tags: z.string().trim().default(""),
|
||||
metaDescription: z.string().trim().default(""),
|
||||
status: postStatus.default("draft"),
|
||||
});
|
||||
|
||||
export type PostInput = z.infer<typeof postInput>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { ALL_SETTING_KEYS } from "../data/settings-fields";
|
||||
|
||||
export const settingsInput = z.object(
|
||||
Object.fromEntries(
|
||||
ALL_SETTING_KEYS.map((key) => [key, z.string().trim().default("")]),
|
||||
),
|
||||
);
|
||||
|
||||
export type SettingsInput = z.infer<typeof settingsInput>;
|
||||
Reference in New Issue
Block a user