Skip to content

Commit 49d484d

Browse files
committed
v2.5.11:随机图API支持设备自适应;管理端支持列表/卡片偏好记录,列表视图支持框选多选
1 parent 1f2ba70 commit 49d484d

23 files changed

+179
-17
lines changed
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

functions/random/adaptive.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* 自适应图片方向检测模块
3+
*
4+
* 提供设备检测和方向决策的纯函数,用于 orientation=auto 自适应模式。
5+
*/
6+
7+
/**
8+
* 移动设备 User-Agent 关键词匹配正则
9+
*/
10+
const MOBILE_UA_REGEX = /Mobile|Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini|IEMobile/i;
11+
12+
/**
13+
* 从 HTTP 请求中检测设备信息。
14+
*
15+
* 检测策略(按优先级):
16+
* 1. 优先读取 Client Hints 头(Sec-CH-Viewport-Width / Sec-CH-Viewport-Height),
17+
* 两者都存在且为有效正数时,返回视口宽高比。
18+
* 2. 回退到 User-Agent 解析,匹配移动设备关键词判断设备类型。
19+
* 3. 都无法判断时返回 { source: null }。
20+
*
21+
* @param {Request} request - Cloudflare Workers Request 对象
22+
* @returns {{ source: 'client-hints' | 'user-agent' | null, viewportRatio?: number, deviceType?: 'mobile' | 'desktop' }}
23+
*/
24+
export function detectDevice(request) {
25+
// 策略 1:Client Hints
26+
const viewportWidth = request.headers.get('Sec-CH-Viewport-Width');
27+
const viewportHeight = request.headers.get('Sec-CH-Viewport-Height');
28+
29+
if (viewportWidth !== null && viewportHeight !== null) {
30+
const width = Number(viewportWidth);
31+
const height = Number(viewportHeight);
32+
33+
if (isFinite(width) && isFinite(height) && width > 0 && height > 0) {
34+
return {
35+
source: 'client-hints',
36+
viewportRatio: width / height,
37+
};
38+
}
39+
}
40+
41+
// 策略 2:User-Agent 解析
42+
const userAgent = request.headers.get('User-Agent');
43+
44+
if (userAgent) {
45+
const isMobile = MOBILE_UA_REGEX.test(userAgent);
46+
return {
47+
source: 'user-agent',
48+
deviceType: isMobile ? 'mobile' : 'desktop',
49+
};
50+
}
51+
52+
// 策略 3:无法判断
53+
return { source: null };
54+
}
55+
56+
57+
/**
58+
* 根据设备信息决定图片方向。
59+
*
60+
* 决策逻辑:
61+
* - Client Hints 模式:根据视口宽高比判断
62+
* - ratio > 1.1 → 'landscape'
63+
* - ratio < 0.9 → 'portrait'
64+
* - 0.9 <= ratio <= 1.1 → 'square'
65+
* - User-Agent 模式:根据设备类型判断
66+
* - mobile → 'portrait'
67+
* - desktop → 'landscape'
68+
* - 无法判断时(source === null):返回空字符串,不进行方向过滤
69+
*
70+
* @param {{ source: 'client-hints' | 'user-agent' | null, viewportRatio?: number, deviceType?: 'mobile' | 'desktop' }} deviceInfo
71+
* @returns {'landscape' | 'portrait' | 'square' | ''}
72+
*/
73+
export function resolveOrientation(deviceInfo) {
74+
if (deviceInfo.source === 'client-hints') {
75+
const ratio = deviceInfo.viewportRatio;
76+
if (ratio > 1.1) {
77+
return 'landscape';
78+
}
79+
if (ratio < 0.9) {
80+
return 'portrait';
81+
}
82+
return 'square';
83+
}
84+
85+
if (deviceInfo.source === 'user-agent') {
86+
if (deviceInfo.deviceType === 'mobile') {
87+
return 'portrait';
88+
}
89+
if (deviceInfo.deviceType === 'desktop') {
90+
return 'landscape';
91+
}
92+
}
93+
94+
// source === null 或无法判断
95+
return '';
96+
}
97+
98+
/**
99+
* 为响应添加 Client Hints 协商头。
100+
*
101+
* - 设置 Accept-CH 头,请求浏览器在后续请求中发送视口尺寸信息
102+
* - 添加/追加 Vary 头,确保缓存按 Client Hints 和 User-Agent 区分
103+
*
104+
* @param {Headers} headers - 响应头对象
105+
* @returns {Headers} 添加了 Accept-CH 和 Vary 头的响应头对象
106+
*/
107+
export function addClientHintsHeaders(headers) {
108+
// 设置 Accept-CH 头
109+
headers.set('Accept-CH', 'Sec-CH-Viewport-Width, Sec-CH-Viewport-Height');
110+
111+
// 添加/追加 Vary 头
112+
const varyValues = ['Sec-CH-Viewport-Width', 'Sec-CH-Viewport-Height', 'User-Agent'];
113+
const existingVary = headers.get('Vary');
114+
115+
if (existingVary) {
116+
// 解析已有的 Vary 值,避免重复添加
117+
const existingValues = existingVary.split(',').map(v => v.trim().toLowerCase());
118+
const newValues = varyValues.filter(v => !existingValues.includes(v.toLowerCase()));
119+
120+
if (newValues.length > 0) {
121+
headers.set('Vary', existingVary + ', ' + newValues.join(', '));
122+
}
123+
} else {
124+
headers.set('Vary', varyValues.join(', '));
125+
}
126+
127+
return headers;
128+
}

