chore: 完善开源项目文档与结构,移除敏感原始图片

- 重写 README.md,添加在线体验、API 文档、部署指南、技术栈等
- 添加 MIT LICENSE
- 添加 CONTRIBUTING.md 贡献指南
- 添加 GitHub Issue/PR 模板
- 完善 .gitignore,防止原始图片和敏感文件误提交
- 从 git 中移除 25 个原始图片文件(.png/.jpg/非序号 webp)
- 项目结构迁移:functions/ -> src/,根目录静态文件 -> public/
- 添加 wrangler.toml 与 package.json 配置
This commit is contained in:
2026-06-06 20:52:59 +08:00
parent f68e19a3bd
commit f45a0068a2
69 changed files with 872 additions and 675 deletions

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
name: Bug 报告
about: 报告一个 Bug 或异常行为
title: "[Bug] "
labels: bug
---
## 问题描述
清晰简洁地描述你遇到的 Bug。
## 复现步骤
1. 步骤一
2. 步骤二
3. 步骤三
## 预期行为
描述你期望发生的结果。
## 实际行为
描述实际发生的结果。
## 环境信息
- **操作系统**: [例如 Windows 11 / macOS / Ubuntu]
- **Node.js 版本**: [例如 20.0.0]
- **Python 版本**: [例如 3.12]
- **Wrangler 版本**: [例如 4.x]
- **浏览器**: [如适用]
## 截图或日志
如有截图或错误日志,请贴在这里。
## 补充说明
任何其他上下文信息。

View File

@@ -0,0 +1,26 @@
---
name: 功能建议
about: 为项目提出新功能或改进建议
title: "[Feature] "
labels: enhancement
---
## 功能描述
清晰简洁地描述你想要的功能或改进。
## 使用场景
描述这个功能会在什么场景下使用,解决什么问题。
## 可能的实现方案
如果你有实现思路,可以简要描述。
## 替代方案
是否考虑过其他替代方案?
## 补充说明
任何其他上下文信息。

26
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,26 @@
## 描述
请简要描述这个 PR 的内容和目的。
## 变更类型
- [ ] Bug 修复
- [ ] 新功能
- [ ] 文档更新
- [ ] 代码重构
- [ ] 性能优化
- [ ] 其他
## 检查清单
- [ ] 代码已测试,本地 `npm run dev` 可正常运行
- [ ] 没有提交敏感信息API Key、账号等
- [ ] 没有提交原始图片到根目录 `mobile-image/` / `desketop-image/`
- [ ] 相关文档已更新README / CONTRIBUTING
- [ ] 提交信息符合 Conventional Commits 规范
## 关联 Issue
如有,请填写:
Fixes #(issue 编号)

64
.gitignore vendored
View File

