initial import: random background api

This commit is contained in:
shumengya
2026-03-29 10:58:33 +08:00
commit f68e19a3bd
62 changed files with 985 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.build-venv/
.wrangler/
__pycache__/
*.pyc

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# 随机背景API
一个可直接部署到 Cloudflare Pages 的静态站点 + Pages Functions API用来按设备方向随机返回背景图。
## 目录约定
- `mobile-image/`:竖屏图片原图上传目录
- `desketop-image/`:横屏图片原图上传目录
## 使用流程
1. 把图片上传到对应目录。
2. 运行构建脚本:
```bash
python3 build.py
```
3. 把项目根目录作为 Cloudflare Pages 的发布目录部署。
4. 打开:
- 页面:`/`
- API`/api/random`
## API
- `GET /api/random`
- 默认按 `mode=auto` 随机返回图片重定向
- `GET /api/random?format=json`
- 返回 JSON
- `GET /api/list`
- 返回图片清单
## 组件嵌入
```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>
```
`widget.js` 只读取静态的 `manifest.json` 和图片文件,不会调用 `api/random`。
## 构建说明
`build.py` 会把目录里的原图转换成同目录下的编号 `webp` 文件,例如:
- `1.webp`
- `2.webp`
- `55.webp`
同时生成 `manifest.json`,供 Pages Functions 读取。

13
_headers Normal file
View File

