项目:PEXELS 壁纸应用 | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
本项目没有自建后端,"接口设计"即对 Pexels 开放 API 的集成设计。涉及两个文件:src/utils/pexels.ts(协议层:PexelsClient 类、类型定义、归一化、降级数据生成)与 src/api/pexels.ts(门面层:参数钳制、降级决策、错误分级、total 截断)。
认证方式:请求头 Authorization: <API_KEY>,无 Bearer 前缀(src/utils/pexels.ts 文件头注释明确强调这一点)。走代理模式(baseUrl 非官方地址)时不发该头,Key 由代理服务端注入。
Base URL 与双通道:官方地址 https://api.pexels.com/v1。src/api/pexels.ts 计算通道优先级:
PROXY_BASE = import.meta.env.VITE_PEXELS_PROXY_BASE
|| (import.meta.env.DEV ? '/api/pexels' : '')即:显式配置了 VITE_PEXELS_PROXY_BASE 就走该代理(任何环境);否则开发环境自动走 Vite 的 /api/pexels 代理(vite.config.ts 的 server.proxy 转发到 https://api.pexels.com/v1,changeOrigin: true,rewrite 去掉前缀);生产环境未配置则直连官方地址。客户端实例是模块级单例:API_KEY || PROXY_BASE 都没有时 client = null,所有接口直接走降级分支,连请求都不发(注释:避免无谓的错误提示)。
请求与错误封装:PexelsClient.request<T> 私有方法统一拼参(buildParams 过滤 undefined 与空串)、带鉴权头、fetch 后按状态码分级抛错——401 抛 PexelsApiError(401, 'API Key 无效,请重新设置');429 抛 PexelsApiError(429, 'Pexels 请求过于频繁,请稍后再试');其余抛通用消息。PexelsApiError 继承 Error 并携带 status 字段,供上层 isAuthOrLimitError(401/403/429 判定)与封面调度器(429 判定)使用。
参数钳制约定:所有列表接口 page 下限 1、perPage 钳制到 [1, 80](Pexels 单页上限 80),在协议层(search/curated 等)与门面层(getPexelsList 等)各做一次,双重防御。
PexelsClient 共封装 9 个方法,对应 8 个端点(视频详情的官方路径是特殊的 /videos/videos/:id,源码注释特别标注"注意官方路径"):
| 客户端方法 | HTTP 端点 | 业务用途 | 关键参数 | 响应要点(归一化后) |
|---|---|---|---|---|
search(query, opts) | GET /v1/search | 首页按分类/关键词搜图 | query、page、per_page、orientation(landscape/portrait/square)、color(12 色名,本项目未启用 UI) | SearchResult{items: NormalizedWallpaper[], totalPages, total=total_results} |
curated(opts) | GET /v1/curated | 「全部」分类的官方精选图 | page、per_page | 同 search |
getById(id) | GET /v1/photos/:id | /pins/:id 详情直达 | 路径参数 id | 单个 NormalizedWallpaper |
videosSearch(query, opts) | GET /v1/videos/search | 视频搜索 | query、page、per_page、orientation、size(large/medium/small)、locale | VideoSearchResult{items: NormalizedVideo[], totalPages, total} |
videosPopular(opts) | GET /v1/videos/popular | 视频页默认热门流 | page、per_page、min_width、min_height、min_duration、max_duration(后四者本项目未启用 UI) | 同 videosSearch |
videoById(id) | GET /v1/videos/videos/:id | /videos/:id 详情直达 | 路径参数 id | 单个 NormalizedVideo |
collectionsFeatured(opts) | GET /v1/collections/featured | 合集页「特色合集」Tab;合集详情页查元信息 | page、per_page | CollectionResult{items: NormalizedCollection[], totalPages, total} |
myCollections(opts) | GET /v1/collections | 合集页「我的合集」Tab(需 Pexels 账号登录态,只读) | page、per_page | 同上;未授权时前端直接返回空数组 |
collectionMedia(id, opts) | GET /v1/collections/:id | 合集详情页媒体流;封面调度器取首图 | 路径参数 id;page、per_page、type(photos/videos)、sort(asc/desc) | `CollectionMediaResult{items: (照片 |
归一化映射(协议层私有方法 normalizePhoto / normalizeVideo / normalizeCollection):
id → String(id);展示图 url = src.large2x || src.large || src.original;缩略 thumb = src.medium || src.small;描述 description = alt || "壁纸 {id}";平均色 avgColor、作者三件套(author/authorUrl/authorId)、原始 src 八档尺寸全量透传。id → String(id);videoFiles/videoPictures 兜底为空数组;作者收拢为 user{id,name,url}。mediaCount/photosCount/videosCount,description 空值兜底空串,private 强制布尔;cover 字段留空,由调用方按需填充(协议层不做 N+1)。type === 'Video' 分派到视频或照片归一化,再打上 type: 'video'|'photo' 小写标签,得到联合类型 NormalizedCollectionMediaItem。三个协议层行为细节值得单独交代。其一,翻页不用 next_page:Pexels 响应里的 next_page 是拼好完整参数的 URL,但本项目没有采用,而是页面层自持 page 游标、每次自增后重新调用归一化方法——这要求页面层保证"页码只在成功后自增"(否则会跳页),换取了重试逻辑的简单直接。其二,totalPages 只是参考值:它由 Math.ceil(total_results / per_page) 计算,而门面层会把 total 截到 8000,真正的完成判定在页面层(已加载数 ≥ total 即停),totalPages 归一化结果在列表场景中基本未被消费。其三,参数过滤先于发送:buildParams 会剔除 undefined 与空串,所以"全部分类"(categoryId 传 'all')在门面层被转成空查询词、最终走 /curated,URL 上不会出现 query=all 这样的脏参数。
**照片组(search / curated / photos:id)**是复用度最高的一组:search 与 curated 共用 normalizeSearchResponse,返回形状完全一致,页面层因此可以用同一个无限滚动容器承载两种数据源。orientation 与 color 两个筛选参数在协议层已支持(Orientation / ColorFilter 类型含 12 个色名),但当前 UI 未暴露筛选入口——属于"协议先行、界面未跟上"的预留能力,扩展筛选 UI 时门面层只需在 PexelsListQuery 里加字段透传即可。
**视频组(videos/search / videos/popular / videos/videos:id)**的特别之处是详情路径:官方视频详情挂载在 /videos/videos/:id(videoById 方法注释专门标注了这个双段路径,接手者极易在这里踩坑)。videosPopular 支持 min_width、min_height、min_duration、max_duration 四个过滤参数,当前 UI 同样未暴露;videosSearch 独有 size(清晰度档位)与 locale 参数。视频对象里的 video_pictures(预览帧序列)归一化后透传但全站未消费,属于为将来"视频抽帧预览"预留的字段。
**合集组(collections/featured / collections / collections:id)**的核心约束是权限与配额:/collections(我的合集)依赖 Pexels 账号认证态,前端直连场景下绝大多数用户拿不到数据,因此门面层把它特殊化为"任何失败都返回空数组"并在 UI 上给出登录提示文案;/collections/:id(集合内媒体)返回的 media 是照片视频混排数组(靠 type 字段区分),这是全站唯一的混合类型数据源,src/views/collection/ 的瀑布流卡片与弹层都为此做了联合类型分支。合集详情页头部显示的标题与统计并不来自这个端点,而是先翻特色合集列表按 id 匹配(最多 3 页 × 80 条)——Pexels 没有提供"按 id 查合集元信息"的端点,这是官方 API 的形状缺口,项目用跨页缓存(metaCache)把代价压到每合集至多一次。
| 变量 | 定义位置 | 读取方 | 语义 |
|---|---|---|---|
VITE_PEXELS_API_KEY | .env.development / .env.production | src/api/pexels.ts、src/env.d.ts 类型声明 | 直连官方 API 时使用;会被打进前端产物,任何访问者可提取 |
VITE_PEXELS_PROXY_BASE | 同上 | 同上 | 反向代理地址;配置后请求改走代理,Key 留在服务端(生产推荐) |
.env.example 明确注释:VITE_ 前缀变量不具备保密性;历史上还存在的 VITE_BASE_API、VITE_APP_SECRET(自有后端签名)已随账号体系移除,无读取方(src/env.d.ts 里仍保留其类型声明,属遗留)。README 警告生产环境优先用代理方案。
门面层 8 个函数遵循同一骨架,但按"数据可信度要求"分四档处理:
| 接口 | 无 Key(client=null) | 有 Key 但请求失败(非 401/403/429) | 有 Key 且 401/403/429 |
|---|---|---|---|
getPexelsList | Picsum 占位列表,total=200 | Picsum 占位列表,total=200 | 抛出(页面弹错误、列表显示重试) |
getPexelsFromId | 单张 Picsum 占位(id 固定 1) | 抛出(避免"张冠李戴":静默返回与点击目标无关的随机图) | 抛出 |
searchVideos / getPopularVideos | Picsum 封面 + BigBuckBunny 示例视频,total=200 | 同左 | 抛出 |
getVideoById | 单个示例视频 | 抛出 | 抛出 |
getFeaturedCollections | 「示例合集 N」说明卡(引导配置 Key),total=200 | 同左 | 抛出 |
getMyCollections | 空数组(注释:返回空比兜底占位更准确,避免误导) | catch 全部错误 → 空数组 | 空数组(401/未授权视为"无合集") |
getCollectionMedia | Picsum 占位照片流,total=200 | 一律抛出(注释:媒体会写进缓存并当作真实数据展示,绝不静默换占位) | 抛出 |
源码对这条不变量的表述(src/api/pexels.ts 的 isAuthOrLimitError 注释):认证/配额类错误必须向上抛出,降级只应用于"网络抖动"这类瞬时故障;否则 429 熔断与配额提示会变成永远进不去的死分支。
降级数据由 src/utils/pexels.ts 底部的生成器工厂按页构造,id 序号 = (page - 1) × perPage + i + 1,保证翻页不重复:
createFallbackWallpaper(id) 生成 NormalizedWallpaper——原图 https://picsum.photos/id/{id}/1920/1080,缩略 /400/300,portrait /1080/1920,tiny /80/60;应用内 id 为 fallback-{id};描述「精美壁纸 {id}」;作者「Picsum Photos」;宽高固定 1920×1080;avgColor 空(卡片回退 randomTint() 随机淡彩占位色)、liked=false、pexelsUrl 空(详情弹层不显示"原页"链接)。批量版 generateFallbackWallpapers(page, perPage);单张版 generateFallbackWallpaper() 固定 id=1,供详情接口无 Key 时使用。createFallbackVideo(id)——封面 https://picsum.photos/id/{(id % 1084) + 1}/640/360(Picsum 有效 id 上限约 1084,取模防 404),时长固定 15 秒,唯一 videoFile 指向开源样例 https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4(quality hd、video/mp4),保证无 Key 时视频详情仍可真实播放;应用内 id 为 fallback-video-{id}。generateFallbackCollections 生成 mediaCount=0 的说明卡,描述文案「请配置 VITE_PEXELS_API_KEY 后查看 Pexels 真实合集」;合集内媒体降级为照片占位流。FALLBACK_MAX_TOTAL 等),注释说明 Picsum 有效图片 id 有限,限制滚动页数防止大量 404。真实模式 total 截断到 8000(PEXELS_MAX_TOTAL),因为 Pexels 分页上限为 page×per_page ≤ 8000,超过后 next_page 为 null——不截断的话无限滚动永远等不到"已加载 ≥ total"而无法收敛。三层防线:
PexelsApiError):401/429 给明确中文文案,其余归为通用失败;错误对象带 status 供上层判别。isAuthOrLimitError 决定降级还是抛出(见 3.2 矩阵)。hasError=true 并 message('error', err.message),m-infinite-list 渲染「加载失败,点击重试」,点击重试不会跳页(页码只在成功后自增);过期请求的错误不提示(序号不匹配直接 return)。限流专项:合集封面调度器(src/utils/pexels-collection-cache.ts)。合集封面是典型 N+1:列表接口不返回封面,每张卡要单独发一次 collectionMedia(perPage=1),而默认配额约 200 次/小时。调度器做四件事:模块级 coverCache 内存缓存(同 id 只请求一次,跨页面共享);queue + drain() 队列——单批 3 个并发(CONCURRENCY=3)、批间 250ms(BATCH_DELAY_MS);requestCover 返回取消函数,卡片被虚拟滚动回收时反注册订阅(waiters 表),避免回调打到已卸载组件;429 熔断——命中后 rateLimited=true、rateLimitUntil = now + 5 分钟(RATE_LIMIT_COOLDOWN_MS),同一冷却期只 message('warn', 'Pexels API 配额已耗尽,合集封面暂停加载,约 5 分钟后自动重试。') 提示一次,冷却到期自动复位放行,冷却期内排队项直接释放并通知空封面(卡片下次进视口会重新排队)。失败不写缓存,下次仍可重试。
配额节流的其它落点:首页/视频页 keep-alive 失活期间 store 变化不发请求;合集列表页每页 12 条;合集详情页元信息查找限 3 页×80 并写 metaCache;分类词表本地静态化;no Key 时接口在门面层短路,不发任何真实请求。
错误文案与用户感知对照。协议层抛出的 PexelsApiError 文案只有两种是定制的:401 →「API Key 无效,请重新设置」,429 →「Pexels 请求过于频繁,请稍后再试」;其余状态归为「API 请求失败: {status} {statusText}」。这些文案最终出现在两个位置:列表页底部的全局 message 弹条(src/views/main/components/list/index.vue 等三处 catch),以及 m-infinite-list 渲染的"加载失败,点击重试"占位(点击后重发当前页)。详情类页面(src/views/pins/index.vue、src/views/video/index.vue、src/views/collection/index.vue)只弹 message 不改列表态。合集封面是唯一"静默失败"的路径:普通错误只丢封面不提示,429 则由调度器统一提示一次「Pexels API 配额已耗尽,合集封面暂停加载,约 5 分钟后自动重试。」——同一冷却期内绝不重复弹,避免用户被连续骚扰。
代理模式的服务端契约。若走 VITE_PEXELS_PROXY_BASE,反向代理需要满足四点:接受前端发来的路径与查询串并原样转发到 api.pexels.com/v1 对应路径;在转发时注入 Authorization: <真实Key> 请求头(前端在该模式下不发此头,见 PexelsClient.request 的条件逻辑);不修改响应体(前端直接 res.json() 解析);正确转发 4xx 状态码——前端的错误分级依赖状态码,若代理把 429 吞成 200,熔断逻辑将完全失效。Vite 开发代理(vite.config.ts)就是这一契约的参考实现:changeOrigin 改写 Host、rewrite 去掉 /api/pexels 前缀。
首页分类请求链(getPexelsList,有 Key、分类「自然」):
请求:GET https://api.pexels.com/v1/search?query=nature&page=1&per_page=20
请求头:Authorization: <VITE_PEXELS_API_KEY>
响应(节选):
{
"page": 1, "per_page": 20, "total_results": 18374,
"photos": [{
"id": 358457, "width": 4592, "height": 3056,
"url": "https://www.pexels.com/photo/...",
"photographer": "John Doe", "photographer_url": "...", "alt": "山间日出",
"avg_color": "#6B7B8C", "liked": false,
"src": { "original": "...jpeg", "large2x": "...", "large": "...",
"medium": "...", "small": "...", "portrait": "...",
"landscape": "...", "tiny": "..." }
}],
"next_page": "https://api.pexels.com/v1/search?query=nature&page=2..."
}
门面返回:{ list: WallpaperItem[], total: 8000 } // min(18374, 8000)无 Key 时同一调用:不发请求,直接返回 { list: toWallpaperItem(generateFallbackWallpapers(1, 20)), total: 200 },其中第 1 项为 id: 'fallback-1'、photo: 'https://picsum.photos/id/1/1920/1080'。
合集封面排队(卡片进入视口):
requestCover('nhxxx', cb)
→ 缓存未命中且未熔断 → 入队 → drain 批处理
→ getCollectionMedia({ id:'nhxxx', page:1, perPage:1 })
→ GET https://api.pexels.com/v1/collections/nhxxx?page=1&per_page=1
→ 取 media[0]:照片用 url,视频用 image → 写 coverCache → cb(cover)类型声明位置:Pexels 原始结构(PexelsPhoto/PexelsVideo/PexelsCollection 及各自响应)、归一化模型(NormalizedWallpaper/NormalizedVideo/NormalizedCollection)、卡片适配模型(WallpaperItem)、弹层模型(ViewerItem)全部定义在 src/utils/pexels.ts 与 src/utils/viewer.ts;环境变量类型在 src/env.d.ts 的 ImportMetaEnv 全局声明中。接口层无运行时校验(信任 Pexels 契约),字段缺失一律在归一化时兜底为空串/0。