Files
cover-studio/src/sources/pexels.ts
T
im0o 9b82cdeb0c refactor: 背景图层接进主线
- 预设底纹统一走 sources/geometry 的 GEOMETRY_PRESETS(16 条),
  BackgroundMenu 不再自带一份 12 条的定义。
- 删掉 SourcePanel.vue(内容已搬进 BackgroundMenu),连带清掉 unsplash 里
  只为它保留的 legacy 函数面和 index 的对应出口。
- PhotoProviderError 从 unsplash.ts 挪到独立的 errors.ts。原来放在叶子模块里
  是为了绕开和 providers.ts 的循环依赖,独立文件同样绕得开而且不别扭。
- 背景改成和普通图层一样可点选可拖动:用户对背景的核心需求是「调整/缩放/裁剪
  一下」,不可点选就没法在画布上取景;取消选中点画板外的灰底即可。
- useLayers 把背景钉在最底:它拖不上去,别的图层也压不到它下面。
2026-09-05 18:28:58 +08:00

148 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}