29 KiB
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 (
- [x]) 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、原生 Canvas/fetch(後台唔引入 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": [...] 之後(結尾 ] 加逗號)加:
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "yingfung-solar-media"
}
]
完整 wrangler.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" 之後加一行:
"kv:create": "wrangler kv namespace create CACHE",
"r2:create": "wrangler r2 bucket create yingfung-solar-media",
- Step 3:
src/lib/env.ts加MEDIA
AppEnv 改成:
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 = ... 之後)加:
const R2_BUCKET = "yingfung-solar-media";
喺 KV 區塊(} else { console.log("KV id 已設定,略過。"); })之後、const setPassword = await rl.question(...) 之前加:
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
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
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
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
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
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:
- 開
http://localhost:4321/media/nope.webp→ 404。 - 登入
http://localhost:4321/admin後(需要.dev.vars有ADMIN_PASSWORD),用瀏覽器 DevTools console 執行(本機 R2 係 wrangler 模擬):
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 有 response(body 係嗰 4 bytes)。
- Step 5: Commit
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
---
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> 之前加:
<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 { ... } 之前加:
.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
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型別
將第一行改成:
export type FieldType = "text" | "textarea" | "url" | "image";
將 hero_image 同 og_image 兩行改成:
{ key: "hero_image", label: "主視覺圖片", type: "image" },
{ key: "og_image", label: "分享圖片 (OG image)", type: "image" },
- Step 2:
settings.astro加 import 同刪舊邏輯
將 frontmatter 改成:
---
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 改成(其餘不變):
{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
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 區加:
import ImageField from "../../components/admin/ImageField.astro";
import { deleteMedia } from "../../lib/media";
將 const db = getDb(getEnv().DB); 改成:
const env = getEnv();
const db = getDb(env.DB);
- Step 2: save 時比對並刪舊圖
將 if (action === "add" || action === "save") 內嘅 } else {(更新分支)改成:
} 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") { ... } 改成:
} 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
將新增表單內:
<div>
<label for="a-img">圖片 URL</label>
<input id="a-img" name="imageUrl" type="text" placeholder="https://..." />
</div>
改成:
<div>
<ImageField name="imageUrl" label="圖片" scope="cases" id="a-img" />
</div>
- Step 5: 每張卡改用 ImageField 並移除舊預覽
將每張卡內:
<div>
<label>圖片 URL</label>
<input name="imageUrl" type="text" value={item.imageUrl ?? ""} />
</div>
改成:
<div>
<ImageField
name="imageUrl"
label="圖片"
scope="cases"
value={item.imageUrl}
id={`img-${item.id}`}
/>
</div>
再移除舊嘅 standalone 預覽 block(ImageField 已自帶預覽):
{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
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 區加:
import ImageField from "../../../components/admin/ImageField.astro";
import { deleteMedia } from "../../../lib/media";
將 const db = getDb(getEnv().DB); 改成:
const env = getEnv();
const db = getDb(env.DB);
- Step 2: 刪文時刪封面
將:
if (action === "delete" && !isNew) {
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
改成:
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) 之前加:
if (existing!.coverImage !== coverImage) {
await deleteMedia(env, existing!.coverImage);
}
更新分支開頭應該係:
} else {
if (existing!.coverImage !== coverImage) {
await deleteMedia(env, existing!.coverImage);
}
await db
.update(posts)
.set({
slug,
...
- Step 4: 封面欄改用 ImageField
將:
<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://..."
/>
改成:
{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
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 字串處理(暫無上傳)。」改成:
- **圖片**:圖片欄位(settings 的 `hero_image` / `og_image`、`cases.imageUrl`、`posts.coverImage`)用 `components/admin/ImageField.astro`,可揀檔即時上傳去 R2(binding `MEDIA`),亦可貼 URL;client 端縮到最大寬 1600px 並轉 WebP 先上傳。上傳 endpoint `/admin/upload`(受 middleware 保護),出圖 endpoint `/media/[...key]`(公開、`immutable` cache)。換圖/刪除時由 `lib/media.ts` 的 `deleteMedia` 清舊 R2 檔。
喺 Routes & SSR 段「Endpoint」bullet 之後加:
- **圖片 endpoints**:`/admin/upload`(POST,上傳去 R2 `MEDIA`);`/media/[...key]`(GET,讀 R2 出圖,設 `Cache-Control: public, max-age=31536000, immutable` 同 ETag/304)。
- 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 之前加:
- **`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 補一句:
- **圖片欄位**:`FieldType` 有 `"image"`;`hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
- Step 4: 更新根
AGENTS.md
「關鍵慣例與陷阱」內補一條(放喺環境變數 / 資料層附近):
- **圖片上傳**:圖片存 Cloudflare R2(binding `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
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)
逐項確認:
/admin/settings:hero_image、og_image可上傳 + 預覽;儲存後前台首頁主視覺/<meta property="og:image">用新圖(OG 應係絕對 URL)。/admin/cases:新增/編輯案例可上傳;前台「完成案例」顯示新圖。/admin/post/new同既有文章:封面可上傳;Blog 列表/文章頁顯示新封面。- 亂打
/media/xxx→ 404;正常/media/<key>有 response 同Cache-Control: immutable。 - 換圖後,舊
/media/<key>→ 404。 - 貼外部 URL(唔上傳)→ 照樣儲存同顯示。
- 上傳 SVG 或 >8MB 檔 → 顯示中文錯誤、唔會寫入。
- 冇 JS(停用 JavaScript)→ URL 欄仍可手動貼,主表單可儲存。
- Step 3: 確認 git 狀態乾淨
Run: git status --short
Expected: 冇未 commit 嘅改動(或只有預期內嘅檔案)。
Self-Review
Spec coverage:
- 4 個欄位全部支援 → Task 5(settings 2 個)、Task 6(cases)、Task 7(post)。
- Worker
/media/*出圖 → Task 3。 - 瀏覽器端縮圖轉 WebP → Task 4。
- 保留貼 URL → Task 4(ImageField 保留 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 一致。