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:
2026-09-05 18:24:11 +08:00
parent 19c8c604cb
commit 83f27d9e90
6 changed files with 960 additions and 83 deletions
+147
View File
@@ -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);
}