Skip to content
OUENMINGPublic

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

星空摄影作品集 — BLDcam

个人星空摄影作品展示网站,记录每一次追星之旅

Version License: MIT Next.js Deployed

项目介绍

个人星空摄影作品集,围绕作品展示、地点标记、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,启用边缘缓存。

分享图/图片变慢或"有时加载失败"时,依次查两处:

  1. 该 Custom Domain 连接是否还在
  2. GET 响应头 cf-cache-status 是否为 HIT

设计哲学

  • 极简:不做 SaaS,不做商业化,只做好"展示星空照片"这一件事
  • 沉浸:暗色主题 + 暖色高光替代蓝色调,减少视觉干扰
  • 自主:自建 VPS + Cloudflare R2,不依赖第三方平台

开发笔记

  1. 豆包 CoT 模型的 output 在 output.find(o => o.type === "message"),不能直接取 output[0]
  2. Sharp 用 fit: "inside" 保留原比例,别用 fit: "cover"——否则瀑布流像砖墙一样死板
  3. 腾讯云 22 端口被拦截 — 换 2222 端口连接
  4. rsync --delete 把数据库清了 — 必须加 --exclude='dev.db'
  5. 分享图路由只有一个响应形态 — 曾按 Accept 分支(image/* 走 307 直连 CDN,其余代理 PNG)。但 Accept 不代表客户端能否跟随跨域重定向(R2 无 CORS 头,fetch 跟不了),共享缓存还会把 307 回放给期待 PNG 的客户端。现在一律回 200 PNG,由 CDN 缓存本路由。og:image 指向 photo.url 直链,不经过这里
  6. R2 的 HEAD 请求恒返回 cf-cache-status: DYNAMIC — 测 R2/CDN 缓存必须用 GET,用 curl -I 会被误导;且 R2/CDN 无 CORS 头时,前端 fetch 不能跟随跨域 307,只能服务端代理或用 <img> 直载
  7. sharp().resize(16) 只固定宽度 — 高度按比例自适应,1×20000 这种极端长宽比会生成 16×320000 的图,PNG 体积与内存都会爆。LQIP 用 resize(16, 16, { fit: "inside" }) 框住两边
  8. DeleteObjectsCommand 的 Quiet: true 会吞掉部分失败 — 该模式下成功项不回,失败项只出现在响应的 Errors 数组里;不读它,删除失败和成功长得一模一样。另外单次上限 1000 个 key,超出整体报错,要分批
  9. R2_PUBLIC_URL 默认空串,url.startsWith("") 恒为 true — 用它做前缀判断前必须先确认非空,否则整个 URL(含协议域名)都会被当成对象 key
  10. PNG 的 quality 不是压缩质量 — sharp 里它意味着"用最少颜色达到给定质量",会开启调色板量化(≤256 色),照片暗部直接出色带。分享图踩过。要调压缩用 compressionLevel(0-9,无损);只传 quality 而没开 palette 时它是空操作
  11. 上传流水线必须有 .rotate() — 无参 .rotate() 按 EXIF 烘焙方向。缺了它像素不转、编码后 orientation 标签也丢了(实测输出 webp 的 orientation 为 undefined),竖拍照片浏览器无从补救。尺寸取 metadata().autoOrient,别手算 orientation >= 5 的交换
  12. 原图下载的缓存要跟着内容走 — URL 稳定但内容会变(旋转会换 R2 对象)。immutable 会让换图后一年内所有缓存返回旧图。现在用 updatedAt 派生 ETag + must-revalidate,命中 304 时连 sharp 转码都省掉
  13. 线上验证先排除边缘缓存 — 部署后立刻测可能拿到 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),读到旧内容时两个来源都要看
  14. Tailwind v4 会把 docs/*.md 当源码扫 — 文档里写 pb-[calc(1rem+env(...))] 这种省略号简写会被照字面生成 CSS,env(...) 非法 → Turbopack 的 CSS 解析器直接失败 → 整个页面白屏。next build 用的 PostCSS 更宽松,所以构建通过、只有 dev 坏,很容易误判成"代码有问题"。用 @source not "../../docs" 把文档排除出扫描范围
  15. "use client" 模块导出的常量,在 Server Component 里不是那个常量 — 服务端 import 到的是 registerClientReference 的代理对象,cookies().get(VIEW_MODE_COOKIE) 永远拿到 undefined。症状极隐蔽:cookie 确实写进去了、客户端切换一切正常,只是一刷新就回默认值。常量要和 provider 分文件,走普通模块导出
  16. 卡片的入场必须是动画,不能是初始状态;而且已经停在视口里的卡片绝不能播它 — 入场写成「默认 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 后没有任何东西抑制这次首报
  17. 只给 ::view-transition-group(*) 设时长不够 — 位移(group)和淡入淡出(old/new)是不同的 UA 动画,只设 group 时 old/new 会停在浏览器默认的 150ms:照片还没移动完就已经淡完了。实测一次筛选过渡里 50 个动画,46 个 400ms、4 个 150ms(那 4 个是侧边栏链接的颜色过渡,无关)。三条必须一起设
  18. Next 的路由自己不会启动视图过渡 — 挂钩 document.startViewTransition 后点筛选链接实测 0 次调用。驱动方是 React 的 <ViewTransition> 组件:experimental.viewTransition: true(默认 false)和树里真的有组件,缺一不可。React 文档还写明不要自己调 startViewTransition(它会打断你)、且 flushSync 会让它跳过 —— 所以本项目 ① 的手动调用和 React 的机制是并存的两套,靠二者不同时触发;同一时刻只会有一次过渡,另一次被跳过而不是报错
  19. 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,压缩器没有可合并的对象就放行了
  20. 过渡被跳过时 reject 的是 ready,不只是 finished — 连点两次 toggle 会让第二个过渡被跳过(AbortError)。只给 finished 加 catch 实测无效(仍泄漏 2 条),因为 ready 从没被读过、它的 rejection 无人处理。两个都要接;updateCallbackDone 不 reject(实测)。判别手法:unhandledrejection 事件带 e.promise,直接和过渡对象的 promise 比身份,不用猜
  21. 过渡被跳过时,startViewTransition 的 update 回调根本不会执行 — 所以写在回调里的状态变更会一起丢掉,控件变成纯空操作(不只是动画没了)。本项目视图切换因此把状态更新放在回调里(必须如此,否则旧快照拍不到旧状态),并在 ready 被拒时回落到普通更新。实测复现条件:文档隐藏(后台标签)→ ready 以 InvalidStateError 被拒且回调不跑。线上部署后在后台标签里验证时才发现;真实用户在可见标签下不受影响(可见标签实测 101 个动画、模式与 cookie 都正常)
  22. <ViewTransition> 必须包住三元的两个分支 — 边界放在「有照片」那一支里时,筛到 0 张的更新会让边界在同一次更新中直接卸载,整块内容硬切、没有过渡。提到三元外层让两支共享边界后,从空状态点筛选实测 1 次过渡、0 报错
  23. <ViewTransition> 的 exit/enter 要求边界「在任何 DOM 节点之前」 — 边界上面只要有 DOM 祖先(本项目 page.tsx 里是 <div> / <main>),离开该页面的导航就不触发过渡。实测点卡片标题跳详情页时 document.startViewTransition 调用 0 次。所以「画廊→详情共享元素」不是加个 view-transition-name 就能成的,得把边界提到 layout 层或改成每卡一个具名边界
  24. 视图过渡会给每个命名元素截图,屏幕外的也照截 — 依据 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 之后、回调返回之前重新打一遍 ② 两次标记要传不同的集合,且要传并集 —— 见下条
  25. 只给「要离开的那个版式」的附近卡片命名,会让新版式里刚进入视口的卡片完全没名字 —— 而没名字的卡片不是「动画差」,是「没有动画」,直接瞬现。瀑布流约 7,400px、单列约 64,600px,差 8.8 倍,而切换会保留滚动偏移 —— 于是同一偏移落在完全不同的照片上。实测中途切换:新版式里可见的 1 张卡全部没名字,于是 111 个动画全在屏幕外播放,用户眼前那张直接闪出来。修法是回调里 flushSync 之后取新版式的附近集合,与旧集合并集再标记(并集只多出 1–2 张,命名总数仍是 28/78)。这条是我自己上一版修法引入的回归,而且我的测量当时没抓到 —— 我数的是动画条数(111 > 0,通过),测不到「动画覆盖的是屏幕外那批」。数动画条数是代理指标,用户报的才是真症状

项目总结

详见 docs/项目总结.md — 完整时间线、13 个难点、经验教训。

致谢

本项目 UI 灵感和部分交互方案参考了 sun0225SUN/camlife,一个出色的摄影作品集项目。架构自行从零搭建,感谢开源社区。

License

MIT

Copyright (c) 2026 Owen

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages