feat: 图库双源接入(Pexels+Unsplash,统一 PhotoProvider)与几何预设扩充
- providers.ts: PHOTO_PROVIDERS 注册表,Pexels 在前(dev 速率高);providerById - unsplash.ts: 改造成 PhotoProvider(parseUnsplashResponse/unsplashProvider/ notifyDownload),共享 PhotoProviderError 定义在此叶模块避免注册表互引成环; legacy searchUnsplash/unsplashStatus/toAsset/trackDownload 保留(基座旧 SourcePanel 还在用,删了这轮 typecheck 过不了) - pexels.ts(新): parsePexelsResponse 纯函数 + pexelsProvider;Authorization 头; fullUrl 取 large2x(≈1880)不取原图;无下载统计端点,不实现 notifyDownload - 没配 key 一律静默 missing,search 抛 PhotoProviderError,模块顶层不抛 - geometry.ts: 新增 GEOMETRY_PRESETS 16 条(固定图案+人工配色,新背景菜单卡片 数据源)+ 4 个新图案家族(waves/dots/horizon/glow)渲染,复用线性亮度钳制 保证白字对比度;公开 GeometryKind 不动(旧面板缩略图字典闭合),遗留种子机制 原样;导出 contrastToWhite/relativeLuminance 供自验 - selfcheck: 手写官方形状样本断言两家解析、缺失态/错误类、预设数量与对比度
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
Attribution,
|
||||
BackgroundAsset,
|
||||
PhotoProvider,
|
||||
PhotoProviderStatus,
|
||||
StockPhoto,
|
||||
} from "../types";
|
||||
import { PhotoProviderError } from "./unsplash";
|
||||
|
||||
/**
|
||||
* Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。
|
||||
*
|
||||
* - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: <key>`,
|
||||
* 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。
|
||||
* - 共享错误类 `PhotoProviderError` 定义在 unsplash.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);
|
||||
}
|
||||
Reference in New Issue
Block a user