项目:PEXELS 壁纸应用 | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
《15-接口全量参考》已经用"字段 → 归一化去向 → 消费点"的表格形式给出了全部端点的静态契约,本篇补上它的另一半:完整的请求与响应实例。每个端点给出可复制执行的 curl、原始响应 JSON(结构与 src/utils/pexels.ts 中对应的 Pexels* 接口逐字段对齐)、归一化与门面出口的关键差异,以及无 Key 降级时同一调用的假数据形态。目标是让读者不启动应用、不发真实请求,就能在纸面上完成"这个接口长什么样、我的代码会拿到什么"的推演。
三条阅读约定需要先立住。其一,示意值标注:以下所有 JSON 中的 id、URL、摄影师姓名、统计数字均为按真实数据形态构造的示意值(如照片 id 358457、宽高 4592×3056),仅字段类型与取值范围可信,具体值不可作为断言依据;Pexels 官方若调整字段,以运行时响应为准 ⚠️ 待确认(本篇未做真实请求采样,结构依据源码类型声明与官方文档公开形状)。其二,敏感值占位:所有示例中的鉴权头一律写作 <VITE_PEXELS_API_KEY>,绝不出现任何真实 Key 值;代理模式下该头不存在。其三,消费视角:响应 JSON 中"本应用未消费"的字段会保留在示例里(它们是真实响应的一部分,也是《15》第 2 节"未消费字段同样入表"原则的实例化),并用注释标明状态,避免读者误以为响应里没有这些字段。
全部请求共用一组基础设施参数,先一次性交代,后续端点不重复。基础地址 https://api.pexels.com/v1(src/utils/pexels.ts 的 PEXELS_API_BASE);鉴权头 Authorization: <VITE_PEXELS_API_KEY>——无 Bearer 前缀(文件头注释强调,照搬主流模板加 Bearer 会 401);走代理模式(baseUrl 非官方地址)时不发该头,Key 由服务端注入。分页参数 page(下限 1)与 per_page(钳制 [1,80])在协议层与门面层各钳一次;buildParams 会剔除 undefined 与空串,因此"全部分类"不会产生 query=all 这类脏参数。响应只认两态:res.ok 为真则 res.json() 解析,否则按状态码构造 PexelsApiError 抛出——401 文案「API Key 无效,请重新设置」、429 文案「Pexels 请求过于频繁,请稍后再试」、其余「API 请求失败: {status} {statusText}」。错误响应体从不被解析:前端对 4xx/5xx 的全部认知来自状态码本身,这意味着无论 Pexels 的错误体长什么样(HTML 错误页、JSON、空体),行为都一致——这是"信任契约 + 状态码驱动"设计的一个直接推论。
一个通用的 curl 模板(以照片搜索为例,替换路径与参数即可用于全部端点):
curl -sS -D - \
-H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/search?query=nature&page=1&per_page=20"-D - 把响应头一并打到标准输出,方便确认 X-Ratelimit-Remaining 一类限流头 ⚠️ 待确认(Pexels 是否返回该头以其文档为准,前端代码不消费它)。开发环境等价请求走同源代理:把地址换成 http://localhost:5173/api/pexels/search?...,不带鉴权头,由 Vite 转发(vite.config.ts 的 server.proxy)。
消费链:PexelsClient.search / PexelsClient.curated → 门面 getPexelsList(分类浏览与全局搜索共用一个门面函数,请求词路由决定走哪条路径)。请求参数:query(分类 id 或搜索词,trim 后)、page、per_page,以及协议已支持但 UI 未暴露的 orientation、color。
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/search?query=nature&page=1&per_page=20"
# 响应(示意值)
{
"page": 1,
"per_page": 20,
"total_results": 18374,
"next_page": "https://api.pexels.com/v1/search?query=nature&page=2&per_page=20",
"prev_page": null,
"photos": [
{
"id": 358457,
"width": 4592,
"height": 3056,
"url": "https://www.pexels.com/photo/misty-mountains-at-dawn-358457/",
"photographer": "李明",
"photographer_url": "https://www.pexels.com/@liming",
"photographer_id": 100200,
"alt": "清晨薄雾中的山脉与湖泊",
"avg_color": "#6B7B8C",
"liked": false,
"src": {
"original": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg",
"large2x": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"large": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
"medium": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&h=350",
"small": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&h=130",
"portrait": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=1200&w=800",
"landscape": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=627&w=1200",
"tiny": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg?auto=compress&cs=tinysrgb&dpr=1&fit=crop&h=200&w=280"
}
}
]
}字段消费速记(完整表见《15》2.1):total_results 被门面截断为 Math.min(18374, 8000);photos[].src.large2x/large/original 构成归一化 url 的逐级回退链;src.medium/small 构成缩略 thumb;alt 兜底为「壁纸 {id}」;avg_color 直接服务卡片占位底色;liked、page、per_page、next_page、prev_page 归一化后未被消费(本项目不采用拼好的翻页 URL,页面层自持 page 游标)。归一化产物是 SearchResult{ items: NormalizedWallpaper[], totalPages: Math.ceil(18374/20)=919, total: 18374 },门面最终返回 { list: WallpaperItem[], total: 8000 }。
同族变体:GET /v1/curated(官方精选,"全部"分类)。请求只带 page 与 per_page,无 query 参数——这正是"全部"分类不需要脏参数的原因:
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/curated?page=1&per_page=20"响应结构与 /search 完全同构(PexelsSearchResponse),仅无 query 语义、total_results 通常是官方精选池的实际规模(示意值 8431)。协议层 normalizeSearchResponse 对两者共用,页面层因此可以用同一个无限滚动容器承载两种数据源而不写任何分支(《03-接口设计》2.1 节"照片组复用度最高"的出处)。需要特别记录一个行为差异:/curated 不支持 orientation/color 筛选参数(官方端点无此能力),SOP-C 暴露筛选 UI 时"全部"分类下筛选控件需禁用(《11-扩展开发指南》第 3 节)。
消费链:PexelsClient.getById → 门面 getPexelsFromId(/pins/:id 详情直达页)。响应是单个 PexelsPhoto 对象(非数组、无分页字段),形状即端点一 photos[] 的单元素:
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/photos/358457"归一化走同一条 normalizePhoto 路径,产出单个 NormalizedWallpaper,门面包一层 toWallpaperItem 直接返回卡片模型。这个端点的特殊性在错误语义:404(id 不存在)与 401/429 一样走"上抛不降级"路径——getPexelsFromId 有 Key 时没有 catch,分享出去的失效链接会以错误提示 + 白底呈现(13 篇 D-11 的已知留白)。它与列表端的另一差异是无 Key 降级形态:返回 generateFallbackWallpaper()(固定 id=1 的单张占位)而非列表。
消费链:PexelsClient.videosSearch → 门面 searchVideos。参数 query/page/per_page 外加未暴露的 orientation/size/locale。
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/videos/search?query=ocean&page=1&per_page=18"
# 响应(示意值,单元素示意)
{
"page": 1,
"per_page": 18,
"total_results": 4210,
"url": "https://api.pexels.com/videos/search/ocean",
"next_page": "https://api.pexels.com/v1/videos/search?query=ocean&page=2&per_page=18",
"videos": [
{
"id": 2499611,
"width": 1920,
"height": 1080,
"url": "https://www.pexels.com/video/waves-crashing-on-rocks-2499611/",
"image": "https://images.pexels.com/videos/2499611/free-video-2499611.jpg?auto=compress&cs=tinysrgb&fit=crop&h=630&w=1200",
"duration": 12,
"user": { "id": 505050, "name": "Anna Chen", "url": "https://www.pexels.com/@annachen" },
"video_files": [
{
"id": 130241,
"quality": "sd",
"file_type": "video/mp4",
"width": 960,
"height": 540,
"fps": 24.0,
"link": "https://videos.pexels.com/video-files/2499611/2499611-sd_960_540_24fps.mp4"
},
{
"id": 130242,
"quality": "hd",
"file_type": "video/mp4",
"width": 1920,
"height": 1080,
"fps": 24.0,
"link": "https://videos.pexels.com/video-files/2499611/2499611-hd_1920_1080_24fps.mp4"
},
{
"id": 130243,
"quality": "hls",
"file_type": "video/mp4",
"width": null,
"height": null,
"fps": null,
"link": "https://videos.pexels.com/video-files/2499611/2499611-hls_playlist.m3u8"
}
],
"video_pictures": [
{ "id": 88110, "picture": "https://images.pexels.com/videos/2499611/pictures/preview-0.jpg", "nr": 0 }
]
}
]
}这条响应里藏着两个本项目的关键防御点。其一,video_files 中的 hls 条目:file_type 虽写 video/mp4 但链接是 m3u8 清单,原生 <video> 在非 Safari 下放不了——pickVideoFile(src/utils/viewer.ts)按 file_type.startsWith('video/') 过滤后仍可能混入 HLS,弹层的 qualities 计算属性与 pickVideoFile 的"全部不可播时回退有 link 者"兜底共同处理这种极端情形(《05-核心流程》第 3 节边界①)。其二,width/height 可能为 null(HLS 条目即如此),pickVideoFile 的宽度排序用 (b.width || 0) - (a.width || 0) 兜底。video_pictures(预览帧序列)归一化后全站未消费,属 D-04 影子字段。fps 字段带"2022-09-15 新增"注释(src/utils/pexels.ts 第 473 行),是官方 API 字段演进的一个切面。视频详情端点六的响应即此 videos[] 单元素。
消费链:PexelsClient.videosPopular → 门面 getPopularVideos(视频页无关键词时的默认流)。响应与端点三同构(PexelsVideoSearchResponse),差异在参数面:支持 min_width/min_height/min_duration/max_duration 四个过滤参数(均未暴露 UI):
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/videos/popular?page=1&per_page=18"它与 /videos/search 的响应无任何结构差异,归一化共用 normalizeVideoResponse,因此视频列表组件以 mode(popular/search)分流请求、以完全同一套渲染逻辑消费(《02-页面设计》3.2 节)。示意图值 total_results: 76120。
消费链:PexelsClient.videoById → 门面 getVideoById(/videos/:id 详情直达页)。路径是全 API 集成里最容易写错的一处:官方视频详情挂在 /videos/videos/:id(双段 videos),源码第 310 行注释专门标注"注意官方路径是 /v1/videos/videos/:id":
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/videos/videos/2499611"响应为单个 PexelsVideo(端点四 videos[] 的单元素,示意 id 2499611)。归一化后门面直接返回 NormalizedVideo(此门面函数不做 WallpaperItem 式的再适配,弹层数据由 toViewerItem 在消费侧转换)。错误语义与端点二同档:有 Key 时失败一律上抛,无 Key 返回 BigBuckBunny 示例视频保证可播(《03-接口设计》3.2 矩阵)。
消费链:PexelsClient.collectionsFeatured → 门面 getFeaturedCollections(合集列表"特色"Tab + 合集详情页元信息翻页匹配)。
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/collections/featured?page=1&per_page=12"
# 响应(示意值)
{
"page": 1,
"per_page": 12,
"total_results": 1092,
"next_page": "https://api.pexels.com/v1/collections/featured?page=2&per_page=12",
"collections": [
{
"id": "nhxxxQ",
"title": "晨雾与远山",
"description": "精选 32 张高海拔山地的晨雾摄影",
"private": false,
"media_count": 32,
"photos_count": 28,
"videos_count": 4
}
]
}这条响应定义了本篇最重要的一条"否定性契约":响应中没有封面字段。 合集卡片的封面必须对每个 id 再发一次端点八(per_page=1 取首图),N+1 由此而来——src/utils/pexels-collection-cache.ts 的整个调度器都是对这个缺口的补偿(ADR-004)。归一化把 snake_case 驼峰化(media_count → mediaCount 等)、数值兜底 0、private 强制布尔;cover 字段在协议层永远不赋值,由卡片局部状态回填。它的第二个消费场景是元信息匹配:合集详情页为查标题最多翻 3 页 × 80 条逐页 find(id)(src/views/collection/index.vue 的 loadCollectionMeta),命中即写 metaCache——Pexels 无"按 id 查合集元信息"的端点是官方形状缺口,本端点是唯一的替代路径。
消费链:PexelsClient.myCollections → 门面 getMyCollections("我的合集"Tab)。参数与端点七相同(page/per_page),响应同构。但它有一条独特的行为契约:
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/collections?page=1&per_page=12"
# 前端直连场景下的典型实际结果:401 Unauthorized(浏览器未持有 Pexels 登录态)
# 门面层对它的处理:catch 全部错误 → 返回 { items: [], total: 0, totalPages: 0 }需要讲清的是"为什么大多数用户拿不到数据":该端点鉴权依赖的是 Pexels 账号的用户级登录态(浏览器 Cookie),而非 API Key 本身——配 Key 只证明"应用被允许调 API",不证明"某个用户登录了 Pexels"。前端直连架构下没有获取用户登录态的路径,于是门面层把无 Key、请求失败、401 三种情形统一归一化为空数组(注释:"返回空数组比兜底占位更准确,避免误导用户"),UI 呈现为空 Tab + 提示文案。这是官方端点里唯一被前端"主动放弃"的一个,也是 ADR-001(无账号架构)在数据契约上的直接投影(13 篇 D-10)。
消费链:PexelsClient.collectionMedia → 门面 getCollectionMedia(合集详情媒体流 + 封面调度器取首图)。参数 page/per_page 外加未暴露的 type(photos/videos)与 sort(asc/desc):
curl -H "Authorization: <VITE_PEXELS_API_KEY>" \
"https://api.pexels.com/v1/collections/nhxxxQ?page=1&per_page=18"
# 响应(示意值,混排)
{
"id": "nhxxxQ",
"page": 1,
"per_page": 18,
"total_results": 32,
"media": [
{
"type": "Photo",
"id": 358457,
"width": 4592,
"height": 3056,
"url": "https://www.pexels.com/photo/misty-mountains-at-dawn-358457/",
"photographer": "李明",
"photographer_url": "https://www.pexels.com/@liming",
"photographer_id": 100200,
"alt": "清晨薄雾中的山脉与湖泊",
"avg_color": "#6B7B8C",
"liked": false,
"src": { "original": "https://images.pexels.com/photos/358457/pexels-photo-358457.jpeg", "...": "八档尺寸同端点一" }
},
{
"type": "Video",
"id": 2499611,
"width": 1920,
"height": 1080,
"url": "https://www.pexels.com/video/waves-crashing-on-rocks-2499611/",
"image": "https://images.pexels.com/videos/2499611/free-video-2499611.jpg",
"duration": 12,
"user": { "id": 505050, "name": "Anna Chen", "url": "https://www.pexels.com/@annachen" },
"video_files": [ "...": "同端点四" ],
"video_pictures": []
}
]
}它是全站唯一的混合类型数据源:media[] 每项靠 type: 'Photo' | 'Video' 判别字段分派,normalizeCollectionMediaResponse 按大写 type 归一化后打上小写 type: 'photo'|'video' 标签,产出联合类型 NormalizedCollectionMediaItem——media-item.vue 的卡片分支与 toViewerItem 的鸭子测试都以此为基础。这条端点有两个消费身份:详情页媒体流(每页 18)与封面调度器的单发探测(每页 1,取 media[0]:视频用 image、照片用 url)。错误语义是全矩阵最严的一格:有 Key 时任何失败一律抛出,注释理由是"媒体与封面会被写进缓存并当作真实数据展示,绝不静默换成 picsum 占位"——缓存污染防护(1.4 节同源原则)。
无 Key(client = null)或有 Key 但网络抖动(非 401/403/429)时,门面层返回由 src/utils/pexels.ts 底部生成器构造的降级数据。id 序号公式统一为 (page - 1) × perPage + i + 1(翻页不重复),总数固定 FALLBACK_MAX_TOTAL = 200。四个形态各给一例:
// ① 降级照片(generateFallbackWallpapers 的第 1 项,getPexelsList 无 Key 时)
{
"id": "fallback-1",
"url": "https://picsum.photos/id/1/1920/1080",
"thumb": "https://picsum.photos/id/1/400/300",
"description": "精美壁纸 1",
"author": "Picsum Photos",
"authorUrl": "",
"authorId": 0,
"width": 1920,
"height": 1080,
"src": {
"original": "https://picsum.photos/id/1/1920/1080",
"large2x": "https://picsum.photos/id/1/1920/1080",
"large": "https://picsum.photos/id/1/1920/1080",
"medium": "https://picsum.photos/id/1/400/300",
"small": "https://picsum.photos/id/1/400/300",
"portrait": "https://picsum.photos/id/1/1080/1920",
"landscape": "https://picsum.photos/id/1/1920/1080",
"tiny": "https://picsum.photos/id/1/80/60"
},
"pexelsUrl": "",
"avgColor": "",
"liked": false
}
// ② 降级视频(createFallbackVideo,全部视频接口无 Key 时共用)
{
"id": "fallback-video-1",
"url": "",
"image": "https://picsum.photos/id/2/640/360",
"duration": 15,
"width": 1920,
"height": 1080,
"user": { "id": 0, "name": "Picsum", "url": "" },
"videoFiles": [
{ "id": 0, "quality": "hd", "file_type": "video/mp4", "width": 1920, "height": 1080, "fps": 30,
"link": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" }
],
"videoPictures": []
}
// ③ 降级合集(generateFallbackCollections 的第 1 项)
{
"id": "fallback-collection-1",
"title": "示例合集 1",
"description": "请配置 VITE_PEXELS_API_KEY 后查看 Pexels 真实合集",
"mediaCount": 0,
"photosCount": 0,
"videosCount": 0,
"private": false
}
// ④ 降级合集媒体(generateFallbackCollectionMedia:复用①并打 type 标签)
{ "...": "同①的全部字段", "type": "photo" }三个降级侧的设计细节需要用文字钉住。取模 1084:降级视频封面地址是 picsum.photos/id/{(id % 1084) + 1}/640/360——Picsum 的有效图片 id 上限约 1084,直接用递增序号会在第 1085 张之后批量 404(createFallbackVideo 的实现注释)。同序号同图:Picsum 的 /id/{n} 形式保证同一序号恒返回同一张图,瀑布流回滚时占位图不"变脸";而 avgColor 留空串使卡片回退 randomTint() 的随机淡彩,pexelsUrl 空串使弹层不显示"原页"外链——降级数据在视觉与交互上自我一致。降级的边界:降级响应经同一套归一化与适配函数(toWallpaperItem 等),因此《04-数据模型》的四层流水线对假数据同样成立;但 401/403/429 永远不产生降级数据(ADR-002),错误只以 PexelsApiError{ status, message } 的形态抛给页面层,页面弹 message('error', err.message) 并把 m-infinite-list 切到"加载失败,点击重试"。
接口契约的另一半是"数据的另一半存放地"——浏览器侧。先给唯一的持久化条目(经 pinia-plugin-persistedstate 写入,业务代码 0 处直接调 localStorage):
// localStorage
key: "theme"
value: {"themeType":"dark"} // "light" | "dark" | "system" 三选一值是整个 state 的 JSON 序列化(store id 即 key),src/utils/theme.ts 启动时 watch(themeType, changeTheme, { immediate: true }) 立即消费。若值被手动改坏(如 {"themeType":"blue"}),changeTheme 的 switch 落空、html class 不动——"非法主题等于维持现状"(12 篇 FAQ-8)。除此之外没有任何持久化键:无收藏、无历史、无缓存落地,这是 ADR-009 隐私边界的实物形态。
两处内存缓存的结构示例(模块级单例,刷新即失):
// src/utils/pexels-collection-cache.ts(会话内存)
coverCache: Map<string, string> // "nhxxxQ" => "https://images.pexels.com/videos/2499611/free-video-2499611.jpg"
metaCache: Map<string, unknown> // "nhxxxQ" => { id:"nhxxxQ", title:"晨雾与远山", mediaCount:32, ... }
waiters: Map<string, Set<cb>> // "nhxxxQ" => Set{ fn1, fn2 }(同封面多订阅者)
queue: string[] // ["x9aaaB", "k2cccD"](待拉取,FIFO)
enqueued: Set<string> // 与 queue 一致,防重复入队
// 熔断标量:rateLimited=false, rateLimitUntil=0, rateLimitNotified=false, draining=false
// src/directives/modules/lazy.ts(会话内存)
loadedCache: Set<string> // "https://images.pexels.com/photos/358457/...h=350" (load 成功才登记)
stateMap: WeakMap<HTMLImageElement, { src: string; stop: () => void }>写入门槛与生命周期在《15》第 5 节已成表,这里只补结构示例揭示的两个事实:其一,coverCache 的值可能是视频封面帧地址(首图是视频时取 image),消费方 collection-item.vue 无须知道这一点——它只把字符串塞进 <img :src>,Pexels 的视频封面帧恰好是可作 <img> 源的静态图;其二,stateMap 用 WeakMap 以 DOM 元素为键,元素被虚拟滚动回收后条目随之可回收,无泄漏之忧——与 loadedCache(按 URL 索引、跨 DOM 生命周期共享)形成"元素态"与"资源态"两种缓存粒度的对照。
把全篇合上,这个项目的数据契约以三张面孔存在:Pexels 的原始面孔(snake_case、八档 src、混排 type 判别、无封面字段、双段视频详情路径),它由官方定义、本应用只能适应;归一化的中间面孔(Normalized* 三兄弟 + WallpaperItem/ViewerItem 两个适配层),它由 src/utils/pexels.ts 与 src/utils/viewer.ts 定义、是本应用对契约的"翻译声明";门面的行为面孔(降级矩阵、total 截断、错误分级),它由 src/api/pexels.ts 定义、决定"拿不到时给什么"。本篇的示例集覆盖第一张面孔的实例与第三张面孔的假数据形态,第二张面孔的字段级映射见《15》第 2 节的表格。当怀疑"数据没显示"时,排查顺序始终是:先看 Network 里的原始响应(第一张面孔是否变了)→ 再对照归一化输出(翻译是否丢失字段)→ 最后查门面行为(是否处于降级/熔断态)——三步分别对应三张面孔,与本篇的章节顺序一致。