项目:PEXELS 壁纸应用 | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
PEXELS 壁纸应用(package.json 中包名为 front)是一个纯前端的图片 / 视频壁纸浏览与下载应用,基于 Vue 3 + Vite 8 + Pinia 3 + Tailwind CSS 4 构建,同时面向移动端浏览器与 PC 端浏览器交付同一套产物。它没有自建后端:所有图片、视频、合集数据都直接来自 Pexels 官方开放 API(https://api.pexels.com/v1);当使用者没有配置 Pexels API Key 时,应用会自动降级为 Picsum 占位图与开源示例视频,保证页面永远可渲染、可滚动、可交互,只是内容为占位数据。
从产品形态上看,它是一个非常克制的"浏览 + 下载"工具:
README.md 与 .env.example 的注释均明确说明:收藏、登录、搜索历史、上传、OSS 等能力连同 utils/request.ts、utils/crypto.ts 一起被移除,历史环境变量 VITE_BASE_API、VITE_APP_SECRET 已无任何读取方。全站唯一的持久化状态是主题偏好(src/store/index.ts 中 theme store 的 persist: true)。liked 字段仅做展示透传)、不能评论,所有内容均由 Pexels 官方策展或搜索接口提供。src/router/index.ts),移动端与 PC 端的差异不通过两套路由或两个构建目标承担,而是由 src/utils/helpers.ts 导出的 isMobileTerminal(UA + 触摸能力 + 视口宽度综合判定)在组件内部分支处理。目标用户可以概括为两类:一是想快速找一张高质量壁纸并下载到手机或电脑的普通用户;二是把本项目作为"Vue 3 移动端优先 + 第三方 API 直连"工程范式参考的前端开发者——项目内大量注释记录了工程决策(如为什么请求要带序号、为什么瀑布流要虚拟滚动、为什么 401 不能静默降级),本身就是一份可运行的决策档案。
从使用场景拆开看,三类典型旅程覆盖了全部页面:找图下载(首页分类浏览或搜索 → 点卡片进弹层 → 下载原图或复制链接,全过程不离开当前页);找动态壁纸(底部 Tab 切到视频 → 热门流浏览 → 弹层内直接播放并可切换清晰度下载 mp4);成套浏览(合集 Tab → 特色合集封面墙 → 进入合集详情看照片视频混排瀑布流)。三类旅程共享同一套骨架(顶栏 + 底部导航)与同一套详情弹层,学习成本被压到最低。
| 功能模块 | 入口 / 关键文件 | 说明 |
|---|---|---|
| 图片瀑布流首页 | src/views/main/index.vue | 分类导航 + 瀑布流 + 无限滚动,一级导航「图片」,路由 / |
| 分类切换 | src/views/main/components/navigation/index.vue | 13 个本地静态分类(src/constants/index.ts 的 CATEGORY_NOMAR_DATA),id 即 Pexels 搜索词 |
| 全局搜索 | src/views/layout/components/header/header-search/index.vue | 顶部搜索框,作用于当前一级导航(图片 / 视频);合集页搜索自动跳回图片页 |
| 图片详情弹层 | src/components/media-viewer/index.vue | 图片与视频共用;左右切换、键盘方向键、移动端滑动、下载、复制链接、地址栏同步 |
| 图片详情直达 | src/views/pins/index.vue | 刷新 / 直接访问 /pins/:id 时独立加载详情,复用同一弹层(embedded=false) |
| 视频列表 | src/views/videos/index.vue | 无关键词走热门(/videos/popular),有关键词走搜索(/videos/search) |
| 视频详情 | src/views/video/index.vue | 独立路由 /videos/:id,支持多清晰度切换(过滤 HLS) |
| 合集列表 | src/views/collections/index.vue | 「特色合集 / 我的合集」双 Tab(m-segmented),封面按需排队加载 |
| 合集详情 | src/views/collection/index.vue | 照片与视频混排瀑布流,合集元信息跨页缓存(src/utils/pexels-collection-cache.ts) |
| 主题切换 | src/views/layout/components/header/header-theme.vue + src/utils/theme.ts | 极简白 / 极夜黑 / 跟随系统三态,持久化到 localStorage |
| 图片懒加载 | src/directives/modules/lazy.ts | v-lazy 指令,IntersectionObserver + 已加载缓存 |
| 瀑布流虚拟滚动 | src/libs/waterfall/index.vue | 只渲染视口 ±400px 缓冲内的卡片,容器宽度自适应列数 |
| 无限滚动加载 | src/libs/infinite-list/index.vue | IntersectionObserver 触底加载,失败点击重试 |
| 下载与复制 | src/components/media-hover-actions/index.vue | PC 端 hover 浮层,file-saver 下载 + 剪贴板复制 |
| 全局消息提示 | src/libs/message/index.ts | 命令式 message(type, content),多实例独立容器 |
| 404 兜底 | src/views/error/404/index.vue | 未匹配路由重定向 /404 |
| 无 Key 降级 | src/api/pexels.ts + src/utils/pexels.ts | Picsum 占位图 / BigBuckBunny 示例视频 / 示例合集卡,页面不白屏 |
以下版本均取自 package.json(^ 语义化范围,实际锁定版本见 pnpm-lock.yaml)。
| 类别 | 依赖 | 版本 | 选型理由(源自源码与 docs/dependency-upgrade-plan.md) |
|---|---|---|---|
| 框架 | vue | ^3.5.29 | Composition API + <script setup>;项目大量依赖 defineAsyncComponent、useVModel 等函数式能力 |
| 构建 | vite | ^8.2.2 | 已是 rolldown 内核;docs/dependency-upgrade-plan.md 记录了 Vite 8 下 postcss.config.js 不可靠、需显式配在 vite.config.ts 的踩坑 |
| 语言 | typescript + vue-tsc | ^5.9.3 / ^3.3.11 | strict + noUncheckedIndexedAccess;pnpm type-check 走 vue-tsc --noEmit |
| 状态 | pinia + pinia-plugin-persistedstate | ^3.0.4 / ^4.7.1 | 从 Vuex 迁移而来;持久化插件仅用于 theme store |
| 路由 | vue-router | ^4.6.4 | createWebHistory,单路由表双端复用 |
| 样式 | tailwindcss + @tailwindcss/vite | ^4.3.3 | Tailwind 4 CSS-first:@theme / @utility / @custom-variant 全部写在 src/styles/index.css,不再有 tailwind.config.js |
| 移动适配 | postcss-px-to-viewport-8-plugin | ^1.2.5 | 仅转换 .vue 样式块内的 px → vw(设计稿宽 375),媒体查询与 1px 不转换 |
| 工具库 | @vueuse/core | ^13.9.0 | useIntersectionObserver、useScroll、useEventListener、useElementBounding、useScrollLock、useVModel 等覆盖了本站几乎全部浏览器交互原语 |
| 动画 | gsap | ^3.14.2 | 详情弹层从卡片中心点放大/收缩的进出场动画(media-viewer 的 JS 钩子) |
| 下载 | file-saver | ^2.0.5 | 跨域图片/视频下载;docs/dependency-upgrade-plan.md 标注其停更但稳定,可评估用原生 <a download> 替代 |
| 图标 | @lucide/vue | ^1.37.0 | 描边图标体系;src/libs/icon-map.ts 维护字符串名到组件的映射 |
| 代码风格 | prettier + prettier-plugin-tailwindcss | ^3.5.3 / ^0.6.14 | .prettierrc 统一格式;无 ESLint(⚠️ 待确认:仓库内未见 lint 配置与脚本) |
几个值得展开的选型决策:
为什么直连 Pexels 而不经过 axios 封装? src/utils/pexels.ts 文件头注释写明:直连 api.pexels.com,不经过后端代理与 axios 封装,"避免触发 request 里的接口签名与 401 登出逻辑"——这是账号体系移除后的产物。认证就是一个裸请求头 Authorization: <API_KEY>(无 Bearer 前缀),错误分级用自建的 PexelsApiError 类承载,整个客户端不到 400 行,没有为它引入任何 HTTP 库。原生 fetch 的能力(JSON 解析、状态码判断)对这个体量的协议层完全够用,少一个依赖就少一份升级负担,这与 docs/dependency-upgrade-plan.md 把 axios 列入"已移除依赖"的记录一脉相承。
为什么图片列表用自研瀑布流而不是现成库? src/libs/waterfall/index.vue 同时承担"绝对定位瀑布流 + 虚拟滚动 + 响应式列数"三件事,与本项目"卡片高度由图片原始宽高比推算、容器可滚动祖先不固定"的页面结构深度耦合(它需要向上查找 overflow-y: auto 祖先绑定滚动)。通用库很难同时满足这三点,且项目已有完整的列高计算工具(src/libs/waterfall/utils.ts)。虚拟滚动的价值在无限滚动场景下会被放大:用户可能一口气翻几十页,若 DOM 随数据线性增长,移动端很快卡顿;该组件只渲染视口上下各 400px 缓冲内的卡片(源码常量 VISIBLE_BUFFER = 400),DOM 数量恒定。
为什么不用现成 UI 框架(Vant / Element 等),而是自建 src/libs 组件库? 本站的 UI 需求高度垂直:底部弹层、hover 气泡、分段控件、瀑布流、无限列表、全局消息,共六个积木。它们都要求与主题系统(dark: class 驱动)、rem 缩放体系(根字号 min(10vw, 40px))深度咬合,第三方库的固定 px 尺寸反而会破坏流式缩放。src/libs/index.ts 用 import.meta.glob 把这些组件以 m- 前缀异步注册为全局组件——模板里零 import、且不用的组件不进首屏包(message 例外,因命令式渲染被显式排除在 glob 之外,注释记录了避免 [INEFFECTIVE_DYNAMIC_IMPORT] 的原因)。
为什么分类数据本地静态化? src/store/index.ts 中 useCategoryData 的注释直言:"分类已本地静态化,不再请求后端"。13 个分类的 id 就是 Pexels 搜索关键词,本地一份常量即可,省下一次请求配额(Pexels 默认约 200 次/小时,配额是本项目最稀缺的资源,后文多处设计都围绕它展开)。src/constants/index.ts 的注释还记录了词表的 bug 史:早期后 4 项 id 重复会导致重复 key 警告与选中滑块错位,因此现在每个 id 必须唯一且即搜索词。
为什么选 @vueuse/core 这么重? 数一下它的实际使用点就明白:懒加载指令与合集封面的 useIntersectionObserver、滚动恢复的 useScroll、弹层滚动锁 useScrollLock、卡片中心点 useElementBounding、事件绑定 useEventListener、双向下拉 useVModel——浏览器交互原语几乎全部经由它,且它按需 tree-shaking,实际进包的只有被用到的小模块(构建配置里单独分进 vendor-utils)。
接手维护前,先掌握六个贯穿全项目的概念,读任何文件都会反复遇到它们:
降级(Fallback)。当 VITE_PEXELS_API_KEY 与 VITE_PEXELS_PROXY_BASE 都未配置时,src/api/pexels.ts 不实例化请求客户端,所有接口返回 Picsum 占位图、BigBuckBunny 示例视频或"示例合集"引导卡;即便有 Key,网络抖动类错误也会临时降级,但 401/403/429 一律上抛。数据"真与假"的判定被隔离在这一个文件里。
瀑布流虚拟滚动。m-waterfall 把每张卡片绝对定位到"当前最矮的列",并通过滚动监听只渲染视口附近的卡片;卡片高度在图片加载前就按原始宽高比算出((列宽/原图宽)×原图高),因此滚动不会跳动。
keep-alive 名单。src/views/layout/index.vue 里 CACHE_NAMES = ['home','videos','collections'],三个一级导航页常驻内存;组件 name 必须与路由名一致才能命中缓存(每个页面都有 defineOptions({ name: ... }))。缓存带来两个衍生问题——滚动位置丢失(用 useScrollRestore 恢复)与失活期间的状态同步(列表页在 onActivated 里比对补拉)。
请求序号(requestSequence)。每个列表页用一个自增整数给请求编号,响应回来时序号对不上就丢弃,解决快速切换筛选条件时旧响应覆盖新响应的竞态;组件卸载时再 +1,让在途响应也不写回。
isMobileTerminal。src/utils/helpers.ts 导出的响应式布尔值,按"移动端 UA 正则 + 触摸能力 + 视口宽 <1024"判定终端,是双端分支的唯一开关(例如 iPadOS 伪装 Mac UA 时靠后两个条件兜底)。
配额意识。Pexels 免费 Key 限流约 200 次/小时,本项目几乎所有"反直觉"的设计——分类静态化、合集封面排队 + 429 熔断、total 截断 8000、失活期间不发请求——都是在给配额省支出。改代码时新增任何"页面加载即请求"的逻辑都要先问一遍:这个请求能不能晚点发、能不能不发、能不能只发一次。
PEXELS/
├── index.html # 入口 HTML(zh-CN,标题「PEXELS 精选图片和视频」,含第三方统计脚本)
├── package.json # 包名 front;scripts: dev/build/preview/type-check
├── vite.config.ts # Tailwind 插件、px-to-viewport、@ 别名、/api/pexels 开发代理、manualChunks 分包
├── tsconfig.json # strict + noUncheckedIndexedAccess;@/* 路径映射
├── .env.example # 环境变量模板(VITE_PEXELS_API_KEY / VITE_PEXELS_PROXY_BASE)
├── .env.development # 开发环境变量(被 .gitignore 忽略)
├── .env.production # 生产环境变量(被 .gitignore 忽略)
├── .npmrc # optional=false,规避 pnpm 11 构建脚本报错
├── docs/
│ └── dependency-upgrade-plan.md # 依赖现状与升级踩坑记录(本仓库唯一的历史文档)
├── public/
│ └── favicon.ico # 站点图标
└── src/
├── main.ts # 应用入口:createApp + 路由 + Pinia + 组件库 + 指令 + 主题初始化 + unhandledrejection 兜底
├── App.vue # 一级路由出口(仅 router-view)
├── env.d.ts # ImportMetaEnv 类型声明 + Vue 模块增强
├── types/vendor.d.ts # 缺少官方类型的第三方声明(md5,遗留)
├── api/
│ └── pexels.ts # Pexels 接口门面:参数收敛、无 Key 降级、401/403/429 抛出、total 截断
├── components/ # 跨页面业务组件
│ ├── media-viewer/index.vue # 图片/视频详情弹层(gsap 动画、键盘/手势、地址同步、下载)
│ └── media-hover-actions/index.vue # PC 端卡片 hover 的下载/复制浮层
├── constants/index.ts # 分类词表 CATEGORY_NOMAR_DATA、主题常量 THEME_*、CategoryItem 类型
├── directives/
│ ├── index.ts # 指令批量注册(import.meta.glob eager)
│ └── modules/lazy.ts # v-lazy 图片懒加载指令
├── libs/ # 通用组件库(m-* 前缀,自动注册)
│ ├── index.ts # import.meta.glob 异步注册 m-button/m-popup/...;排除 message
│ ├── icon-map.ts # 图标名字符串 → lucide 组件映射
│ ├── button/index.vue # m-button:type(primary/main/info) + size + icon + loading
│ ├── popup/index.vue # m-popup:移动端底部弹层 + body 滚动锁
│ ├── popover/index.vue # m-popover:hover 气泡,四向定位,200ms 延迟关闭
│ ├── segmented/index.vue # m-segmented:iOS 风格分段控件(一级导航/合集 Tab)
│ ├── message/ # message() 命令式全局提示(独立容器防互相覆盖)
│ ├── infinite-list/index.vue # m-infinite-list:触底加载/失败重试/加载完成
│ └── waterfall/ # m-waterfall:绝对定位瀑布流 + 虚拟滚动 + 响应式列数
├── router/index.ts # 路由表:layout 嵌套 6 个子路由 + /404 + catchAll
├── store/index.ts # pinia 实例 + app/category/theme 三个 store(theme 持久化)
├── styles/index.css # Tailwind 4 CSS-first 配置:根字号、dark 变体、@theme 调色板、scrollbar @utility
├── utils/
│ ├── pexels.ts # PexelsClient 类 + 全部类型定义 + Picsum/示例视频降级数据生成
│ ├── pexels-collection-cache.ts # 合集封面内存缓存 + 3 并发排队调度 + 429 熔断冷却
│ ├── theme.ts # 主题应用(html class 切换)+ 系统主题监听
│ ├── viewer.ts # 详情弹层统一数据模型 ViewerItem 与多来源归一化 toViewerItem
│ └── helpers.ts # isMobileTerminal、randomTint、formatDuration、useScrollRestore
└── views/
├── layout/index.vue # 全站骨架:header + keep-alive 路由出口 + 移动端 tabbar
├── layout/components/
│ ├── main-tabs.ts # 一级导航配置(图片/视频/集合)与路由前缀匹配
│ ├── header/index.vue # 顶栏:logo + PC 分段导航 + 搜索 + 主题
│ ├── header/header-search/index.vue # 全局搜索框(回车触发,合集页跳回图片页)
│ ├── header/header-theme.vue # 主题三态下拉(m-popover)
│ └── tabbar/index.vue # 移动端底部导航
├── main/index.vue # 首页(路由名 home,keep-alive 缓存)
│ ├── components/navigation/index.vue # 分类导航(移动端滑块+弹窗;PC 墨条指示)
│ └── components/list/ # 列表容器 list/index.vue + 卡片 item.vue
├── videos/index.vue # 视频页(popular/search 双模式)
│ └── components/list/index.vue + video-item.vue
├── video/index.vue # 视频详情直达页(/videos/:id)
├── collections/index.vue # 合集列表页(featured/mine 双 Tab)
│ └── components/collection-item.vue # 封面 IntersectionObserver 按需加载
├── collection/index.vue # 合集详情页(照片视频混排)
│ └── components/media-item.vue
├── pins/index.vue # 图片详情直达页(/pins/:id)
└── error/404/index.vue # 404 页结构上有两条清晰的分层线索。纵向按"api → store/utils → libs/components → views"组织:src/api/pexels.ts 是唯一允许了解"是否有 Key、是否降级"的位置;src/utils/pexels.ts 是协议层,只懂 Pexels 协议与归一化,不懂业务;src/libs 是纯 UI 积木,不 import 任何业务模块;src/views 组合以上一切。横向按"双端一套"组织:除 tabbar(仅移动端渲染)与 navigation(两端两套模板)外,几乎所有组件同时服务两端,靠 isMobileTerminal 一个布尔值分叉。
pnpm install # 安装依赖(.npmrc 已设 optional=false)
pnpm dev # 启动开发服务器(vite --host,监听 0.0.0.0,便于真机调试)
pnpm build # 生产构建,产物输出 dist/
pnpm preview # 本地预览构建产物
pnpm type-check # vue-tsc --noEmit 类型检查(唯一纯 JS SFC 盲区见《09-测试与质量保障》2.2)第一次运行时若未配置 Pexels API Key,页面会显示 Picsum 占位图与示例合集卡——这是预期行为(README.md「常见问题」明确说明)。要看到真实数据,复制 .env.example 为 .env.development,填入 VITE_PEXELS_API_KEY(申请地址 https://www.pexels.com/api/),重启 dev server 即可。开发模式下即使不配置代理变量,请求也会自动走 Vite 的 /api/pexels 代理(vite.config.ts 的 server.proxy),以规避 localhost 来源直连 Pexels 被 Cloudflare 拦截的问题;生产构建则默认直连 api.pexels.com,需要页面部署在真实域名下。
阅读源码的建议顺序:src/main.ts(启动装配)→ src/router/index.ts(页面地图)→ src/store/index.ts(全局状态)→ src/api/pexels.ts + src/utils/pexels.ts(数据从哪来)→ src/views/layout/index.vue(骨架)→ src/views/main/components/list/index.vue(最核心的列表页逻辑)→ src/libs/waterfall/index.vue(最复杂的 UI 组件)。
两条上手后的自检路径:改样式前先读 README.md 的「设计约定」与 02 篇第 6 节——rem 间距被根字号放大约 2.5 倍、精确尺寸用任意值 px、lucide 描边图标禁用 fill-*,这三条违反任意一条都会出现"本地看没问题、真机全乱"的隐性缺陷;加新接口前先读 03 篇——所有 Pexels 方法进 PexelsClient(协议层)、所有降级决策进 src/api/pexels.ts(门面层),跳过门面直连协议层会绕开错误分级与配额保护。
本地调试的两个实用技巧:一是 pnpm dev 自带 --host,手机连同一 Wi-Fi 访问开发机 IP 即可真机调试,触屏手势(弹层左右滑动切换)只有真机或浏览器设备模拟才能验证;二是想临时验证"无 Key 降级"效果,把 .env.development 里的 Key 注释掉重启即可,不需要动任何代码。
本目录共 7 篇文档,设计目标是:读者只读文档、不读源码,即可理解项目设计并接手维护。按角色推荐读法:
首次接手的维护者:按 00 → 01 → 04 → 02 的顺序读。00 建立全局认知,01 讲清数据怎么流动、为什么这样设计,04 补齐"数据长什么样",02 再看"页面怎么组装",最后把 05 当作操作手册对照源码走读。
二次开发者(加功能):00 → 03 → 05。加新接口必读 03(端点清单、降级矩阵、限流);加新页面/交互必读 05(每条流程都列了涉及文件清单,照着扩展即可)。
部署运维:直接读 06,重点是环境变量表、SPA 回退配置与安全注意事项三节。
00-项目总览.md(本文):定位、功能、技术栈、目录、上手。
01-系统架构.md:整体架构图、分层职责、端到端数据流、关键设计决策与设计模式。
02-页面设计.md:路由与页面总表、各页面布局与交互、组件树、Pinia 设计、UI 交互流程。
03-接口设计.md:Pexels API 端点清单、Key 配置与 Picsum 降级映射、限流与错误处理。
04-数据模型.md:store 状态结构、本地持久化、媒体数据实体、数据生命周期。
05-核心流程.md:搜索浏览、详情与下载、视频播放、主题切换、无 Key 降级等端到端流程。
06-部署运行.md:环境要求、启动步骤、环境变量、构建产物与静态部署、质量工具。