functions/random/index.js

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { fetchOthersConfig } from "../utils/sysConfig";
22
import { readIndex } from "../utils/indexManager";
3+
import { detectDevice, resolveOrientation, addClientHintsHeaders } from "./adaptive.js";
34

45
let othersConfig = {};
56
let allowRandom = false;
@@ -40,8 +41,24 @@ export async function onRequest(context) {
4041
fileType = fileType.split(',');
4142
}
4243

43-
// 读取图片方向参数:landscape(横图), portrait(竖图), square(方图)
44-
const orientation = requestUrl.searchParams.get('orientation') || '';
44+
// 读取图片方向参数:landscape(横图), portrait(竖图), square(方图), auto(自适应)
45+
const orientationParam = requestUrl.searchParams.get('orientation') || '';
46+
47+
// 根据参数值决定行为
48+
const VALID_ORIENTATIONS = ['landscape', 'portrait', 'square'];
49+
let orientation = '';
50+
let isAutoMode = false;
51+
52+
if (VALID_ORIENTATIONS.includes(orientationParam)) {
53+
// 手动指定有效方向,直接使用
54+
orientation = orientationParam;
55+
} else if (orientationParam === 'auto') {
56+
// 自适应模式:检测设备并自动决策
57+
isAutoMode = true;
58+
const deviceInfo = detectDevice(request);
59+
orientation = resolveOrientation(deviceInfo);
60+
}
61+
// 其他情况(未指定或无效值):orientation 保持空字符串,不过滤
4562

4663
// 读取指定文件夹
4764
const paramDir = requestUrl.searchParams.get('dir') || '';
@@ -65,6 +82,9 @@ export async function onRequest(context) {
6582
// 筛选出符合fileType要求的记录
6683
allRecords = allRecords.filter(item => { return fileType.some(type => item.FileType?.includes(type)) });
6784

85+
// 保存过滤前的记录,用于自适应模式降级
86+
const allRecordsBeforeOrientationFilter = allRecords;
87+
6888
// 根据图片方向筛选
6989
if (orientation && allRecords.length > 0) {
7090
const SQUARE_THRESHOLD = 0.1; // 宽高比差异小于10%视为方图
@@ -86,9 +106,19 @@ export async function onRequest(context) {
86106
});
87107
}
88108

109+
// 自适应模式降级:过滤后无匹配图片时,降级到全部图片
110+
if (isAutoMode && orientation && allRecords.length === 0) {
111+
allRecords = allRecordsBeforeOrientationFilter;
112+
}
113+
114+
// 构建响应头:自适应模式下添加 Client Hints 协商头
115+
const responseHeaders = new Headers();
116+
if (isAutoMode) {
117+
addClientHintsHeaders(responseHeaders);
118+
}
89119

90120
if (allRecords.length == 0) {
91-
return new Response(JSON.stringify({}), { status: 200 });
121+
return new Response(JSON.stringify({}), { status: 200, headers: responseHeaders });
92122
} else {
93123
const randomIndex = Math.floor(Math.random() * allRecords.length);
94124
const randomKey = allRecords[randomIndex];
@@ -108,19 +138,23 @@ export async function onRequest(context) {
108138
// Return an image response
109139
randomUrl = requestUrl.origin + randomPath;
110140
let contentType = 'image/jpeg';
141+
const imgHeaders = new Headers(responseHeaders);
111142
return new Response(await fetch(randomUrl).then(res => {
112143
contentType = res.headers.get('content-type');
113144
return res.blob();
114145
}), {
115-
headers: contentType ? { 'Content-Type': contentType } : { 'Content-Type': 'image/jpeg' },
146+
headers: (() => {
147+
imgHeaders.set('Content-Type', contentType || 'image/jpeg');
148+
return imgHeaders;
149+
})(),
116150
status: 200
117151
});
118152
}
119153

120154
if (resType == 'text') {
121-
return new Response(randomUrl, { status: 200 });
155+
return new Response(randomUrl, { status: 200, headers: responseHeaders });
122156
} else {
123-
return new Response(JSON.stringify({ url: randomUrl }), { status: 200 });
157+
return new Response(JSON.stringify({ url: randomUrl }), { status: 200, headers: responseHeaders });
124158
}
125159
}
126160
}

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/logo.png"><link rel="apple-touch-icon" href="/logo.png"><link rel="mask-icon" href="/logo.png" color="#f4b400"><meta name="description" content="Sanyue ImgHub - A modern file hosting platform"><meta name="keywords" content="Sanyue, ImgHub, file hosting, image hosting, cloud storage"><meta name="author" content="SanyueQi"><title>Sanyue ImgHub</title><script defer="defer" src="/js/chunk-vendors.75bb1397.js"></script><script defer="defer" src="/js/app.f1037a91.js"></script><link href="/css/chunk-vendors.4363ed49.css" rel="stylesheet"><link href="/css/app.75711fde.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but sanyue_imghub doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
1+
<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/logo.png"><link rel="apple-touch-icon" href="/logo.png"><link rel="mask-icon" href="/logo.png" color="#f4b400"><meta name="description" content="Sanyue ImgHub - A modern file hosting platform"><meta name="keywords" content="Sanyue, ImgHub, file hosting, image hosting, cloud storage"><meta name="author" content="SanyueQi"><title>Sanyue ImgHub</title><script defer="defer" src="/js/chunk-vendors.75bb1397.js"></script><script defer="defer" src="/js/app.1b7ddf66.js"></script><link href="/css/chunk-vendors.4363ed49.css" rel="stylesheet"><link href="/css/app.75711fde.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but sanyue_imghub doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

index.html.gz

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)