Add R2 image upload implementation plan

This commit is contained in:
2026-09-12 00:39:48 +08:00
parent cd48930044
commit 47f14b79e3
@@ -0,0 +1,999 @@
# R2 Image Upload Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 後台 4 個圖片欄位(`hero_image``og_image`、案例 `imageUrl`、文章 `coverImage`)支援揀檔即時上傳去 Cloudflare R2,同時保留貼 URL。
**Architecture:** 單一 Worker 加一個 R2 binding `MEDIA``/admin/upload`(受 middleware 保護)收檔寫入 R2`/media/*` 公開讀出。瀏覽器端用 Canvas 縮到最大寬 1600px、轉 WebP 再上傳。舊圖喺換圖/刪除時由 `src/lib/media.ts``deleteMedia` 清理。**唔改 DB schema、唔加 migration。**
**Tech Stack:** Astro 7`output: "static"` + per-page SSR)、Cloudflare Workers + R2、Drizzle/D1、Zod、原生 Canvasfetch(後台唔引入 React)。
**Spec:** `docs/superpowers/specs/2026-09-12-r2-image-upload-design.md`
**重要:呢個專案冇 test / lint script。每個 task 的驗證係 `npm run build`(唯一 build 驗證)+ 有需要時 `npm run dev` 手動測。冇單元測試框架,所以步驟用 build + 手動檢查取代 TDD。**
---
## File Structure
| 檔案 | 責任 |
|---|---|
| `wrangler.jsonc` | R2 binding`MEDIA` |
| `package.json` | `r2:create` script |
| `scripts/setup.mjs` | 一鍵 setup 建 R2 bucket |
| `src/lib/env.ts` | `AppEnv.MEDIA` |
| `src/lib/media.ts`(新) | `/media/` prefix、URL↔key、`deleteMedia` |
| `src/pages/admin/upload.ts`(新) | 上傳 endpoint |
| `src/pages/media/[...key].ts`(新) | 出圖 endpoint |
| `src/components/admin/ImageField.astro`(新) | 圖片欄元件(URL 欄 + file input + 預覽 + 狀態) |
| `src/layouts/AdminLayout.astro` | client 端縮圖/上傳 script + image field 樣式 |
| `src/data/settings-fields.ts` | `FieldType``image`hero/og 改型別 |
| `src/pages/admin/settings.astro` | image 型別用 ImageField + 換圖刪舊 |
| `src/pages/admin/cases.astro` | ImageField + 換圖/刪除清理 |
| `src/pages/admin/post/[id].astro` | ImageField + 換封面/刪文清理 |
**唔郁:** `src/db/schema.ts``migrations/**``src/schemas/*`(圖片仍係字串)。
---
### Task 1: R2 binding、env 型別同 setup 指令
**Files:**
- Modify: `wrangler.jsonc`
- Modify: `package.json`
- Modify: `scripts/setup.mjs`
- Modify: `src/lib/env.ts`
- [ ] **Step 1: `wrangler.jsonc` 加 R2 binding**
`"kv_namespaces": [...]` 之後(結尾 `]` 加逗號)加:
```jsonc
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "yingfung-solar-media"
}
]
```
完整 `wrangler.jsonc` 應該係:
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "yingfung-solar",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-09-10",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page"
},
"observability": {
"enabled": true,
"logs": { "enabled": true }
},
"vars": {
"SITE_NAME": "盈豐太陽能"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "yingfung-solar-db",
"database_id": "PASTE_D1_DATABASE_ID_HERE"
}
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "PASTE_KV_NAMESPACE_ID_HERE"
}
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "yingfung-solar-media"
}
]
}
```
- [ ] **Step 2: `package.json` 加 `r2:create`**
`"kv:create"` 之後加一行:
```json
"kv:create": "wrangler kv namespace create CACHE",
"r2:create": "wrangler r2 bucket create yingfung-solar-media",
```
- [ ] **Step 3: `src/lib/env.ts` 加 `MEDIA`**
`AppEnv` 改成:
```ts
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
MEDIA: R2Bucket;
ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
};
```
- [ ] **Step 4: `scripts/setup.mjs` 加建 R2 bucket 步驟**
喺檔案上方 constant 區(`const KV_PLACEHOLDER = ...` 之後)加:
```js
const R2_BUCKET = "yingfung-solar-media";
```
喺 KV 區塊(`} else { console.log("KV id 已設定,略過。"); }`)之後、`const setPassword = await rl.question(...)` 之前加:
```js
console.log(`檢查 / 建立 R2 bucket「${R2_BUCKET}」…`);
const r2List = runSoft("npx wrangler r2 bucket list");
const r2Text = `${r2List.out || ""}\n${r2List.err || ""}`;
if (new RegExp(`\\b${R2_BUCKET}\\b`).test(r2Text)) {
console.log(" R2 bucket 已存在,略過。");
} else {
const r2 = runSoft(`npx wrangler r2 bucket create ${R2_BUCKET}`);
if (r2.ok) {
console.log(" R2 bucket 已建立。");
} else {
console.log(" 建立 R2 bucket 失敗(可能未開通 R2),請稍後手動執行: npm run r2:create");
console.log(r2.out || r2.err || "");
}
}
```
- [ ] **Step 5: 重新產生 binding 型別**
Run: `npm run types`
Expected: 成功,`worker-configuration.d.ts``MEDIA: R2Bucket;` 出現喺 `Env`
Run: `rg -n "MEDIA: R2Bucket" worker-configuration.d.ts`
Expected: 有一行 match(若 `wrangler types` 讀唔到 R2 binding,手動加都唔會影響 build,但正常會出)。
- [ ] **Step 6: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 7: Commit**
```bash
git add wrangler.jsonc package.json scripts/setup.mjs src/lib/env.ts worker-configuration.d.ts
git commit -m "Add R2 MEDIA binding and setup script"
```
---
### Task 2: `src/lib/media.ts` 媒體工具
**Files:**
- Create: `src/lib/media.ts`
- [ ] **Step 1: 建立 `src/lib/media.ts`**
```ts
import type { AppEnv } from "./env";
export const MEDIA_PREFIX = "/media/";
/** 由 R2 key 砌出對外 URL。 */
export function mediaUrl(key: string): string {
return `${MEDIA_PREFIX}${key}`;
}
/** 由 /media/... URL 取出 R2 key;唔係 /media/ 開頭回 null。 */
export function mediaKey(url: string | null | undefined): string | null {
if (!url || !url.startsWith(MEDIA_PREFIX)) return null;
return url.slice(MEDIA_PREFIX.length);
}
/** 刪除 /media/... 對應嘅 R2 物件;外部 URL 或空值唔理,唔會 throw。 */
export async function deleteMedia(
env: AppEnv,
url: string | null | undefined,
): Promise<void> {
const key = mediaKey(url);
if (!key) return;
try {
await env.MEDIA.delete(key);
} catch {
// 刪除失敗唔應該阻礙儲存流程。
}
}
```
- [ ] **Step 2: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 3: Commit**
```bash
git add src/lib/media.ts
git commit -m "Add media helper for R2 keys and cleanup"
```
---
### Task 3: 上傳同出圖 endpoints
**Files:**
- Create: `src/pages/admin/upload.ts`
- Create: `src/pages/media/[...key].ts`
- [ ] **Step 1: 建立 `src/pages/admin/upload.ts`**
```ts
import type { APIRoute } from "astro";
import { getEnv } from "../../lib/env";
import { mediaUrl } from "../../lib/media";
export const prerender = false;
const SCOPES = new Set(["settings", "cases", "posts"]);
const ALLOWED: Record<string, string> = {
"image/webp": "webp",
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
};
const MAX_BYTES = 8 * 1024 * 1024;
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
export const POST: APIRoute = async ({ request }) => {
let form: FormData;
try {
form = await request.formData();
} catch {
return json({ ok: false, error: "無法讀取上傳內容。" }, 400);
}
const file = form.get("file");
const scope = String(form.get("scope") ?? "");
if (!(file instanceof File) || file.size === 0) {
return json({ ok: false, error: "請揀選圖片檔案。" }, 400);
}
if (!SCOPES.has(scope)) {
return json({ ok: false, error: "上傳目標唔正確。" }, 400);
}
const ext = ALLOWED[file.type];
if (!ext) {
return json({ ok: false, error: "只支援 JPG / PNG / WebP / GIF 圖片。" }, 400);
}
if (file.size > MAX_BYTES) {
return json({ ok: false, error: "圖片太大(上限 8MB)。" }, 400);
}
const key = `${scope}/${crypto.randomUUID()}.${ext}`;
try {
await getEnv().MEDIA.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
});
} catch {
return json({ ok: false, error: "上傳失敗,請稍後再試。" }, 500);
}
return json({ ok: true, url: mediaUrl(key) });
};
```
- [ ] **Step 2: 建立 `src/pages/media/[...key].ts`**
```ts
import type { APIRoute } from "astro";
import { getEnv } from "../../lib/env";
export const prerender = false;
export const GET: APIRoute = async ({ params, request }) => {
const key = params.key ?? "";
if (!key) return new Response("Not found", { status: 404 });
const obj = await getEnv().MEDIA.get(key);
if (!obj) return new Response("Not found", { status: 404 });
const etag = obj.httpEtag;
if (etag && request.headers.get("if-none-match") === etag) {
return new Response(null, { status: 304, headers: { ETag: etag } });
}
const headers = new Headers();
headers.set("Content-Type", obj.httpMetadata?.contentType ?? "application/octet-stream");
headers.set("Cache-Control", "public, max-age=31536000, immutable");
if (etag) headers.set("ETag", etag);
return new Response(obj.body, { headers });
};
```
- [ ] **Step 3: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error,而且 `dist/` 唔會 build 出 `/media` 靜態頁(endpoint 係 SSR)。
- [ ] **Step 4: 手動測試 endpoints**
Run: `npm run dev`(另一個 terminal
Expected:
1.`http://localhost:4321/media/nope.webp` → 404。
2. 登入 `http://localhost:4321/admin` 後(需要 `.dev.vars``ADMIN_PASSWORD`),用瀏覽器 DevTools console 執行(本機 R2 係 wrangler 模擬):
```js
const fd = new FormData();
fd.append("file", new File([new Uint8Array([82,73,70,70])], "x.webp", { type: "image/webp" }));
fd.append("scope", "cases");
console.log(await (await fetch("/admin/upload", { method: "POST", body: fd })).json());
```
Expected: `{ ok: true, url: "/media/cases/<uuid>.webp" }`,跟住開嗰個 url 有 responsebody 係嗰 4 bytes)。
- [ ] **Step 5: Commit**
```bash
git add src/pages/admin/upload.ts "src/pages/media/[...key].ts"
git commit -m "Add R2 upload and media serving endpoints"
```
---
### Task 4: `ImageField` 元件同後台 client script
**Files:**
- Create: `src/components/admin/ImageField.astro`
- Modify: `src/layouts/AdminLayout.astro`
- [ ] **Step 1: 建立 `src/components/admin/ImageField.astro`**
```astro
---
interface Props {
name: string;
label: string;
value?: string | null;
scope: "settings" | "cases" | "posts";
id?: string;
}
const { name, label, value = "", scope, id = name } = Astro.props;
---
<div class="image-field" data-image-field data-scope={scope}>
<label for={id}>{label}</label>
<input
id={id}
name={name}
type="text"
value={value ?? ""}
placeholder="https://... 或按下面上傳"
class="image-url"
/>
<div class="image-actions">
<input type="file" accept="image/*" data-image-input />
<span class="image-status" data-image-status></span>
</div>
<img class="image-preview" data-image-preview src={value || undefined} alt="" hidden={!value} />
</div>
```
- [ ] **Step 2: `AdminLayout.astro` 加 client script**
`</main>` 之後、`</body>` 之前加:
```astro
<script>
const MAX_WIDTH = 1600;
const QUALITY = 0.85;
async function toOptimised(file: File): Promise<Blob> {
if (!("createImageBitmap" in window)) return file;
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_WIDTH / bitmap.width);
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0, width, height);
if (typeof bitmap.close === "function") bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/webp", QUALITY),
);
return blob ?? file;
} catch {
return file;
}
}
async function upload(field: HTMLElement, file: File): Promise<void> {
const scope = field.dataset.scope ?? "";
const input = field.querySelector<HTMLInputElement>(".image-url");
const preview = field.querySelector<HTMLImageElement>(".image-preview");
const status = field.querySelector<HTMLElement>(".image-status");
if (!input || !status) return;
status.textContent = "上傳中…";
const blob = await toOptimised(file);
const body = new FormData();
body.append("file", new File([blob], file.name, { type: blob.type }));
body.append("scope", scope);
try {
const res = await fetch("/admin/upload", { method: "POST", body });
const data = (await res.json()) as { ok: boolean; url?: string; error?: string };
if (!res.ok || !data.ok || !data.url) {
status.textContent = data.error ?? "上傳失敗。";
return;
}
input.value = data.url;
if (preview) {
preview.src = data.url;
preview.hidden = false;
}
status.textContent = "✓ 已上傳";
} catch {
status.textContent = "上傳失敗,請檢查網絡。";
}
}
document.querySelectorAll<HTMLElement>("[data-image-field]").forEach((field) => {
const fileInput = field.querySelector<HTMLInputElement>("[data-image-input]");
if (!fileInput) return;
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) void upload(field, file);
});
});
</script>
```
- [ ] **Step 3: `AdminLayout.astro` 加樣式**
`<style is:global>``.row { ... }` 之前加:
```css
.image-field { display: block; }
.image-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.image-actions input[type="file"] { font: inherit; font-size: 13px; max-width: 100%; }
.image-status { font-size: 12.5px; font-weight: 600; color: #0b8a5e; }
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; }
.image-preview[hidden] { display: none; }
```
- [ ] **Step 4: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 5: Commit**
```bash
git add src/components/admin/ImageField.astro src/layouts/AdminLayout.astro
git commit -m "Add ImageField component and client-side upload script"
```
---
### Task 5: 網站設定圖片欄位(含換圖刪舊)
**Files:**
- Modify: `src/data/settings-fields.ts`
- Modify: `src/pages/admin/settings.astro`
- [ ] **Step 1: `settings-fields.ts` 加 `image` 型別**
將第一行改成:
```ts
export type FieldType = "text" | "textarea" | "url" | "image";
```
`hero_image``og_image` 兩行改成:
```ts
{ key: "hero_image", label: "主視覺圖片", type: "image" },
```
```ts
{ key: "og_image", label: "分享圖片 (OG image)", type: "image" },
```
- [ ] **Step 2: `settings.astro` 加 import 同刪舊邏輯**
將 frontmatter 改成:
```astro
---
import AdminLayout from "../../layouts/AdminLayout.astro";
import ImageField from "../../components/admin/ImageField.astro";
import { ALL_SETTING_KEYS, SETTINGS_GROUPS } from "../../data/settings-fields";
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 { deleteMedia } from "../../lib/media";
import { settingsInput } from "../../schemas";
export const prerender = false;
const env = getEnv();
const db = getDb(env.DB);
const IMAGE_SETTING_KEYS = new Set(
SETTINGS_GROUPS.flatMap((g) =>
g.fields.filter((f) => f.type === "image").map((f) => f.key),
),
);
let saved = false;
let error = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const parsed = parseForm(form, settingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const before = await getSettings(db);
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] ?? "");
if (IMAGE_SETTING_KEYS.has(key) && before[key] !== value) {
await deleteMedia(env, before[key]);
}
await db
.insert(siteSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
}
saved = true;
}
}
const settings = await getSettings(db);
---
```
- [ ] **Step 3: `settings.astro` 欄位 render 加 ImageField**
將 template 內 `SETTINGS_GROUPS.map((group) => ( ... ))` 的欄位 map 改成(其餘不變):
```astro
{group.fields.map((f) => (
<div style={f.type === "textarea" ? "grid-column:1/-1" : ""}>
{f.type === "image" ? (
<ImageField
name={f.key}
label={f.label}
value={settings[f.key] ?? ""}
scope="settings"
id={f.key}
/>
) : (
<>
<label for={f.key}>{f.label}</label>
{f.type === "textarea" ? (
<textarea id={f.key} name={f.key} placeholder={f.placeholder}>
{settings[f.key] ?? ""}
</textarea>
) : (
<input
id={f.key}
name={f.key}
type="text"
value={settings[f.key] ?? ""}
placeholder={f.placeholder}
/>
)}
</>
)}
</div>
))}
```
- [ ] **Step 4: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 5: 手動測試**
Run: `npm run dev`
Expected: `/admin/settings` 的「首頁 Hero」同「SEO 設定」入面,主視覺圖片同 OG 圖變咗有檔案選擇 + 預覽;上傳後 URL 欄填 `/media/settings/<uuid>.webp`;儲存後前台首頁圖片更新;再換一張,舊 `/media/...` 變 404。
- [ ] **Step 6: Commit**
```bash
git add src/data/settings-fields.ts src/pages/admin/settings.astro
git commit -m "Add image upload to site settings with old-file cleanup"
```
---
### Task 6: 完成案例圖片上傳
**Files:**
- Modify: `src/pages/admin/cases.astro`
- [ ] **Step 1: 加 import 同 `env`**
喺 import 區加:
```astro
import ImageField from "../../components/admin/ImageField.astro";
import { deleteMedia } from "../../lib/media";
```
`const db = getDb(getEnv().DB);` 改成:
```astro
const env = getEnv();
const db = getDb(env.DB);
```
- [ ] **Step 2: save 時比對並刪舊圖**
`if (action === "add" || action === "save")` 內嘅 `} else {`(更新分支)改成:
```astro
} else {
const id = str(form, "id");
const newImage = parsed.data.imageUrl || null;
const [current] = await db.select().from(cases).where(eq(cases.id, id)).limit(1);
if (current && current.imageUrl !== newImage) {
await deleteMedia(env, current.imageUrl);
}
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: newImage,
status: parsed.data.status,
updatedAt: new Date(),
})
.where(eq(cases.id, id));
}
```
- [ ] **Step 3: delete 時刪圖**
`} else if (action === "delete") { ... }` 改成:
```astro
} else if (action === "delete") {
const id = str(form, "id");
const [current] = await db.select().from(cases).where(eq(cases.id, id)).limit(1);
await deleteMedia(env, current?.imageUrl);
await db.delete(cases).where(eq(cases.id, id));
}
```
- [ ] **Step 4: 新增表單改用 ImageField**
將新增表單內:
```astro
<div>
<label for="a-img">圖片 URL</label>
<input id="a-img" name="imageUrl" type="text" placeholder="https://..." />
</div>
```
改成:
```astro
<div>
<ImageField name="imageUrl" label="圖片" scope="cases" id="a-img" />
</div>
```
- [ ] **Step 5: 每張卡改用 ImageField 並移除舊預覽**
將每張卡內:
```astro
<div>
<label>圖片 URL</label>
<input name="imageUrl" type="text" value={item.imageUrl ?? ""} />
</div>
```
改成:
```astro
<div>
<ImageField
name="imageUrl"
label="圖片"
scope="cases"
value={item.imageUrl}
id={`img-${item.id}`}
/>
</div>
```
再移除舊嘅 standalone 預覽 blockImageField 已自帶預覽):
```astro
{item.imageUrl && (
<img src={item.imageUrl} alt={item.title} style="margin-top:14px;max-width:220px;border-radius:10px;" />
)}
```
- [ ] **Step 6: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 7: 手動測試**
Run: `npm run dev`
Expected: `/admin/cases` 新增同每張卡都有上傳;上傳後前台「完成案例」顯示新圖;換圖後舊圖 404;刪除案例後其圖 404。
- [ ] **Step 8: Commit**
```bash
git add src/pages/admin/cases.astro
git commit -m "Add image upload to cases with cleanup"
```
---
### Task 7: 文章封面上傳
**Files:**
- Modify: `src/pages/admin/post/[id].astro`
- [ ] **Step 1: 加 import 同 `env`**
喺 import 區加:
```astro
import ImageField from "../../../components/admin/ImageField.astro";
import { deleteMedia } from "../../../lib/media";
```
`const db = getDb(getEnv().DB);` 改成:
```astro
const env = getEnv();
const db = getDb(env.DB);
```
- [ ] **Step 2: 刪文時刪封面**
將:
```astro
if (action === "delete" && !isNew) {
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
```
改成:
```astro
if (action === "delete" && !isNew) {
await deleteMedia(env, existing?.coverImage);
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
```
- [ ] **Step 3: 更新時比對並刪舊封面**
`} else {`(更新分支)內、`await db.update(posts)` 之前加:
```astro
if (existing!.coverImage !== coverImage) {
await deleteMedia(env, existing!.coverImage);
}
```
更新分支開頭應該係:
```astro
} else {
if (existing!.coverImage !== coverImage) {
await deleteMedia(env, existing!.coverImage);
}
await db
.update(posts)
.set({
slug,
...
```
- [ ] **Step 4: 封面欄改用 ImageField**
將:
```astro
<label for="coverImage">封面圖片 URL(列表卡片用)</label>
{errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
<input
id="coverImage"
name="coverImage"
type="text"
value={post.coverImage ?? ""}
placeholder="https://..."
/>
```
改成:
```astro
{errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
<ImageField
name="coverImage"
label="封面圖片(列表卡片用)"
scope="posts"
value={post.coverImage}
id="coverImage"
/>
```
- [ ] **Step 5: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 6: 手動測試**
Run: `npm run dev`
Expected: `/admin/post/new` 同上傳封面;發布後 Blog 列表/文章頁顯示新封面;換封面舊圖 404;刪文封面 404。
- [ ] **Step 7: Commit**
```bash
git add "src/pages/admin/post/[id].astro"
git commit -m "Add cover image upload to blog posts with cleanup"
```
---
### Task 8: DOX 文件更新
**Files:**
- Modify: `src/AGENTS.md`
- Modify: `src/lib/AGENTS.md`
- Modify: `src/data/AGENTS.md`
- Modify: `AGENTS.md`
- [ ] **Step 1: 更新 `src/AGENTS.md`**
Admin 段「- **圖片**:一律以 URL 字串處理(暫無上傳)。」改成:
```markdown
- **圖片**:圖片欄位(settings 的 `hero_image` / `og_image``cases.imageUrl``posts.coverImage`)用 `components/admin/ImageField.astro`,可揀檔即時上傳去 R2(binding `MEDIA`),亦可貼 URLclient 端縮到最大寬 1600px 並轉 WebP 先上傳。上傳 endpoint `/admin/upload`(受 middleware 保護),出圖 endpoint `/media/[...key]`(公開、`immutable` cache)。換圖/刪除時由 `lib/media.ts``deleteMedia` 清舊 R2 檔。
```
喺 Routes & SSR 段「**Endpoint**」bullet 之後加:
```markdown
- **圖片 endpoints**`/admin/upload`POST,上傳去 R2 `MEDIA`);`/media/[...key]`GET,讀 R2 出圖,設 `Cache-Control: public, max-age=31536000, immutable` 同 ETag304)。
```
- [ ] **Step 2: 更新 `src/lib/AGENTS.md`**
Ownership 段改成「擁有 `src/lib/` 七個檔案:`env.ts``db.ts``auth.ts``form.ts``media.ts``ai.ts``markdown.ts`。」
`env.ts` bullet 的 `AppEnv` 清單加 `MEDIA``AppEnv``DB``CACHE``MEDIA``ADMIN_PASSWORD?`…))。
`markdown.ts` bullet 之前加:
```markdown
- **`media.ts`**`MEDIA_PREFIX``/media/`)、`mediaUrl(key)``mediaKey(url)``deleteMedia(env, url)``deleteMedia` 只處理 `/media/` 開頭嘅值,外部 URL/空值唔理、唔 throw。
```
- [ ] **Step 3: 更新 `src/data/AGENTS.md`**
`settings-fields.ts` 相關:Local Contracts 內「**設定欄位**」bullet 補一句:
```markdown
- **圖片欄位**`FieldType``"image"``hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
```
- [ ] **Step 4: 更新根 `AGENTS.md`**
「關鍵慣例與陷阱」內補一條(放喺環境變數 / 資料層附近):
```markdown
- **圖片上傳**:圖片存 Cloudflare R2binding `MEDIA`、bucket `yingfung-solar-media`),由同一個 Worker 的 `/media/*` 出圖;上傳經 `/admin/upload`client 端先縮圖轉 WebP。本機靠 `platformProxy` 模擬 R2,雲端要先 `npm run r2:create`。圖片欄位仍可貼 URL;換圖/刪除時 `lib/media.ts` 會清舊 R2 檔。
```
`scripts/` 是 project-level files owned by root,唔使另加 Child DOX。
- [ ] **Step 5: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [ ] **Step 6: Commit**
```bash
git add AGENTS.md src/AGENTS.md src/lib/AGENTS.md src/data/AGENTS.md
git commit -m "Update DOX docs for R2 image upload"
```
---
### Task 9: 最終端到端驗證
**Files:** 無(只驗證)
- [ ] **Step 1: 全量 build**
Run: `npm run build`
Expected: 成功。
- [ ] **Step 2: 端到端手動檢查(`npm run dev`**
逐項確認:
1. `/admin/settings``hero_image``og_image` 可上傳 + 預覽;儲存後前台首頁主視覺/`<meta property="og:image">` 用新圖(OG 應係絕對 URL)。
2. `/admin/cases`:新增/編輯案例可上傳;前台「完成案例」顯示新圖。
3. `/admin/post/new` 同既有文章:封面可上傳;Blog 列表/文章頁顯示新封面。
4. 亂打 `/media/xxx` → 404;正常 `/media/<key>` 有 response 同 `Cache-Control: immutable`
5. 換圖後,舊 `/media/<key>` → 404。
6. 貼外部 URL(唔上傳)→ 照樣儲存同顯示。
7. 上傳 SVG 或 >8MB 檔 → 顯示中文錯誤、唔會寫入。
8. 冇 JS(停用 JavaScript)→ URL 欄仍可手動貼,主表單可儲存。
- [ ] **Step 3: 確認 git 狀態乾淨**
Run: `git status --short`
Expected: 冇未 commit 嘅改動(或只有預期內嘅檔案)。
---
## Self-Review
**Spec coverage:**
- 4 個欄位全部支援 → Task 5settings 2 個)、Task 6cases)、Task 7post)。
- Worker `/media/*` 出圖 → Task 3。
- 瀏覽器端縮圖轉 WebP → Task 4。
- 保留貼 URL → Task 4ImageField 保留 text input)+ Task 9 step 2.8。
- 自動刪舊圖 → Task 2 `deleteMedia`、Task 5/6/7 呼叫。
- 拒絕 SVG / 8MB 上限 → Task 3。
- R2 binding / env / setup / r2:create → Task 1。
- 冇 UI migration → 冇 DB task,正確。
- DOX 更新 → Task 8。
**Placeholder scan:** 冇 TBD/「add appropriate…」;每個 code step 都有完整內容。
**Type consistency:** `deleteMedia(env, url)``mediaUrl(key)``mediaKey(url)``ImageField` props`name` / `label` / `value` / `scope` / `id`)喺所有 task 一致;`scope` 值只用 `settings` / `cases` / `posts`,同 endpoint `SCOPES` 一致。