@@ -1,4 +1,68 @@
# Dependencies
node_modules/
package-lock.json
# Build artifacts
.build-venv/
.wrangler/
__pycache__/
*.pyc
# Logs
*.log
npm-debug.log*
# Source images (only numbered .webp should be tracked; original images stay local)
# Ignore everything in source dirs
/mobile-image/*
/desketop-image/*
# Keep .gitkeep to preserve empty directories
!/mobile-image/.gitkeep
!/desketop-image/.gitkeep
# Keep only purely numbered .webp in source dirs (generated build artifacts)
# e.g. 1.webp, 10.webp, 123.webp — but NOT 3yfvfgrD.webp
!/mobile-image/[0-9].webp
!/mobile-image/[0-9][0-9].webp
!/mobile-image/[0-9][0-9][0-9].webp
!/mobile-image/[0-9][0-9][0-9][0-9].webp
!/desketop-image/[0-9].webp
!/desketop-image/[0-9][0-9].webp
!/desketop-image/[0-9][0-9][0-9].webp
!/desketop-image/[0-9][0-9][0-9][0-9].webp
# Ignore all original image formats (redundant safety net)
/mobile-image/*.jpg
/mobile-image/*.jpeg
/mobile-image/*.png
/mobile-image/*.gif
/mobile-image/*.bmp
/mobile-image/*.tif
/mobile-image/*.tiff
/desketop-image/*.jpg
/desketop-image/*.jpeg
/desketop-image/*.png
/desketop-image/*.gif
/desketop-image/*.bmp
/desketop-image/*.tif
/desketop-image/*.tiff
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Wrangler local state
.dev.vars
.env
.env.local

67
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,67 @@
# 贡献指南
感谢你对 RandBG API 项目的关注!我们欢迎任何形式的贡献,包括但不限于提交 Issue、修复 Bug、改进文档、新增功能等。
## 如何贡献
### 提交 Issue
如果你发现了 Bug 或有新功能建议,请通过 [GitHub Issues](https://github.com/shumengya/randbg-api/issues) 提交。
提交 Issue 时,请尽可能包含以下信息:
- **问题描述**:清晰描述你遇到的问题或建议
- **复现步骤**:如果是 Bug请提供详细的复现步骤
- **环境信息**操作系统、Node.js 版本、Python 版本等
- **预期结果**与**实际结果**
- **截图或日志**(如有)
### 提交 Pull Request
1. **Fork 本仓库** 并克隆到本地
2. **创建分支**:从 `main` 分支创建一个新的功能分支
```bash
git checkout -b feature/your-feature-name
```
3. **进行修改**:确保代码风格与现有项目保持一致
4. **测试**:在本地运行 `npm run dev` 确保修改不会破坏现有功能
5. **提交**:使用清晰的提交信息
```bash
git commit -m "feat: 添加 xxx 功能"
```
6. **推送并创建 PR**:推送到你的 Fork 仓库,然后向本仓库提交 Pull Request
## 代码规范
- **JavaScript**:使用 ES Module保持简洁添加必要的 JSDoc 注释
- **Python**:遵循 PEP 8 规范,使用类型注解(如有)
- **Git 提交信息**:参考 [Conventional Commits](https://www.conventionalcommits.org/)
- `feat:` 新功能
- `fix:` 修复 Bug
- `docs:` 文档更新
- `refactor:` 代码重构
- `chore:` 构建/工具链更新
## 项目结构说明
- `src/`Worker 运行时代码API 逻辑)
- `public/`:静态资源(会被部署到 Cloudflare Assets
- `index.html`:演示页面
- `manifest.json`:图片清单(由 `build.py` 自动生成)
- `mobile-image/` / `desketop-image/`:构建后的 WebP 图片
- `mobile-image/` / `desketop-image/`(根目录):**原始图片存放目录,不要提交到 Git**
- `build.py`:构建脚本,将原图转换为 WebP 并生成清单
## 注意事项
- **不要提交原始图片**:根目录下的 `mobile-image/` 和 `desketop-image/` 仅用于本地构建,已配置 `.gitignore` 忽略非编号文件。
- **不要提交敏感信息**`wrangler.toml` 中不应包含 `account_id` 或 API Token。
- **保持 API 兼容性**:如果修改 API 接口,请确保向后兼容,或在文档中明确标注 Breaking Changes。
## 行为准则
- 尊重他人,保持友善和建设性的沟通
- 接受不同的观点和经验
- 关注对社区最有利的事情
再次感谢你的贡献!

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 shumengya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

254
README.md
View File

@@ -1,54 +1,236 @@
# 随机背景API
# RandBG API - 随机背景图片服务
一个可直接部署到 Cloudflare Pages 的静态站点 + Pages Functions API用来按设备方向随机返回背景图。
[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Demo](https://img.shields.io/badge/在线体验-randbg.smyhub.com-28a745)](https://randbg.smyhub.com)
## 目录约定
> 一个基于 **Cloudflare Workers** 部署的轻量级随机背景图片 API支持自动识别设备类型移动端/桌面端),并提供优雅的 Web 演示界面。
- `mobile-image/`:竖屏图片原图上传目录
- `desketop-image/`:横屏图片原图上传目录
---
## 使用流程
## 在线体验
- **在线演示**: [https://randbg.smyhub.com](https://randbg.smyhub.com)
- **GitHub 仓库**: [https://github.com/shumengya/randbg-api](https://github.com/shumengya/randbg-api)
---
## 特性
- **自动设备识别**:根据 User-Agent 和 Client Hints 自动判断移动端/桌面端
- **双重响应格式**:支持 `302 Redirect` 直接跳转图片,或 `JSON` 返回图片元数据
- **静态资源优化**:使用 WebP 格式,配合 Cloudflare 静态资源缓存,加载极速
- **智能构建流程**`build.py` 自动将原图转换为编号 WebP 并生成 `manifest.json`
- **一体化部署**Worker 负责 API 逻辑,静态页面与图片通过 Assets 提供,单次部署即发布前后端
- **零成本部署**:基于 Cloudflare Workers Free Tier完全免费
---
## API 文档
### `GET /api/random`
随机返回一张背景图片。
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `mode` | string | `auto` | 强制指定类型:`auto` / `mobile` / `desktop` |
| `orientation` | string | `auto` | 屏幕方向:`portrait` / `landscape` |
| `device` | string | `auto` | 设备类型:`mobile` / `desktop` |
| `format` | string | `redirect` | 响应格式:`redirect`302 跳转)/ `json` |
**示例请求:**
```bash
# 默认 302 跳转到随机图片
curl https://randbg.smyhub.com/api/random
# 返回 JSON
curl https://randbg.smyhub.com/api/random?format=json
# 响应:
# {
# "variant": "desktop",
# "folder": "desketop-image",
# "filename": "7.webp",
# "url": "https://randbg.smyhub.com/desketop-image/7.webp",
# "count": 19
# }
# 强制使用移动端图片
curl https://randbg.smyhub.com/api/random?mode=mobile
# 强制桌面端并返回 JSON
curl https://randbg.smyhub.com/api/random?mode=desktop&format=json
```
### `GET /api/list`
返回各变体的图片清单与统计信息。
```bash
curl https://randbg.smyhub.com/api/list
```
**响应示例:**
```json
{
"generated_at": "2026-05-18T11:45:45.514521+00:00",
"variants": {
"mobile": {
"folder": "mobile-image",
"count": 6,
"images": ["1.webp", "2.webp", "3.webp", "4.webp", "5.webp", "6.webp"]
},
"desktop": {
"folder": "desketop-image",
"count": 19,
"images": ["1.webp", "2.webp", ...]
}
}
}
```
### `GET /manifest.json`
静态清单文件,由 `build.py` 自动生成,记录各变体的目录与文件名列表。
---
## 技术栈
| 层级 | 技术 |
|------|------|
| Runtime | [Cloudflare Workers](https://workers.cloudflare.com/) |
| 静态托管 | Cloudflare Workers Static Assets |
| 构建脚本 | Python 3 + [Pillow](https://pillow.readthedocs.io/) |
| 前端 | Vanilla HTML / CSS / JS |
---
## 项目结构
```
randbg-api/
├── src/
│ ├── worker.js # Workers 入口路由
│ ├── api/
│ │ ├── random.js # /api/random 接口逻辑
│ │ └── list.js # /api/list 接口逻辑
│ └── lib/
│ └── background.js # 通用工具清单加载、设备识别、URL 构建)
├── public/
│ ├── index.html # 演示与文档页面
│ ├── manifest.json # 图片清单build.py 生成)
│ ├── _headers # 静态资源 HTTP 响应头
│ ├── mobile-image/ # 移动端 WebP 图片(构建产物)
│ └── desketop-image/ # 桌面端 WebP 图片(构建产物)
├── mobile-image/ # 原始图片(本地工作目录,不提交到 Git
├── desketop-image/ # 原始图片(本地工作目录,不提交到 Git
├── build.py # 构建脚本:原图 → WebP + manifest.json
├── wrangler.toml # Workers 部署配置
├── package.json # 依赖与脚本
└── README.md # 本文档
```
> **注意**:根目录下的 `mobile-image/` 和 `desketop-image/` 仅用于本地存放原始素材,不应提交到 GitHub。`build.py` 会将转换后的编号 WebP 输出到 `public/` 目录,只有 `public/` 下的内容会被部署。
---
## 部署指南
### 前置条件
- [Cloudflare 账号](https://dash.cloudflare.com/)
- [Node.js](https://nodejs.org/) 18+
- [Python 3](https://python.org/) 3.9+
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
### 1. 克隆项目
```bash
git clone https://github.com/shumengya/randbg-api.git
cd randbg-api
```
### 2. 安装依赖
```bash
npm install
```
### 3. 准备图片
将原始图片放入对应目录:
```
mobile-image/ # 竖屏图片(移动端)
desketop-image/ # 横屏图片(桌面端)
```
支持格式:`.jpg` `.jpeg` `.png` `.bmp` `.gif` `.tif` `.tiff` `.webp`
### 4. 构建 WebP 与清单
1. 把图片上传到对应目录。
2. 运行构建脚本:
```bash
python3 build.py
```
3. 把项目根目录作为 Cloudflare Pages 的发布目录部署。
4. 打开:
- 页面:`/`
- API`/api/random`
## API
脚本会自动完成以下工作:
1. 将原图转换为高质量编号 WebP`1.webp` `2.webp` ...
2. 生成 `public/manifest.json`
3. 将转换后的文件输出到 `public/` 部署目录
- `GET /api/random`
- 默认按 `mode=auto` 随机返回图片重定向
- `GET /api/random?format=json`
- 返回 JSON
- `GET /api/list`
- 返回图片清单
如果 `public/` 下已有序号 WebP 且没有原图,脚本会**保留现有文件**,不会误删。
## 组件嵌入
### 5. 本地预览
```html
<script src="https://random-background-api.pages.dev/widget.js"></script>
<random-background-widget
src="https://random-background-api.pages.dev"
mode="auto"
blur="12"
radius="16px"
height="260px">
</random-background-widget>
```bash
npm run dev
```
`widget.js` 只读取静态的 `manifest.json` 和图片文件,不会调用 `api/random`
默认在 `http://localhost:8787` 启动预览
## 构建说明
### 6. 部署到 Cloudflare
`build.py` 会把目录里的原图转换成同目录下的编号 `webp` 文件,例如:
```bash
npm run deploy
```
- `1.webp`
- `2.webp`
- `55.webp`
Wrangler 会将 `src/worker.js` 打包为 Worker同时将 `public/` 作为静态 Assets 一起发布。
同时生成 `manifest.json`,供 Pages Functions 读取。
---
## 目录约定
- **`public/mobile-image/`**:竖屏图片(移动端)
- **`public/desketop-image/`**:横屏图片(桌面端)
- 注意拼写沿用历史目录名API 内部已兼容
- **`public/manifest.json`**构建产物API 运行时通过内部请求读取该清单
---
## 注意事项
1. **原图不上传**:根目录下的 `mobile-image/``desketop-image/` 仅用于本地构建,已配置 `.gitignore` 避免误提交。GitHub 仓库中只保留构建后的 `public/` 资源。
2. **图片版权**:请确保上传的图片拥有合法使用权或来自版权友好的来源。
3. **Worker 体积限制**:静态资源通过 Cloudflare Assets 提供,不占用 Worker 脚本 4 MiB 限制,可放心使用大量图片。
4. **缓存策略**`public/_headers` 已为图片配置了 `immutable` 长期缓存,为 `manifest.json` 配置了 5 分钟缓存。如需更新图片,请重新运行 `build.py` 并部署。
---
## 开源协议
本项目基于 [MIT License](LICENSE) 开源。
---
## 贡献
欢迎提交 Issue 和 Pull Request如果你有任何建议或发现问题请随时反馈。
---
<p align="center">
Made with ❤️ by <a href="https://github.com/shumengya">shumengya</a>
</p>

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import venv
@@ -11,11 +12,24 @@ from datetime import datetime, timezone
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
MANIFEST_PATH = BASE_DIR / "manifest.json"
PUBLIC_DIR = BASE_DIR / "public"
MANIFEST_PATH = PUBLIC_DIR / "manifest.json"
VARIANTS = {
"mobile": [BASE_DIR / "mobile-image"],
"desktop": [BASE_DIR / "desketop-image"],
"mobile": {
"out": PUBLIC_DIR / "mobile-image",
"scan": [
PUBLIC_DIR / "mobile-image",
BASE_DIR / "mobile-image",
],
},
"desktop": {
"out": PUBLIC_DIR / "desketop-image",
"scan": [
PUBLIC_DIR / "desketop-image",
BASE_DIR / "desketop-image",
],
},
}
INPUT_EXTENSIONS = {
@@ -81,14 +95,15 @@ def iter_source_files(directory: Path) -> list[Path]:
return files
def select_source_directory(candidates: list[Path]) -> Path:
for directory in candidates:
def select_scan_directory(scan_candidates: list[Path]) -> Path:
"""优先有原图的目录,其次有序号 webp否则退回 scan 第一项(通常为 public下的输出目录"""
for directory in scan_candidates:
if iter_source_files(directory):
return directory
for directory in candidates:
if directory.exists():
for directory in scan_candidates:
if sorted_generated_webp_names(directory):
return directory
return candidates[0]
return scan_candidates[0]
def clear_generated_files(directory: Path) -> None:
@@ -100,6 +115,23 @@ def clear_generated_files(directory: Path) -> None:
path.unlink()
def sorted_generated_webp_names(directory: Path) -> list[str]:
"""已存在的序号 webp无原图时再跑脚本不会清空"""
if not directory.exists():
return []
names = [
path.name
for path in directory.iterdir()
if path.is_file() and GENERATED_NAME_RE.match(path.name)
]
def sort_key(entry: str) -> int:
return int(Path(entry).stem)
names.sort(key=sort_key)
return names
def convert_to_webp(source: Path, target: Path, image_mod, image_ops) -> None:
with image_mod.open(source) as image:
image = image_ops.exif_transpose(image)
@@ -114,24 +146,47 @@ def convert_to_webp(source: Path, target: Path, image_mod, image_ops) -> None:
image.save(target, format="WEBP", quality=92, method=6)
def build_variant(name: str, directories: list[Path], image_mod, image_ops) -> dict:
directory = select_source_directory(directories)
directory.mkdir(parents=True, exist_ok=True)
clear_generated_files(directory)
def copy_generated_webps(source_dir: Path, out_dir: Path, names: list[str]) -> None:
"""将已有序号 webp 同步到 public 下的部署目录。"""
out_dir.mkdir(parents=True, exist_ok=True)
for entry in names:
shutil.copy2(source_dir / entry, out_dir / entry)
def build_variant(name: str, spec: dict[str, Path | list[Path]], image_mod, image_ops) -> dict:
scan_dir = select_scan_directory(list(spec["scan"]))
out_dir = spec["out"]
out_dir.mkdir(parents=True, exist_ok=True)
sources = iter_source_files(scan_dir)
sources = iter_source_files(directory)
images: list[str] = []
if sources:
clear_generated_files(out_dir)
for index, source in enumerate(sources, start=1):
target = directory / f"{index}.webp"
target = out_dir / f"{index}.webp"
try:
convert_to_webp(source, target, image_mod, image_ops)
images.append(target.name)
print(f"[{name}] {source.name} -> {target.name}")
rel_src = os.path.relpath(source.resolve(), BASE_DIR.resolve())
print(f"[{name}] {rel_src} -> {target.name}")
except Exception as exc:
print(f"[{name}] skip {source.name}: {exc}", file=sys.stderr)
else:
images = sorted_generated_webp_names(out_dir)
if not images and scan_dir.resolve() != out_dir.resolve():
legacy = sorted_generated_webp_names(scan_dir)
if legacy:
copy_generated_webps(scan_dir, out_dir, legacy)
images = legacy
print(f"[{name}] 已从 {scan_dir.relative_to(BASE_DIR)} 复制序号 webp ×{len(images)} -> public/")
if images:
if scan_dir.resolve() == out_dir.resolve():
print(f"[{name}] 未检测到原图,保留 public 下序号 webp ×{len(images)}")
else:
print(f"[{name}] 跳过:未发现原图,且 public/ 与归档目录均无 1.webp 等序号文件")
return {
"folder": directory.name,
"folder": out_dir.name,
"count": len(images),
"images": images,
}
@@ -149,8 +204,8 @@ def main() -> int:
"variants": {},
}
for name, directories in VARIANTS.items():
manifest["variants"][name] = build_variant(name, directories, image_mod, image_ops)
for name, variant_spec in VARIANTS.items():
manifest["variants"][name] = build_variant(name, variant_spec, image_mod, image_ops)
write_manifest(manifest)
print(f"Wrote manifest: {MANIFEST_PATH}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -1,390 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>随机背景 API</title>
<meta name="description" content="Cloudflare Pages 随机背景 API 文档与在线预览。">
<style>
:root {
--bg: #f6f7fb;
--panel: #fff;
--text: #172033;
--muted: #5b657a;
--line: #e3e7ef;
--soft: #f3f5f9;
--accent: #2563eb;
--code: #0b1220;
--shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(180deg, #fff 0%, var(--bg) 100%);
color: var(--text);
font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 14px;
}
.top {
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) 250px;
align-items: start;
}
.main {
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
margin-top: 10px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
overflow: hidden;
}
.pad { padding: 12px; }
h1, h2, p { margin: 0; }
h1 {
font-size: clamp(24px, 3vw, 34px);
line-height: 1.1;
letter-spacing: -0.03em;
}
h2 {
font-size: 15px;
margin-bottom: 6px;
}
.lead {
margin-top: 6px;
color: var(--muted);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--soft);
font-size: 11px;
color: var(--text);
}
.links {
display: grid;
gap: 6px;
font-size: 13px;
}
.section {
display: grid;
gap: 8px;
}
.grid-2 {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.endpoint {
padding: 10px;
border-radius: 12px;
border: 1px solid var(--line);
background: #fff;
}
.endpoint strong {
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.code {
margin: 0;
padding: 9px 10px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--code);
color: #e7eefc;
overflow-x: auto;
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
label {
display: block;
margin-bottom: 4px;
font-size: 11px;
color: var(--muted);
}
select, button, input {
width: 100%;
padding: 9px 10px;
border-radius: 10px;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
font: inherit;
}
button {
cursor: pointer;
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.row {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr)) 100px;
}
.preview-grid {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
align-items: start;
}
.preview-box {
aspect-ratio: 16 / 10;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--line);
background: #eef2f7;
}
.preview-box img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.small {
color: var(--muted);
font-size: 11px;
}
.footer {
margin-top: 10px;
color: var(--muted);
font-size: 11px;
}
@media (max-width: 900px) {
.top, .main, .grid-2, .preview-grid, .row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="wrap">
<section class="top">
<div class="card pad">
<h1>随机背景 API</h1>
<p class="lead">Cloudflare Pages 随机背景接口。上传图片后先用 `build.py` 生成序号 `.webp`,然后可通过 `/api/random` 和 `/api/list` 调用。</p>
<div class="meta">
<span class="tag">竖屏mobile-image</span>
<span class="tag">横屏desketop-image</span>
<span class="tag">返回302 / JSON</span>
<span class="tag">组件widget.js</span>
</div>
</div>
<div class="card pad">
<h2>快速链接</h2>
<div class="links">
<a href="/api/list" target="_blank" rel="noreferrer">/api/list</a>
<a href="/manifest.json" target="_blank" rel="noreferrer">manifest.json</a>
<a href="/mobile-image/1.webp" target="_blank" rel="noreferrer">mobile-image/1.webp</a>
</div>
</div>
</section>
<section class="main">
<div class="card pad section" id="api">
<h2>API 调用</h2>
<div class="grid-2">
<div class="endpoint">
<strong>随机图片</strong>
<div class="small">默认 302 跳转到随机图片。</div>
<pre class="code">GET /api/random</pre>
</div>
<div class="endpoint">
<strong>返回 JSON</strong>
<div class="small">适合前端自己处理图片地址。</div>
<pre class="code">GET /api/random?format=json</pre>
</div>
</div>
<div class="grid-2">
<div class="endpoint">
<strong>强制竖屏</strong>
<pre class="code">GET /api/random?mode=mobile</pre>
</div>
<div class="endpoint">
<strong>强制横屏</strong>
<pre class="code">GET /api/random?mode=desktop</pre>
</div>
</div>
<p class="small">支持参数:`mode=auto|mobile|desktop`、`orientation=portrait|landscape`、`device=mobile|desktop`、`format=redirect|json`。</p>
</div>
<div class="card pad section" id="preview">
<h2>在线预览</h2>
<div class="row">
<div>
<label for="modeSelect">mode</label>
<select id="modeSelect">
<option value="auto">auto</option>
<option value="mobile">mobile</option>
<option value="desktop">desktop</option>
</select>
</div>
<div>
<label for="orientationSelect">orientation</label>
<select id="orientationSelect">
<option value="">auto</option>
<option value="portrait">portrait</option>
<option value="landscape">landscape</option>
</select>
</div>
<div>
<label for="formatSelect">format</label>
<select id="formatSelect">
<option value="json">json</option>
<option value="redirect">redirect</option>
</select>
</div>
<button id="refreshBtn" type="button">刷新</button>
</div>
<div class="preview-grid">
<div>
<label for="apiUrl">当前地址</label>
<input id="apiUrl" readonly value="">
<div class="preview-box" style="margin-top:8px">
<img id="previewImg" alt="随机背景预览" src="">
</div>
</div>
<div>
<label>返回结果</label>
<pre class="code" id="jsonOutput">{}</pre>
</div>
</div>
</div>
</section>
<section class="card pad section" style="margin-top:10px">
<h2>嵌入组件</h2>
<div class="grid-2">
<div class="endpoint">
<strong>直接引用</strong>
<pre class="code">&lt;script src="https://random-background-api.pages.dev/widget.js"&gt;&lt;/script&gt;
&lt;random-background-widget
src="https://random-background-api.pages.dev"
blur="12"
radius="16px"
height="260px"&gt;
&lt;/random-background-widget&gt;</pre>
</div>
<div class="endpoint">
<strong>说明</strong>
<div class="small">组件只读静态 `manifest.json` 和图片,不消耗 Functions 额度;`blur` 控制高斯模糊像素值。</div>
</div>
</div>
</section>
<div class="footer">
构建命令:<code>python3 build.py</code>。这里保留 Pages Functions所以可以直接提供 HTTP 随机 API。
</div>
</main>
<script>
const modeSelect = document.getElementById('modeSelect');
const orientationSelect = document.getElementById('orientationSelect');
const formatSelect = document.getElementById('formatSelect');
const refreshBtn = document.getElementById('refreshBtn');
const apiUrl = document.getElementById('apiUrl');
const previewImg = document.getElementById('previewImg');
const jsonOutput = document.getElementById('jsonOutput');
function detectOrientation() {
return window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape';
}
function detectDevice() {
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
return navigator.userAgentData.mobile ? 'mobile' : 'desktop';
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ? 'mobile' : 'desktop';
}
function buildApiPath() {
const params = new URLSearchParams();
params.set('mode', modeSelect.value);
params.set('device', detectDevice());
params.set('format', formatSelect.value);
const orientation = orientationSelect.value || detectOrientation();
params.set('orientation', orientation);
return `/api/random?${params.toString()}`;
}
async function refresh() {
const url = buildApiPath();
apiUrl.value = url;
previewImg.style.opacity = '0.72';
if (formatSelect.value === 'json') {
const response = await fetch(url);
const data = await response.json();
jsonOutput.textContent = JSON.stringify(data, null, 2);
previewImg.src = data.url || '';
} else {
jsonOutput.textContent = '{ "format": "redirect" }';
previewImg.src = url;
}
}
previewImg.addEventListener('load', () => {
previewImg.style.opacity = '1';
});
refreshBtn.addEventListener('click', refresh);
modeSelect.addEventListener('change', refresh);
orientationSelect.addEventListener('change', refresh);
formatSelect.addEventListener('change', refresh);
refresh();
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

11
package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "randbg-api",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^4"
}
}

View File

@@ -2,10 +2,6 @@
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=300
/widget.js
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=300
/mobile-image/*
Cache-Control: public, max-age=31536000, immutable

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

258
public/index.html Normal file
View File

@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>随机背景API</title>
<meta name="description" content="Cloudflare Workers随机背景 API 说明与预览。">
<style>
:root {
--text: #1a1a1a;
--muted: #666;
--border: #ddd;
--code-bg: #f5f5f5;
--accent: #06c;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 1.5rem 1.25rem 3rem;
color: var(--text);
font: 16px/1.6 ui-sans-serif, system-ui, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #fff;
}
a { color: var(--accent); }
code {
font: 0.92em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: var(--code-bg);
padding: 0.15em 0.35em;
}
pre, .pre-like {
margin: 0.5rem 0 1rem;
padding: 0.65rem 0.85rem;
background: var(--code-bg);
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow-x: auto;
border: 1px solid var(--border);
white-space: pre-wrap;
word-break: break-all;
}
.doc {
max-width: 40rem;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
line-height: 1.25;
}
h2 {
font-size: 1.05rem;
font-weight: 600;
margin: 2rem 0 0.65rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border);
}
p { margin: 0 0 0.85rem; }
.muted { color: var(--muted); font-size: 0.95rem; }
ul {
margin: 0 0 1rem;
padding-left: 1.2rem;
}
li { margin: 0.2rem 0; }
.api-item { margin-bottom: 1.1rem; }
.api-item p { margin: 0.25rem 0 0.35rem; }
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
align-items: flex-end;
margin: 0.75rem 0 1rem;
}
.field label {
display: block;
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 0.2rem;
}
select {
font: inherit;
padding: 0.35rem 0.45rem;
border: 1px solid var(--border);
background: #fff;
color: inherit;
min-width: 6.5rem;
}
button {
font: inherit;
padding: 0.38rem 0.85rem;
border: 1px solid var(--border);
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:hover { filter: brightness(1.05); }
#apiUrl {
width: 100%;
box-sizing: border-box;
font: 13px ui-monospace, Menlo, Consolas, monospace;
padding: 0.45rem 0.5rem;
border: 1px solid var(--border);
background: #fafafa;
}
#previewImg {
display: block;
width: 100%;
max-height: 14rem;
object-fit: cover;
margin-top: 0.5rem;
border: 1px solid var(--border);
background: var(--code-bg);
}
footer {
margin-top: 2.5rem;
font-size: 0.875rem;
color: var(--muted);
border-top: 1px solid var(--border);
padding-top: 1rem;
}
@media (max-width: 480px) {
.controls { flex-direction: column; align-items: stretch; }
button { width: 100%; }
}
</style>
</head>
<body>
<main class="doc">
<h1>随机背景 API</h1>
<p>Cloudflare Workers 接口。先用 <code>build.py</code><code>public/</code> 下生成序号 <code>.webp</code><code>manifest.json</code>,再通过 <code>/api/random</code><code>/api/list</code> 调用。</p>
<p class="muted">约定:<code>mobile-image</code> 竖屏,<code>desketop-image</code> 横屏。默认响应为 302<code>format=json</code> 时返回 JSON。</p>
<h2 id="links">快捷链接</h2>
<ul>
<li><a href="/api/list" target="_blank" rel="noreferrer"><code>/api/list</code></a></li>
<li><a href="/manifest.json" target="_blank" rel="noreferrer"><code>/manifest.json</code></a></li>
<li><a href="/mobile-image/1.webp" target="_blank" rel="noreferrer"><code>/mobile-image/1.webp</code></a>(示例)</li>
</ul>
<h2 id="api">接口说明</h2>
<div class="api-item">
<p><strong><code>GET /api/random</code></strong></p>
<p class="muted">按设备方向选一个文件名302 跳到对应图片。</p>
<pre>GET /api/random</pre>
</div>
<div class="api-item">
<p><strong><code>GET /api/random?format=json</code></strong></p>
<p class="muted">返回包含 <code>url</code> 等字段的 JSON。</p>
<pre>GET /api/random?format=json</pre>
</div>
<div class="api-item">
<p><strong><code>GET /api/random?mode=mobile</code></strong> / <strong><code>?mode=desktop</code></strong></p>
<p class="muted">强制使用竖屏或横屏素材目录。</p>
<pre>GET /api/random?mode=mobile
GET /api/random?mode=desktop</pre>
</div>
<p class="muted">查询参数:<code>mode=auto|mobile|desktop</code><code>orientation=portrait|landscape</code><code>device=mobile|desktop</code><code>format=redirect|json</code></p>
<h2 id="preview">在线预览</h2>
<div class="controls">
<div class="field">
<label for="modeSelect">mode</label>
<select id="modeSelect">
<option value="auto">auto</option>
<option value="mobile">mobile</option>
<option value="desktop">desktop</option>
</select>
</div>
<div class="field">
<label for="orientationSelect">orientation</label>
<select id="orientationSelect">
<option value="">auto</option>
<option value="portrait">portrait</option>
<option value="landscape">landscape</option>
</select>
</div>
<div class="field">
<label for="formatSelect">format</label>
<select id="formatSelect">
<option value="json">json</option>
<option value="redirect">redirect</option>
</select>
</div>
<button id="refreshBtn" type="button">刷新</button>
</div>
<p><label for="apiUrl" class="muted" style="font-size: 0.75rem;">当前请求</label></p>
<input id="apiUrl" readonly value="" autocomplete="off" spellcheck="false">
<img id="previewImg" alt="随机背景预览" src="">
<p><span class="muted" style="font-size: 0.75rem;">响应体</span></p>
<pre class="pre-like" id="jsonOutput">{}</pre>
<footer>
构建:<code>python3 build.py</code>。部署:<code>npm run deploy</code><code>wrangler deploy</code>。Worker 仅含 API静态页与图片来自 Assets。
</footer>
</main>
<script>
const modeSelect = document.getElementById('modeSelect');
const orientationSelect = document.getElementById('orientationSelect');
const formatSelect = document.getElementById('formatSelect');
const refreshBtn = document.getElementById('refreshBtn');
const apiUrl = document.getElementById('apiUrl');
const previewImg = document.getElementById('previewImg');
const jsonOutput = document.getElementById('jsonOutput');
function detectOrientation() {
return window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape';
}
function detectDevice() {
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
return navigator.userAgentData.mobile ? 'mobile' : 'desktop';
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ? 'mobile' : 'desktop';
}
function buildApiPath() {
const params = new URLSearchParams();
params.set('mode', modeSelect.value);
params.set('device', detectDevice());
params.set('format', formatSelect.value);
const orientation = orientationSelect.value || detectOrientation();
params.set('orientation', orientation);
return `/api/random?${params.toString()}`;
}
async function refresh() {
const url = buildApiPath();
apiUrl.value = url;
previewImg.style.opacity = '0.72';
if (formatSelect.value === 'json') {
const response = await fetch(url);
const data = await response.json();
jsonOutput.textContent = JSON.stringify(data, null, 2);
previewImg.src = data.url || '';
} else {
jsonOutput.textContent = '{ "format": "redirect" }';
previewImg.src = url;
}
}
previewImg.addEventListener('load', () => {
previewImg.style.opacity = '1';
});
refreshBtn.addEventListener('click', refresh);
modeSelect.addEventListener('change', refresh);
orientationSelect.addEventListener('change', refresh);
formatSelect.addEventListener('change', refresh);
refresh();
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
{
"generated_at": "2026-03-28T07:14:07.314011+00:00",
"generated_at": "2026-05-18T11:45:45.514521+00:00",
"variants": {
"mobile": {
"folder": "mobile-image",

BIN
public/mobile-image/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

BIN
public/mobile-image/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

BIN
public/mobile-image/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
public/mobile-image/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
public/mobile-image/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

BIN
public/mobile-image/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -1,8 +1,8 @@
import { getVariantPayload, loadManifest } from '../lib/background.js';
export async function onRequestGet(context) {
const { request } = context;
const manifest = await loadManifest(request);
/** @param {Request} request @param {{ ASSETS?: { fetch(input: RequestInfo, init?: RequestInit): Promise<Response> } }} env */
export async function handleListGet(request, env) {
const manifest = await loadManifest(request, env);
return Response.json(
{
@@ -16,6 +16,7 @@ export async function onRequestGet(context) {
);
}
/** @param {unknown} manifest @param {'mobile' | 'desktop'} variant */
function summarizeVariant(manifest, variant) {
const { folder, images } = getVariantPayload(manifest, variant);
return { folder, count: images.length, images };

View File

@@ -1,10 +1,10 @@
import { buildAssetUrl, detectVariant, getVariantPayload, loadManifest, pickRandom } from '../lib/background.js';
export async function onRequestGet(context) {
const { request } = context;
/** @param {Request} request @param {{ ASSETS?: { fetch(input: RequestInfo, init?: RequestInit): Promise<Response> } }} env */
export async function handleRandomGet(request, env) {
const url = new URL(request.url);
const format = (url.searchParams.get('format') || 'redirect').toLowerCase();
const manifest = await loadManifest(request);
const manifest = await loadManifest(request, env);
const variant = detectVariant(request);
const { folder, images } = getVariantPayload(manifest, variant);

View File

@@ -5,9 +5,11 @@ const FALLBACK_MANIFEST = {
},
};
export async function loadManifest(request) {
export async function loadManifest(request, env) {
try {
const response = await fetch(new URL('/manifest.json', request.url));
const url = new URL('/manifest.json', request.url);
const response =
env && env.ASSETS ? await env.ASSETS.fetch(new Request(url.toString())) : await fetch(url);
if (!response.ok) return FALLBACK_MANIFEST;
return await response.json();
} catch {

38
src/worker.js Normal file
View File

@@ -0,0 +1,38 @@
import { handleListGet } from './api/list.js';
import { handleRandomGet } from './api/random.js';
export default {
/**
* @param {Request} request
* @param {{ ASSETS: { fetch(input: Request | string, init?: RequestInit): Promise<Response> } }} env
*/
async fetch(request, env) {
const url = new URL(request.url);
const pathname = normalizePath(url.pathname);
if (pathname === '/api/random') return routeApiGet(request, env, handleRandomGet);
if (pathname === '/api/list') return routeApiGet(request, env, handleListGet);
return env.ASSETS.fetch(request);
},
};
/**
* @param {string} pathname
*/
function normalizePath(pathname) {
if (pathname.length > 1 && pathname.endsWith('/')) return pathname.replace(/\/+$/, '');
return pathname;
}
/**
* @param {Request} request
* @param {{ ASSETS?: { fetch(input: RequestInfo, init?: RequestInit): Promise<Response> } }} env
* @param {(request: Request, env: object) => Promise<Response> | Response} handler
*/
async function routeApiGet(request, env, handler) {
if (request.method !== 'GET') {
return new Response('Method Not Allowed', { status: 405 });
}
return handler(request, env);
}

209
widget.js
View File

@@ -1,209 +0,0 @@
(() => {
const DEFAULT_BASE_URL = (() => {
try {
return new URL(document.currentScript?.src || window.location.href).origin;
} catch {
return window.location.origin;
}
})();
const STYLE = `
:host {
display: block;
width: var(--rbw-width, 100%);
height: var(--rbw-height, 260px);
}
.frame {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--rbw-radius, 14px);
background: #eef2f7;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: var(--rbw-fit, cover);
filter: blur(var(--rbw-blur, 0px));
transform: scale(1.04);
opacity: 0;
transition: opacity 180ms ease;
will-change: filter, opacity, transform;
}
.state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
text-align: center;
color: #5b657a;
font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(180deg, rgba(255,255,255,.45), rgba(255,255,255,.2));
backdrop-filter: blur(4px);
}
:host([loaded]) .state,
:host([error]) .state {
opacity: 0;
pointer-events: none;
}
:host([loaded]) img {
opacity: 1;
}
`;
const DEFAULTS = {
mode: 'auto',
blur: '0',
radius: '14px',
fit: 'cover',
height: '260px',
width: '100%',
};
class RandomBackgroundWidget extends HTMLElement {
static observedAttributes = ['src', 'mode', 'blur', 'radius', 'fit', 'height', 'width'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._manifest = null;
this._requestSeq = 0;
this._boundRefresh = this.refresh.bind(this);
}
connectedCallback() {
this.render();
this.refresh();
window.addEventListener('resize', this._boundRefresh, { passive: true });
}
disconnectedCallback() {
window.removeEventListener('resize', this._boundRefresh);
}
attributeChangedCallback() {
if (this.isConnected) {
this.render();
this.refresh();
}
}
get baseUrl() {
return (this.getAttribute('src') || DEFAULT_BASE_URL).replace(/\/+$/, '');
}
get mode() {
return (this.getAttribute('mode') || DEFAULTS.mode).toLowerCase();
}
get blur() {
const raw = Number.parseFloat(this.getAttribute('blur') || DEFAULTS.blur);
return Number.isFinite(raw) && raw > 0 ? raw : 0;
}
get radius() {
return this.getAttribute('radius') || DEFAULTS.radius;
}
get fit() {
return this.getAttribute('fit') || DEFAULTS.fit;
}
get height() {
return this.getAttribute('height') || DEFAULTS.height;
}
get width() {
return this.getAttribute('width') || DEFAULTS.width;
}
render() {
if (!this.shadowRoot) return;
this.style.setProperty('--rbw-width', this.width);
this.style.setProperty('--rbw-height', this.height);
this.style.setProperty('--rbw-radius', this.radius);
this.style.setProperty('--rbw-fit', this.fit);
this.style.setProperty('--rbw-blur', `${this.blur}px`);
this.shadowRoot.innerHTML = `
<style>${STYLE}</style>
<div class="frame">
<img part="image" alt="random background">
<div class="state">加载中</div>
</div>
`;
}
async refresh() {
const seq = ++this._requestSeq;
const img = this.shadowRoot.querySelector('img');
const state = this.shadowRoot.querySelector('.state');
try {
if (!this._manifest) {
this._manifest = await this.fetchManifest();
}
if (seq !== this._requestSeq) return;
const variant = this.resolveVariant();
const data = this._manifest?.variants?.[variant] || {};
const folder = data.folder || (variant === 'mobile' ? 'mobile-image' : 'desketop-image');
const images = Array.isArray(data.images) ? data.images.filter((item) => typeof item === 'string') : [];
if (!images.length) {
throw new Error(`no images in ${folder}`);
}
const filename = images[Math.floor(Math.random() * images.length)];
img.src = `${this.baseUrl}/${folder}/${filename}`;
img.alt = `random background ${variant}`;
this.setAttribute('loaded', '');
this.removeAttribute('error');
state.textContent = '';
} catch (error) {
this.removeAttribute('loaded');
this.setAttribute('error', '');
state.textContent = error instanceof Error ? error.message : 'load failed';
}
}
resolveVariant() {
if (this.mode === 'mobile' || this.mode === 'desktop') return this.mode;
const orientation = window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape';
const device = this.detectDevice();
if (orientation === 'portrait') return 'mobile';
if (orientation === 'landscape') return 'desktop';
return device;
}
detectDevice() {
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
return navigator.userAgentData.mobile ? 'mobile' : 'desktop';
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ? 'mobile' : 'desktop';
}
async fetchManifest() {
const url = new URL('manifest.json', `${this.baseUrl}/`);
const response = await fetch(url.toString(), { mode: 'cors', cache: 'no-store' });
if (!response.ok) {
throw new Error(`manifest ${response.status}`);
}
return await response.json();
}
}
if (!customElements.get('random-background-widget')) {
customElements.define('random-background-widget', RandomBackgroundWidget);
}
window.RandomBackgroundWidget = RandomBackgroundWidget;
})();

9
wrangler.toml Normal file
View File

@@ -0,0 +1,9 @@
name = "randbg-api"
main = "src/worker.js"
compatibility_date = "2026-03-01"
# 静态前端与 manifest / 图片均放在 public仅 Worker 脚本被打包API 体量极小)
[assets]
directory = "public"
binding = "ASSETS"
run_worker_first = true