@@ -0,0 +1,13 @@
/manifest.json
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
/desketop-image/*
Cache-Control: public, max-age=31536000, immutable

161
build.py Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import venv
from datetime import datetime, timezone
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
MANIFEST_PATH = BASE_DIR / "manifest.json"
VARIANTS = {
"mobile": [BASE_DIR / "mobile-image"],
"desktop": [BASE_DIR / "desketop-image"],
}
INPUT_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".bmp",
".gif",
".tif",
".tiff",
".webp",
}
GENERATED_NAME_RE = re.compile(r"^\d+\.webp$", re.IGNORECASE)
VENV_DIR = BASE_DIR / ".build-venv"
def ensure_pillow() -> None:
try:
from PIL import Image # noqa: F401
return
except Exception:
pass
venv_python = VENV_DIR / "bin" / "python"
if not venv_python.exists():
print("Pillow not found, creating a local build venv...", file=sys.stderr)
builder = venv.EnvBuilder(with_pip=True)
builder.create(VENV_DIR)
has_pillow = subprocess.run(
[str(venv_python), "-c", "import PIL"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
if not has_pillow:
print("Installing Pillow into the local build venv...", file=sys.stderr)
subprocess.check_call([str(venv_python), "-m", "pip", "install", "--quiet", "Pillow"])
os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])
def load_pillow():
ensure_pillow()
from PIL import Image, ImageOps
return Image, ImageOps
def iter_source_files(directory: Path) -> list[Path]:
if not directory.exists():
return []
files: list[Path] = []
for path in sorted(directory.iterdir(), key=lambda item: item.name.lower()):
if not path.is_file():
continue
if GENERATED_NAME_RE.match(path.name):
continue
if path.suffix.lower() not in INPUT_EXTENSIONS:
continue
files.append(path)
return files
def select_source_directory(candidates: list[Path]) -> Path:
for directory in candidates:
if iter_source_files(directory):
return directory
for directory in candidates:
if directory.exists():
return directory
return candidates[0]
def clear_generated_files(directory: Path) -> None:
if not directory.exists():
directory.mkdir(parents=True, exist_ok=True)
return
for path in directory.iterdir():
if path.is_file() and GENERATED_NAME_RE.match(path.name):
path.unlink()
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)
if image.mode not in {"RGB", "RGBA", "LA", "P"}:
image = image.convert("RGBA")
elif image.mode == "P":
image = image.convert("RGBA")
elif image.mode == "LA":
image = image.convert("RGBA")
elif image.mode == "RGB":
pass
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)
sources = iter_source_files(directory)
images: list[str] = []
for index, source in enumerate(sources, start=1):
target = directory / f"{index}.webp"
try:
convert_to_webp(source, target, image_mod, image_ops)
images.append(target.name)
print(f"[{name}] {source.name} -> {target.name}")
except Exception as exc:
print(f"[{name}] skip {source.name}: {exc}", file=sys.stderr)
return {
"folder": directory.name,
"count": len(images),
"images": images,
}
def write_manifest(manifest: dict) -> None:
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
image_mod, image_ops = load_pillow()
manifest = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"variants": {},
}
for name, directories in VARIANTS.items():
manifest["variants"][name] = build_variant(name, directories, image_mod, image_ops)
write_manifest(manifest)
print(f"Wrote manifest: {MANIFEST_PATH}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

1
desketop-image/.gitkeep Normal file
View File

@@ -0,0 +1 @@

BIN
desketop-image/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

BIN
desketop-image/10.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
desketop-image/11.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

BIN
desketop-image/12.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
desketop-image/13.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
desketop-image/14.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
desketop-image/15.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
desketop-image/16.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
desketop-image/17.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

BIN
desketop-image/18.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

BIN
desketop-image/19.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

BIN
desketop-image/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
desketop-image/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

BIN
desketop-image/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

BIN
desketop-image/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

BIN
desketop-image/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

BIN
desketop-image/7.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

BIN
desketop-image/8.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

BIN
desketop-image/9.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

22
functions/api/list.js Normal file
View File

@@ -0,0 +1,22 @@
import { getVariantPayload, loadManifest } from '../lib/background.js';
export async function onRequestGet(context) {
const { request } = context;
const manifest = await loadManifest(request);
return Response.json(
{
generated_at: manifest?.generated_at || null,
variants: {
mobile: summarizeVariant(manifest, 'mobile'),
desktop: summarizeVariant(manifest, 'desktop'),
},
},
{ headers: { 'Cache-Control': 'no-store, max-age=0', 'Access-Control-Allow-Origin': '*' } },
);
}
function summarizeVariant(manifest, variant) {
const { folder, images } = getVariantPayload(manifest, variant);
return { folder, count: images.length, images };
}

36
functions/api/random.js Normal file
View File

@@ -0,0 +1,36 @@
import { buildAssetUrl, detectVariant, getVariantPayload, loadManifest, pickRandom } from '../lib/background.js';
export async function onRequestGet(context) {
const { request } = context;
const url = new URL(request.url);
const format = (url.searchParams.get('format') || 'redirect').toLowerCase();
const manifest = await loadManifest(request);
const variant = detectVariant(request);
const { folder, images } = getVariantPayload(manifest, variant);
if (!images.length) {
return Response.json(
{ error: 'no images available', variant, folder },
{ status: 404, headers: { 'Cache-Control': 'no-store, max-age=0', 'Access-Control-Allow-Origin': '*' } },
);
}
const filename = pickRandom(images);
const assetUrl = buildAssetUrl(request, folder, filename);
if (format === 'json') {
return Response.json(
{ variant, folder, filename, url: assetUrl, count: images.length },
{ headers: { 'Cache-Control': 'no-store, max-age=0', 'Access-Control-Allow-Origin': '*' } },
);
}
return new Response(null, {
status: 302,
headers: {
Location: assetUrl,
'Cache-Control': 'no-store, max-age=0',
'Access-Control-Allow-Origin': '*',
},
});
}

View File

@@ -0,0 +1,52 @@
const FALLBACK_MANIFEST = {
variants: {
mobile: { folder: 'mobile-image', images: [] },
desktop: { folder: 'desketop-image', images: [] },
},
};
export async function loadManifest(request) {
try {
const response = await fetch(new URL('/manifest.json', request.url));
if (!response.ok) return FALLBACK_MANIFEST;
return await response.json();
} catch {
return FALLBACK_MANIFEST;
}
}
export function detectVariant(request) {
const url = new URL(request.url);
const params = url.searchParams;
const mode = (params.get('mode') || 'auto').toLowerCase();
const orientation = (params.get('orientation') || '').toLowerCase();
const device = (params.get('device') || '').toLowerCase();
const ua = (request.headers.get('user-agent') || '').toLowerCase();
const chMobile = request.headers.get('sec-ch-ua-mobile');
if (mode === 'mobile' || mode === 'desktop') return mode;
if (orientation === 'portrait' || orientation === 'vertical') return 'mobile';
if (orientation === 'landscape' || orientation === 'horizontal') return 'desktop';
if (device === 'mobile' || device === 'desktop') return device;
if (chMobile === '?1') return 'mobile';
if (chMobile === '?0') return 'desktop';
if (['mobi', 'android', 'iphone', 'ipad', 'ipod', 'phone'].some((token) => ua.includes(token))) return 'mobile';
return 'desktop';
}
export function getVariantPayload(manifest, variant) {
const data = manifest?.variants?.[variant] || {};
const folder = data.folder || (variant === 'mobile' ? 'mobile-image' : 'desketop-image');
const images = Array.isArray(data.images)
? data.images.filter((name) => typeof name === 'string' && name.toLowerCase().endsWith('.webp'))
: [];
return { folder, images };
}
export function buildAssetUrl(request, folder, filename) {
return new URL(`/${folder}/${filename}`, request.url).toString();
}
export function pickRandom(items) {
return items[Math.floor(Math.random() * items.length)];
}

390
index.html Normal file
View File

@@ -0,0 +1,390 @@
<!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>

42
manifest.json Normal file
View File

@@ -0,0 +1,42 @@
{
"generated_at": "2026-03-28T07:14:07.314011+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",
"3.webp",
"4.webp",
"5.webp",
"6.webp",
"7.webp",
"8.webp",
"9.webp",
"10.webp",
"11.webp",
"12.webp",
"13.webp",
"14.webp",
"15.webp",
"16.webp",
"17.webp",
"18.webp",
"19.webp"
]
}
}
}

1
mobile-image/.gitkeep Normal file
View File

@@ -0,0 +1 @@

BIN
mobile-image/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

BIN
mobile-image/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

BIN
mobile-image/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
mobile-image/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
mobile-image/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

BIN
mobile-image/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

209
widget.js Normal file
View File

@@ -0,0 +1,209 @@
(() => {
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;
})();