- 预设底纹统一走 sources/geometry 的 GEOMETRY_PRESETS(16 条), BackgroundMenu 不再自带一份 12 条的定义。 - 删掉 SourcePanel.vue(内容已搬进 BackgroundMenu),连带清掉 unsplash 里 只为它保留的 legacy 函数面和 index 的对应出口。 - PhotoProviderError 从 unsplash.ts 挪到独立的 errors.ts。原来放在叶子模块里 是为了绕开和 providers.ts 的循环依赖,独立文件同样绕得开而且不别扭。 - 背景改成和普通图层一样可点选可拖动:用户对背景的核心需求是「调整/缩放/裁剪 一下」,不可点选就没法在画布上取景;取消选中点画板外的灰底即可。 - useLayers 把背景钉在最底:它拖不上去,别的图层也压不到它下面。
148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
import type {
|
||
Attribution,
|
||
BackgroundAsset,
|
||
PhotoProvider,
|
||
PhotoProviderStatus,
|
||
StockPhoto,
|
||
} from "../types";
|
||
import { PhotoProviderError } from "./errors";
|
||
|
||
/**
|
||
* Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。
|
||
*
|
||
* - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: <key>`,
|
||
* 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。
|
||
* - 共享错误类 `PhotoProviderError` 在 `errors.ts`(不依赖任何兄弟模块的叶子),
|
||
* 从那里 import,避免和 providers.ts 的注册表互相引成环。
|
||
* - **没配 key 时不崩**:静默处于 "missing" 态;`search()` 抛 `PhotoProviderError`。
|
||
* - 速率配额比 Unsplash demo key 高得多(200/时 vs 50/时),dev 期主用这家,
|
||
* 所以 `PHOTO_PROVIDERS` 里它排在前面(providers.ts 组装)。
|
||
* - Pexels 的署名是「建议」而非「强制」,也没有下载统计端点——
|
||
* `notifyDownload` 不实现(接口里它是可选方法,调用方无条件调即可)。
|
||
*/
|
||
|
||
/** 纯函数解析:按官方文档形状构造的响应样本 → 归一化 StockPhoto[]。Node 侧可断言。 */
|
||
export function parsePexelsResponse(json: unknown): StockPhoto[] {
|
||
const root = toRecord(json);
|
||
if (!root || !Array.isArray(root.photos)) return [];
|
||
const out: StockPhoto[] = [];
|
||
for (const item of root.photos) {
|
||
const photo = toStockPhoto(toRecord(item));
|
||
if (photo) out.push(photo);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export const pexelsProvider: PhotoProvider = {
|
||
id: "pexels",
|
||
label: "Pexels",
|
||
keyUrl: "https://www.pexels.com/api/new/",
|
||
status(): PhotoProviderStatus {
|
||
return readKey() ? "ready" : "missing";
|
||
},
|
||
search: async (query, page) => {
|
||
const key = readKey();
|
||
if (!key) throw new PhotoProviderError("未配置 Pexels API Key(VITE_PEXELS_KEY)");
|
||
const q = query.trim();
|
||
if (!q) return [];
|
||
return fetchPexels(q, page, key);
|
||
},
|
||
toAsset: toStockAsset,
|
||
};
|
||
|
||
/** 面板能直接用:一条归一化结果 → 一份可进画布的 BackgroundAsset。 */
|
||
function toStockAsset(photo: StockPhoto): BackgroundAsset {
|
||
const attribution: Attribution = {
|
||
text: `Photo by ${photo.authorName} on Pexels`,
|
||
url: photo.pageUrl,
|
||
};
|
||
return {
|
||
kind: "stock",
|
||
src: photo.fullUrl,
|
||
width: photo.width,
|
||
height: photo.height,
|
||
attribution,
|
||
};
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 内部 */
|
||
|
||
function readKey(): string {
|
||
// import.meta.env 只保证在 Vite 里存在。用可选链兜底,Node/Bun/静态产物都安全。
|
||
const meta = import.meta as { env?: { VITE_PEXELS_KEY?: unknown } };
|
||
const raw = meta.env?.VITE_PEXELS_KEY;
|
||
return typeof raw === "string" ? raw.trim() : "";
|
||
}
|
||
|
||
function toRecord(v: unknown): Record<string, unknown> | null {
|
||
return v && typeof v === "object" && !Array.isArray(v)
|
||
? (v as Record<string, unknown>)
|
||
: null;
|
||
}
|
||
|
||
// 字段照官方文档形状防御式取值;界面用不到的字段(photographer_url / avg_color /
|
||
// liked / src.portrait|landscape|tiny 等)一律不解析——不确定的字段不编造。
|
||
/** 一张 Pexels 原始对象 → StockPhoto。缺 id / 图片地址的结果没法用,返回 null。 */
|
||
function toStockPhoto(raw: Record<string, unknown> | null): StockPhoto | null {
|
||
if (!raw) return null;
|
||
const id = typeof raw.id === "number" ? String(raw.id) : undefined;
|
||
const url = typeof raw.url === "string" ? raw.url : undefined;
|
||
const photographer = typeof raw.photographer === "string" ? raw.photographer : "";
|
||
const width = typeof raw.width === "number" ? raw.width : 0;
|
||
const height = typeof raw.height === "number" ? raw.height : 0;
|
||
const alt = typeof raw.alt === "string" ? raw.alt.trim() || null : null;
|
||
const src = toRecord(raw.src);
|
||
const s = (k: string): string => {
|
||
const v = src?.[k];
|
||
return typeof v === "string" ? v : "";
|
||
};
|
||
if (!id || !url) return null;
|
||
const original = s("original");
|
||
const large2x = s("large2x");
|
||
const large = s("large");
|
||
const medium = s("medium");
|
||
const small = s("small");
|
||
if (!large2x && !large && !original) return null; // 一张图连大图档都没有,没法装进画布
|
||
return {
|
||
id,
|
||
provider: "pexels",
|
||
width,
|
||
height,
|
||
thumbUrl: medium || small || large2x || large,
|
||
// 装进画布用约 1600px 的档,不要原图(原图动辄 6000px)。
|
||
// Pexels 的档位是宽档:large2x ≈ 1880px,large ≈ 940px,按 brief 优先 large2x。
|
||
fullUrl: large2x || large || original,
|
||
pageUrl: url,
|
||
authorName: photographer || "Pexels 用户",
|
||
alt,
|
||
// Pexels 没有下载统计端点,downloadLocation 不填。
|
||
};
|
||
}
|
||
|
||
/** 实际请求。错误路径统一抛 PhotoProviderError。 */
|
||
async function fetchPexels(query: string, page: number, key: string): Promise<StockPhoto[]> {
|
||
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&page=${page}&per_page=15`;
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(url, { headers: { Authorization: key } });
|
||
} catch {
|
||
throw new PhotoProviderError("连不上 Pexels(网络或跨域限制)");
|
||
}
|
||
if (!res.ok) {
|
||
if (res.status === 401) throw new PhotoProviderError("Pexels API Key 无效,检查 .env.local");
|
||
if (res.status === 403 || res.status === 429)
|
||
throw new PhotoProviderError("Pexels 请求被限流,等一会儿再试");
|
||
// 非预期状态:响应体里可能有官方 error 字段,能读就读出来给用户看。
|
||
let detail = "";
|
||
try {
|
||
const body = toRecord((await res.json()) as unknown);
|
||
detail = typeof body?.error === "string" ? `:${body.error}` : "";
|
||
} catch {
|
||
/* 读不出就算了,用状态码兜底 */
|
||
}
|
||
throw new PhotoProviderError(`Pexels 返回 ${res.status}${detail}`);
|
||
}
|
||
const data = (await res.json()) as unknown;
|
||
return parsePexelsResponse(data);
|
||
}
|