个人星空摄影作品展示网站,记录每一次追星之旅
个人星空摄影作品集,围绕作品展示、地点标记、EXIF 数据可视化构建。不做 SaaS,只做一件事:把星空照片呈现好。
- 摄影画廊 — 瀑布流布局展示作品,支持分类和标签筛选
- 双主题 — 默认暗色(星空专用暖色高光替代蓝色调),一键切亮色
- 地图标记 — MapLibre 集成,每张照片标注拍摄地点,交互式浏览
- EXIF 数据 — 自动提取相机参数(ISO、快门、光圈、焦距、时间)
- 双视图模式 — "沉浸" / "列表" 自由切换,切换时照片在两种版式之间连续移动而不是硬切(浏览器 View Transitions,零依赖)
- 筛选过渡 — 切换城市/分类时画廊整体淡入淡出,不硬跳(React
<ViewTransition>,零依赖) - AI 标题/分类建议 — 上传时由豆包视觉模型(Volcengine Ark)给出标题与分类建议,手动触发、可改可不改
- 管理后台 — 登录后可上传、编辑、管理照片(admin 路由)
- 个人星空摄影作品展示
- 摄影爱好者的自建作品集
- 学习 Next.js + Prisma + R2 的全栈项目参考
| 功能名称 | 功能说明 | 技术栈 | 更新时间 | 版本 |
|---|---|---|---|---|
| 摄影画廊 | 瀑布流布局展示 | React + Tailwind | 2026-07-04 | v1.0 |
| 地图标记 | 照片拍摄地点标记(逐点渲染,无聚合) | MapLibre GL | 2026-07-04 | v1.0 |
| EXIF 提取 | 自动读取相机参数 | exifr | 2026-07-04 | v1.0 |
| 双主题 | 亮/暗切换,默认暗色 | React Context + localStorage | 2026-07-04 | v1.0 |
| 双视图模式 | 沉浸/列表切换 | React Context | 2026-07-04 | v1.0 |
| AI 标题/分类建议 | 上传时给出标题与分类建议(不含描述) | 豆包 Seed 2.0 / Volcengine Ark | 2026-06-20 | v1.0 |
| 管理后台 | 照片上传/编辑/管理 | Next.js admin route | 2026-06-21 | v1.0 |
| 图片存储 | Cloudflare R2 对象存储 | @aws-sdk/client-s3 | 2026-06-14 | v1.0 |
| 分享图 | 经典 EXIF + 签名 SVG 双模板 | Sharp 服务端合成 | 2026-07-24 | v1.8 |
| 分享图 UI | 弹窗预览 + 模板切换 | shadcn Dialog + YARL portal | 2026-07-26 | v2.0 |
| 原图下载 | WebP→PNG 转码下载 | Sharp 服务端转码 | 2026-08-06 | v2.0.1 |
| 分享图稳定性 | 已缓存直接返回 PNG + CDN 边缘缓存 | R2 Custom Domain + 服务端代理 | 2026-08-06 | v2.0.1 |
| 上传/下载上限 | 上传 ≤20MB 且限图片 MIME;下载源图 ≤30MB、解码 ≤5000 万像素 | 服务端校验 + sharp limitInputPixels |
2026-09-19 | v2.1 |
| 视图切换过渡 | 瀑布流 ↔ 单列:同一张照片跨版式配对,连续移动而不硬切 | View Transitions API(Next 16 内置,零依赖) | 2026-09-27 | v2.2 |
| 筛选过渡 | 切换城市/分类时画廊淡入淡出,照片不再硬跳 | React <ViewTransition> + experimental.viewTransition |
2026-09-27 | v2.3 |
| 技术 | 版本 | 用途 | 官网 |
|---|---|---|---|
| Next.js | 16.2.9 | React 框架 | https://nextjs.org |
| React | 19.2.4 | 前端 UI | https://react.dev |
| TypeScript | 5 | 类型安全 | https://www.typescriptlang.org |
| Tailwind CSS | 4 | 样式 | https://tailwindcss.com |
| Prisma | 6.19.3 | ORM + SQLite | https://prisma.io |
| SQLite | — | 数据库 | — |
| MapLibre GL | 5.24.0 | 地图渲染 | https://maplibre.org |
| 豆包 Seed 2.0 | — | AI 标题/分类 | https://volcengine.com |
| Cloudflare R2 | — | 图片存储 | https://cloudflare.com |
| Sharp | 0.35 | 图片处理 | https://sharp.pixelplumbing.com |
用户浏览器
│
├── Next.js (App Router)
│ ├── (front)/ → 公开页面(画廊、地图、照片详情)
│ ├── admin/ → 管理后台(需密码)
│ └── api/ → REST API(照片 CRUD、AI 建议)
│
├── Prisma → SQLite ← 结构化数据
├── Cloudflare R2 ← 原始图片存储
└── 豆包/Volcengine Ark ← AI 标题 + 分类
camlife-lite/
├── src/
│ ├── app/
│ │ ├── (front)/ # 前端页面
│ │ │ ├── page.tsx # 首页画廊(SSR 首屏 20 张)
│ │ │ ├── photo/ # 照片详情
│ │ │ └── map/ # 3D 地球照片地图
│ │ ├── admin/ # 管理后台(上传 + 管理)
│ │ ├── api/ # 7 个 API 端点
│ │ │ ├── auth/ # 登录/退出
│ │ │ └── photos/ # CRUD + AI suggest
│ │ ├── layout.tsx
│ │ └── globals.css # 亮/暗双主题变量
│ ├── components/
│ │ ├── admin/ # 上传队列 + 照片管理
│ │ ├── gallery/ # 瀑布流卡片 + Feed 流 + 灯箱
│ │ ├── layout/ # Header 胶囊栏 + 侧边栏
│ │ └── ui/ # shadcn 基础组件
│ ├── context/ # 视图模式(waterfall/feed)
│ ├── features/map/ # MapLibre 地图(逐点 Marker,无聚合)
│ ├── hooks/ # 自定义 Hooks
│ ├── lib/ # AI、R2、auth、geocode、prisma、图片流水线、视图模式常量
├── prisma/
│ └── schema.prisma # Photo 模型
├── scripts/ # 工具脚本
├── docs/ # 项目文档
├── deploy-dist/ # 部署产物
├── ARCHITECTURE.md # 架构审查(深/浅模块、seam、风险)
├── CLAUDE.md # AI 助手配置
├── deploy.sh # 一键部署脚本
├── vps-setup.sh # VPS 初始化
└── package.json
- Node.js >= 20.9(Next.js 16 要求)
- npm
- Cloudflare R2 账号(图片存储)
- 豆包/Volcengine Ark API Key(可选,用于 AI 标题/分类建议)
# 1. 克隆 + 安装
git clone https://github.com/OUENMING/bldcam.git
cd camlife-lite && npm install
# postinstall 会自动执行 prisma generate,生成 @prisma/client
# (缺这一步时运行期会报 "@prisma/client did not initialize yet")
# 2. 配置 .env(该文件不进版本库)
touch .env
# 必填:R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET / R2_ENDPOINT /
# R2_REGION / R2_PUBLIC_URL、ADMIN_PASSWORD、ADMIN_TOKEN
# 可选:ARK_API_KEY、ARK_BASE_URL(豆包 AI 标题/分类建议)
# 3. 初始化数据库
npx prisma db push
# 4. 启动开发服务器
npm run dev打开 http://localhost:3000 访问;后台在 http://localhost:3000/admin。
npm run dev # 启动开发服务器
npm run build # 构建生产版本
npm run start # 启动生产服务器
npm run lint # 代码检查
npx prisma studio # 数据库管理界面npm run build
bash deploy.sh # 一键推送到 VPS项目部署在 https://bldcam.page,VPS + PM2 + Nginx 运行。
deploy.sh 会在 npm run build 之前清掉 deploy-dist。顺序反了的话,output: 'standalone' 会把项目根(含上一轮的 deploy-dist)一起 trace 进新产物,再被 cp -r .next/standalone/* 复制进去 —— 每部署一次多套一层(实测到过 32 层、263MB,等于每次都在传历史部署的副本)。
图片经 Cloudflare R2 Custom Domain 提供:cdn.bldcam.page 挂在 R2 桶 photosave 的 Settings → Custom Domains,对象带 max-age=31536000, immutable,启用边缘缓存。
分享图/图片变慢或"有时加载失败"时,依次查两处:
- 该 Custom Domain 连接是否还在
- GET 响应头
cf-cache-status是否为HIT
- 极简:不做 SaaS,不做商业化,只做好"展示星空照片"这一件事
- 沉浸:暗色主题 + 暖色高光替代蓝色调,减少视觉干扰
- 自主:自建 VPS + Cloudflare R2,不依赖第三方平台
- 豆包 CoT 模型的 output 在
output.find(o => o.type === "message"),不能直接取output[0] - Sharp 用
fit: "inside"保留原比例,别用fit: "cover"——否则瀑布流像砖墙一样死板 - 腾讯云 22 端口被拦截 — 换 2222 端口连接
- rsync
--delete把数据库清了 — 必须加--exclude='dev.db' - 分享图路由只有一个响应形态 — 曾按
Accept分支(image/*走 307 直连 CDN,其余代理 PNG)。但Accept不代表客户端能否跟随跨域重定向(R2 无 CORS 头,fetch跟不了),共享缓存还会把 307 回放给期待 PNG 的客户端。现在一律回 200 PNG,由 CDN 缓存本路由。og:image 指向photo.url直链,不经过这里 - R2 的 HEAD 请求恒返回
cf-cache-status: DYNAMIC— 测 R2/CDN 缓存必须用 GET,用curl -I会被误导;且 R2/CDN 无 CORS 头时,前端 fetch 不能跟随跨域 307,只能服务端代理或用<img>直载 sharp().resize(16)只固定宽度 — 高度按比例自适应,1×20000 这种极端长宽比会生成 16×320000 的图,PNG 体积与内存都会爆。LQIP 用resize(16, 16, { fit: "inside" })框住两边DeleteObjectsCommand的Quiet: true会吞掉部分失败 — 该模式下成功项不回,失败项只出现在响应的Errors数组里;不读它,删除失败和成功长得一模一样。另外单次上限 1000 个 key,超出整体报错,要分批R2_PUBLIC_URL默认空串,url.startsWith("")恒为 true — 用它做前缀判断前必须先确认非空,否则整个 URL(含协议域名)都会被当成对象 key- PNG 的
quality不是压缩质量 — sharp 里它意味着"用最少颜色达到给定质量",会开启调色板量化(≤256 色),照片暗部直接出色带。分享图踩过。要调压缩用compressionLevel(0-9,无损);只传quality而没开palette时它是空操作 - 上传流水线必须有
.rotate()— 无参.rotate()按 EXIF 烘焙方向。缺了它像素不转、编码后 orientation 标签也丢了(实测输出 webp 的orientation为undefined),竖拍照片浏览器无从补救。尺寸取metadata().autoOrient,别手算orientation >= 5的交换 - 原图下载的缓存要跟着内容走 — URL 稳定但内容会变(旋转会换 R2 对象)。
immutable会让换图后一年内所有缓存返回旧图。现在用updatedAt派生 ETag +must-revalidate,命中 304 时连 sharp 转码都省掉 - 线上验证先排除边缘缓存 — 部署后立刻测可能拿到 Cloudflare 的旧响应(曾因此误判"新代码没上线")。看
cf-cache-status,或隔一会儿重发。新增的路由尤其容易误判:对它的请求会命中一个被缓存的 404(本次加/opengraph-image.png后在线上读到 404,而文件明明已在 VPS 上、直连本机 3000 端口是 200)。缓存串必须每次请求现生成 —— 把同一个?cb=xxx复用在多次 curl 上,后几次读的就是第一次填充的那份缓存,等于没加;本次因此把「已修好」误判成「没生效」。另外 Next 自己也有页面缓存(_not-found的.meta里x-nextjs-stale-time: 300,响应头x-nextjs-cache: HIT),读到旧内容时两个来源都要看 - Tailwind v4 会把
docs/*.md当源码扫 — 文档里写pb-[calc(1rem+env(...))]这种省略号简写会被照字面生成 CSS,env(...)非法 → Turbopack 的 CSS 解析器直接失败 → 整个页面白屏。next build用的 PostCSS 更宽松,所以构建通过、只有 dev 坏,很容易误判成"代码有问题"。用@source not "../../docs"把文档排除出扫描范围 "use client"模块导出的常量,在 Server Component 里不是那个常量 — 服务端 import 到的是registerClientReference的代理对象,cookies().get(VIEW_MODE_COOKIE)永远拿到undefined。症状极隐蔽:cookie 确实写进去了、客户端切换一切正常,只是一刷新就回默认值。常量要和 provider 分文件,走普通模块导出- 卡片的入场必须是动画,不能是初始状态;而且已经停在视口里的卡片绝不能播它 — 入场写成「默认
opacity: 0,IntersectionObserver 触发后改类」时,卡片存在一个隐藏态,而 View Transition 的「之后」快照正好拍在那个状态上,每张照片都会被拍成空白框。我为此先后写过两版补丁(JS 属性开关 →:active-view-transition兜底),两版都是治标——根因是那个隐藏态本身不该存在:卡片默认可见,入场改成 IO 触发的@keyframes动画(card-reveal),补丁整套删掉。但这样还不够:筛选/视图切换时新卡片是在视口内挂载的,IO 一响就会播动画、快照拍到动画第一帧。所以use-reveal.ts判的不是「在不在视口内」而是「挂载时不在视口内、之后滚进来」——它看 IO 的第一次回调,若已相交就永不播。实测:新加载 11 张卡片 0 个动画、opacity: 1、translate: none;滚进来的一张translate从13.85px走到0(旧代码写transition-[opacity,transform]而偏移走translate,所以那个升起从前根本没生效过)。残余窗口:卡片正在播入场的那 700ms 里点切换/筛选,退出的旧快照会拍到它的中途透明度 —— 但那些卡片本来就在被替换,实测看不出可见影响,因此没为它加兜底规则(加了要把:active-view-transition依赖请回来,不划算)。两处「是否算在屏内」的边界必须共用同一个常量:同步判定用真实视口、观察器用rootMargin: 200px时,落在视口下方 0–200px 带内的卡片会被前者判成折叠外交给观察器,而后者首次回调因外扩立刻报相交,于是它挂着屏外的位置把入场跑完了 —— 删掉firstReport后没有任何东西抑制这次首报 - 只给
::view-transition-group(*)设时长不够 — 位移(group)和淡入淡出(old/new)是不同的 UA 动画,只设 group 时 old/new 会停在浏览器默认的 150ms:照片还没移动完就已经淡完了。实测一次筛选过渡里 50 个动画,46 个 400ms、4 个 150ms(那 4 个是侧边栏链接的颜色过渡,无关)。三条必须一起设 - Next 的路由自己不会启动视图过渡 — 挂钩
document.startViewTransition后点筛选链接实测 0 次调用。驱动方是 React 的<ViewTransition>组件:experimental.viewTransition: true(默认 false)和树里真的有组件,缺一不可。React 文档还写明不要自己调startViewTransition(它会打断你)、且flushSync会让它跳过 —— 所以本项目 ① 的手动调用和 React 的机制是并存的两套,靠二者不同时触发;同一时刻只会有一次过渡,另一次被跳过而不是报错 - Tailwind v4 的偏移走的是独立的
translate属性,不是transform;而且压缩器会把两条声明悄悄合并掉 —translate-y-4编译成translate: var(--tw-translate-x) var(--tw-translate-y),所以任何针对transform的写法都管不到它(旧入场动画写transition-[opacity,transform]因此从不播这个位移)。另外实测:同一个规则里写transform: none; translate: none,构建产物会变成transform: translate(0)——translate:none出现 0 次,偏移原封不动留着。不要同时写这两条;本项目card-reveal的 keyframes 只用translate,压缩器没有可合并的对象就放行了 - 过渡被跳过时 reject 的是
ready,不只是finished— 连点两次 toggle 会让第二个过渡被跳过(AbortError)。只给finished加catch实测无效(仍泄漏 2 条),因为ready从没被读过、它的 rejection 无人处理。两个都要接;updateCallbackDone不 reject(实测)。判别手法:unhandledrejection事件带e.promise,直接和过渡对象的 promise 比身份,不用猜 - 过渡被跳过时,
startViewTransition的 update 回调根本不会执行 — 所以写在回调里的状态变更会一起丢掉,控件变成纯空操作(不只是动画没了)。本项目视图切换因此把状态更新放在回调里(必须如此,否则旧快照拍不到旧状态),并在ready被拒时回落到普通更新。实测复现条件:文档隐藏(后台标签)→ready以InvalidStateError被拒且回调不跑。线上部署后在后台标签里验证时才发现;真实用户在可见标签下不受影响(可见标签实测 101 个动画、模式与 cookie 都正常) <ViewTransition>必须包住三元的两个分支 — 边界放在「有照片」那一支里时,筛到 0 张的更新会让边界在同一次更新中直接卸载,整块内容硬切、没有过渡。提到三元外层让两支共享边界后,从空状态点筛选实测 1 次过渡、0 报错<ViewTransition>的 exit/enter 要求边界「在任何 DOM 节点之前」 — 边界上面只要有 DOM 祖先(本项目page.tsx里是<div>/<main>),离开该页面的导航就不触发过渡。实测点卡片标题跳详情页时document.startViewTransition调用 0 次。所以「画廊→详情共享元素」不是加个view-transition-name就能成的,得把边界提到 layout 层或改成每卡一个具名边界- 视图过渡会给每个命名元素截图,屏幕外的也照截 — 依据 CSSWG issue #8282(Chrome 的 spec 作者):能「不相交就不命名」的
auto值至今仍只是提案,visible(全截)才是当前行为。本项目有无限滚动,卡片只增不减,所以「给每张卡命名」等于「给每张卡截图」:实测加载 77 张时 78 个命名元素、386 个动画、两条长任务(79ms + 96ms),用户反馈滚下去切换严重掉帧。修法是过渡前只给视口附近的卡片留名字(远处加data-vt-far,靠一条view-transition-name: none !important压过卡片的内联声明 —— 样式表的!important能压内联普通声明,这条实测过)。修后:动画 386 → 21/146(按方向)、命名元素 78 → 5/30、长任务 2 条 → 1 条,剩下那条是同步渲染。要点:① 进入的卡片是新 DOM 节点,标记必须在flushSync之后、回调返回之前重新打一遍 ② 两次标记要传不同的集合,且要传并集 —— 见下条 - 只给「要离开的那个版式」的附近卡片命名,会让新版式里刚进入视口的卡片完全没名字 —— 而没名字的卡片不是「动画差」,是「没有动画」,直接瞬现。瀑布流约 7,400px、单列约 64,600px,差 8.8 倍,而切换会保留滚动偏移 —— 于是同一偏移落在完全不同的照片上。实测中途切换:新版式里可见的 1 张卡全部没名字,于是 111 个动画全在屏幕外播放,用户眼前那张直接闪出来。修法是回调里
flushSync之后取新版式的附近集合,与旧集合并集再标记(并集只多出 1–2 张,命名总数仍是 28/78)。这条是我自己上一版修法引入的回归,而且我的测量当时没抓到 —— 我数的是动画条数(111 > 0,通过),测不到「动画覆盖的是屏幕外那批」。数动画条数是代理指标,用户报的才是真症状
详见 docs/项目总结.md — 完整时间线、13 个难点、经验教训。
本项目 UI 灵感和部分交互方案参考了 sun0225SUN/camlife,一个出色的摄影作品集项目。架构自行从零搭建,感谢开源社区。
MIT
Copyright (c) 2026 Owen