commit f68e19a3bd000f506564faeb503bd1faa72f05c5 Author: shumengya Date: Sun Mar 29 10:58:33 2026 +0800 initial import: random background api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a1cd7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.build-venv/ +.wrangler/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..d27d357 --- /dev/null +++ b/README.md @@ -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 + + + +``` + +`widget.js` 只读取静态的 `manifest.json` 和图片文件,不会调用 `api/random`。 + +## 构建说明 + +`build.py` 会把目录里的原图转换成同目录下的编号 `webp` 文件,例如: + +- `1.webp` +- `2.webp` +- `55.webp` + +同时生成 `manifest.json`,供 Pages Functions 读取。 diff --git a/_headers b/_headers new file mode 100644 index 0000000..844f195 --- /dev/null +++ b/_headers @@ -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 diff --git a/build.py b/build.py new file mode 100644 index 0000000..6b0859f --- /dev/null +++ b/build.py @@ -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()) diff --git a/desketop-image/.gitkeep b/desketop-image/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/desketop-image/.gitkeep @@ -0,0 +1 @@ + diff --git a/desketop-image/1.webp b/desketop-image/1.webp new file mode 100644 index 0000000..2c22953 Binary files /dev/null and b/desketop-image/1.webp differ diff --git a/desketop-image/10.webp b/desketop-image/10.webp new file mode 100644 index 0000000..6c8eb80 Binary files /dev/null and b/desketop-image/10.webp differ diff --git a/desketop-image/11.webp b/desketop-image/11.webp new file mode 100644 index 0000000..07a4d25 Binary files /dev/null and b/desketop-image/11.webp differ diff --git a/desketop-image/12.webp b/desketop-image/12.webp new file mode 100644 index 0000000..7ed3417 Binary files /dev/null and b/desketop-image/12.webp differ diff --git a/desketop-image/13.webp b/desketop-image/13.webp new file mode 100644 index 0000000..b587ec4 Binary files /dev/null and b/desketop-image/13.webp differ diff --git a/desketop-image/14.webp b/desketop-image/14.webp new file mode 100644 index 0000000..5afd06e Binary files /dev/null and b/desketop-image/14.webp differ diff --git a/desketop-image/15.webp b/desketop-image/15.webp new file mode 100644 index 0000000..7ee04a9 Binary files /dev/null and b/desketop-image/15.webp differ diff --git a/desketop-image/16.webp b/desketop-image/16.webp new file mode 100644 index 0000000..d239c20 Binary files /dev/null and b/desketop-image/16.webp differ diff --git a/desketop-image/17.webp b/desketop-image/17.webp new file mode 100644 index 0000000..0f27713 Binary files /dev/null and b/desketop-image/17.webp differ diff --git a/desketop-image/18.webp b/desketop-image/18.webp new file mode 100644 index 0000000..d281c39 Binary files /dev/null and b/desketop-image/18.webp differ diff --git a/desketop-image/19.webp b/desketop-image/19.webp new file mode 100644 index 0000000..1997f9f Binary files /dev/null and b/desketop-image/19.webp differ diff --git a/desketop-image/2.webp b/desketop-image/2.webp new file mode 100644 index 0000000..6825a63 Binary files /dev/null and b/desketop-image/2.webp differ diff --git a/desketop-image/3.webp b/desketop-image/3.webp new file mode 100644 index 0000000..a699097 Binary files /dev/null and b/desketop-image/3.webp differ diff --git a/desketop-image/3yfvfgrD.webp b/desketop-image/3yfvfgrD.webp new file mode 100644 index 0000000..e8af8ec Binary files /dev/null and b/desketop-image/3yfvfgrD.webp differ diff --git a/desketop-image/4.webp b/desketop-image/4.webp new file mode 100644 index 0000000..21af0f9 Binary files /dev/null and b/desketop-image/4.webp differ diff --git a/desketop-image/5.webp b/desketop-image/5.webp new file mode 100644 index 0000000..d8ecf88 Binary files /dev/null and b/desketop-image/5.webp differ diff --git a/desketop-image/5CrdoShv.webp b/desketop-image/5CrdoShv.webp new file mode 100644 index 0000000..908add8 Binary files /dev/null and b/desketop-image/5CrdoShv.webp differ diff --git a/desketop-image/5E2wsNlc.webp b/desketop-image/5E2wsNlc.webp new file mode 100644 index 0000000..e9adaea Binary files /dev/null and b/desketop-image/5E2wsNlc.webp differ diff --git a/desketop-image/6.webp b/desketop-image/6.webp new file mode 100644 index 0000000..e1ef0d5 Binary files /dev/null and b/desketop-image/6.webp differ diff --git a/desketop-image/7.webp b/desketop-image/7.webp new file mode 100644 index 0000000..5d79439 Binary files /dev/null and b/desketop-image/7.webp differ diff --git a/desketop-image/8.webp b/desketop-image/8.webp new file mode 100644 index 0000000..daef5ea Binary files /dev/null and b/desketop-image/8.webp differ diff --git a/desketop-image/9.webp b/desketop-image/9.webp new file mode 100644 index 0000000..889147d Binary files /dev/null and b/desketop-image/9.webp differ diff --git a/desketop-image/AGWXMclV.webp b/desketop-image/AGWXMclV.webp new file mode 100644 index 0000000..c9463a9 Binary files /dev/null and b/desketop-image/AGWXMclV.webp differ diff --git a/desketop-image/IIbmur0t.webp b/desketop-image/IIbmur0t.webp new file mode 100644 index 0000000..0256344 Binary files /dev/null and b/desketop-image/IIbmur0t.webp differ diff --git a/desketop-image/ItOJOHST.webp b/desketop-image/ItOJOHST.webp new file mode 100644 index 0000000..ec5ff63 Binary files /dev/null and b/desketop-image/ItOJOHST.webp differ diff --git a/desketop-image/L0nQHehz.webp b/desketop-image/L0nQHehz.webp new file mode 100644 index 0000000..1139765 Binary files /dev/null and b/desketop-image/L0nQHehz.webp differ diff --git a/desketop-image/RK0sjvKj.webp b/desketop-image/RK0sjvKj.webp new file mode 100644 index 0000000..dfb950f Binary files /dev/null and b/desketop-image/RK0sjvKj.webp differ diff --git a/desketop-image/XI78fkzD.webp b/desketop-image/XI78fkzD.webp new file mode 100644 index 0000000..c70ad70 Binary files /dev/null and b/desketop-image/XI78fkzD.webp differ diff --git a/desketop-image/atm7rt6D.webp b/desketop-image/atm7rt6D.webp new file mode 100644 index 0000000..bc3eaf7 Binary files /dev/null and b/desketop-image/atm7rt6D.webp differ diff --git a/desketop-image/c2HxMuGK.webp b/desketop-image/c2HxMuGK.webp new file mode 100644 index 0000000..32dae92 Binary files /dev/null and b/desketop-image/c2HxMuGK.webp differ diff --git a/desketop-image/cUDkKiOf.webp b/desketop-image/cUDkKiOf.webp new file mode 100644 index 0000000..90846fb Binary files /dev/null and b/desketop-image/cUDkKiOf.webp differ diff --git a/desketop-image/cuSpSkq4.webp b/desketop-image/cuSpSkq4.webp new file mode 100644 index 0000000..4a6e914 Binary files /dev/null and b/desketop-image/cuSpSkq4.webp differ diff --git a/desketop-image/hj64Cqxn.webp b/desketop-image/hj64Cqxn.webp new file mode 100644 index 0000000..d09984a Binary files /dev/null and b/desketop-image/hj64Cqxn.webp differ diff --git a/desketop-image/mrSQGVzt.webp b/desketop-image/mrSQGVzt.webp new file mode 100644 index 0000000..dcb0c23 Binary files /dev/null and b/desketop-image/mrSQGVzt.webp differ diff --git a/desketop-image/mtQKTMpU.webp b/desketop-image/mtQKTMpU.webp new file mode 100644 index 0000000..b43d8dd Binary files /dev/null and b/desketop-image/mtQKTMpU.webp differ diff --git a/desketop-image/nhS9Wnnh.webp b/desketop-image/nhS9Wnnh.webp new file mode 100644 index 0000000..9d9815c Binary files /dev/null and b/desketop-image/nhS9Wnnh.webp differ diff --git a/desketop-image/xTsVkCli.webp b/desketop-image/xTsVkCli.webp new file mode 100644 index 0000000..2c6433f Binary files /dev/null and b/desketop-image/xTsVkCli.webp differ diff --git a/desketop-image/zr6atT6m.webp b/desketop-image/zr6atT6m.webp new file mode 100644 index 0000000..028b9ea Binary files /dev/null and b/desketop-image/zr6atT6m.webp differ diff --git a/functions/api/list.js b/functions/api/list.js new file mode 100644 index 0000000..f3c34bb --- /dev/null +++ b/functions/api/list.js @@ -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 }; +} diff --git a/functions/api/random.js b/functions/api/random.js new file mode 100644 index 0000000..cff8383 --- /dev/null +++ b/functions/api/random.js @@ -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': '*', + }, + }); +} diff --git a/functions/lib/background.js b/functions/lib/background.js new file mode 100644 index 0000000..f254805 --- /dev/null +++ b/functions/lib/background.js @@ -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)]; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..93b95ed --- /dev/null +++ b/index.html @@ -0,0 +1,390 @@ + + + + + + 随机背景 API + + + + +
+
+
+

随机背景 API

+

Cloudflare Pages 随机背景接口。上传图片后先用 `build.py` 生成序号 `.webp`,然后可通过 `/api/random` 和 `/api/list` 调用。

+
+ 竖屏:mobile-image + 横屏:desketop-image + 返回:302 / JSON + 组件:widget.js +
+
+ +
+

快速链接

+ +
+
+ +
+
+

API 调用

+
+
+ 随机图片 +
默认 302 跳转到随机图片。
+
GET /api/random
+
+
+ 返回 JSON +
适合前端自己处理图片地址。
+
GET /api/random?format=json
+
+
+ +
+
+ 强制竖屏 +
GET /api/random?mode=mobile
+
+
+ 强制横屏 +
GET /api/random?mode=desktop
+
+
+ +

支持参数:`mode=auto|mobile|desktop`、`orientation=portrait|landscape`、`device=mobile|desktop`、`format=redirect|json`。

+
+ +
+

在线预览

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ 随机背景预览 +
+
+
+ +
{}
+
+
+
+
+ +
+

嵌入组件

+
+
+ 直接引用 +
<script src="https://random-background-api.pages.dev/widget.js"></script>
+<random-background-widget
+  src="https://random-background-api.pages.dev"
+  blur="12"
+  radius="16px"
+  height="260px">
+</random-background-widget>
+
+
+ 说明 +
组件只读静态 `manifest.json` 和图片,不消耗 Functions 额度;`blur` 控制高斯模糊像素值。
+
+
+
+ + +
+ + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..fffe585 --- /dev/null +++ b/manifest.json @@ -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" + ] + } + } +} diff --git a/mobile-image/.gitkeep b/mobile-image/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mobile-image/.gitkeep @@ -0,0 +1 @@ + diff --git a/mobile-image/1.webp b/mobile-image/1.webp new file mode 100644 index 0000000..35d69e3 Binary files /dev/null and b/mobile-image/1.webp differ diff --git a/mobile-image/1772108021881_44374ab6c1daa54e0204bca48ac382f2.png b/mobile-image/1772108021881_44374ab6c1daa54e0204bca48ac382f2.png new file mode 100644 index 0000000..c2bdc79 Binary files /dev/null and b/mobile-image/1772108021881_44374ab6c1daa54e0204bca48ac382f2.png differ diff --git a/mobile-image/1772108021977_8020902a0c8788538eee1cd06e784c6a.png b/mobile-image/1772108021977_8020902a0c8788538eee1cd06e784c6a.png new file mode 100644 index 0000000..8ba092f Binary files /dev/null and b/mobile-image/1772108021977_8020902a0c8788538eee1cd06e784c6a.png differ diff --git a/mobile-image/1772108022800_f945774e0a45f7a4afdc3da2b112025f.png b/mobile-image/1772108022800_f945774e0a45f7a4afdc3da2b112025f.png new file mode 100644 index 0000000..fd16663 Binary files /dev/null and b/mobile-image/1772108022800_f945774e0a45f7a4afdc3da2b112025f.png differ diff --git a/mobile-image/1772108024006_3f9030ba77e355869115bc90fe019d53.png b/mobile-image/1772108024006_3f9030ba77e355869115bc90fe019d53.png new file mode 100644 index 0000000..a514b02 Binary files /dev/null and b/mobile-image/1772108024006_3f9030ba77e355869115bc90fe019d53.png differ diff --git a/mobile-image/1772108030393_159bbf61f88b38475ee9144a2e8e4956.png b/mobile-image/1772108030393_159bbf61f88b38475ee9144a2e8e4956.png new file mode 100644 index 0000000..228aa28 Binary files /dev/null and b/mobile-image/1772108030393_159bbf61f88b38475ee9144a2e8e4956.png differ diff --git a/mobile-image/1772108123232_VJ86r.jpg b/mobile-image/1772108123232_VJ86r.jpg new file mode 100644 index 0000000..2814cd3 Binary files /dev/null and b/mobile-image/1772108123232_VJ86r.jpg differ diff --git a/mobile-image/2.webp b/mobile-image/2.webp new file mode 100644 index 0000000..689e07f Binary files /dev/null and b/mobile-image/2.webp differ diff --git a/mobile-image/3.webp b/mobile-image/3.webp new file mode 100644 index 0000000..74f13f7 Binary files /dev/null and b/mobile-image/3.webp differ diff --git a/mobile-image/4.webp b/mobile-image/4.webp new file mode 100644 index 0000000..95dd2e1 Binary files /dev/null and b/mobile-image/4.webp differ diff --git a/mobile-image/5.webp b/mobile-image/5.webp new file mode 100644 index 0000000..f10b9a2 Binary files /dev/null and b/mobile-image/5.webp differ diff --git a/mobile-image/6.webp b/mobile-image/6.webp new file mode 100644 index 0000000..a37bf8a Binary files /dev/null and b/mobile-image/6.webp differ diff --git a/widget.js b/widget.js new file mode 100644 index 0000000..8f088ce --- /dev/null +++ b/widget.js @@ -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 = ` + +
+ random background +
加载中
+
+ `; + } + + 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; +})();