Greasy Fork is available in English.
在右下角添加悬浮球,支持 S3/OSS/R2/SMMS/ImgURL 上传,支持剪贴板直接粘贴图片(Ctrl+V)
// ==UserScript==
// @name 图床上传脚本
// @namespace http://21zys.com/
// @version 1.8.0
// @description 在右下角添加悬浮球,支持 S3/OSS/R2/SMMS/ImgURL 上传,支持剪贴板直接粘贴图片(Ctrl+V)
// @match *://*/*
// @author 21zys
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @require https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.13/dayjs.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/uuid/8.3.2/uuid.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js
// @connect *
// @license MIT
// ==/UserScript==
(function() {
'use strict';
if (window !== window.top) return;
// --- 核心工具:配置加载器 ---
function loadConfig(baseKey, isScoped, defaultData) {
const key = isScoped ? `${baseKey}_${window.location.hostname}` : baseKey;
let data = null;
try {
data = JSON.parse(GM_getValue(key) || localStorage.getItem(key));
} catch (e) {}
return { ...defaultData, ...data };
}
function saveConfig(baseKey, isScoped, data) {
const key = isScoped ? `${baseKey}_${window.location.hostname}` : baseKey;
const str = JSON.stringify(data);
GM_setValue(key, str);
localStorage.setItem(key, str);
}
// --- 工具函数:DOM 创建 ---
function createEl(tag, styles = {}, props = {}, parent = null) {
const el = document.createElement(tag);
Object.assign(el.style, styles);
for (const key in props) {
if (key === 'dataset') Object.assign(el.dataset, props[key]);
else el[key] = props[key];
}
if (parent) parent.appendChild(el);
return el;
}
// --- 工具函数:拖拽 ---
function makeDraggable(element, storageKey, handle = null, restrictToEdge = true) {
const target = handle || element;
let isDragging = false, startX, startY;
target.addEventListener('mousedown', (e) => {
if ((handle && e.target !== handle) || (e.target !== target && e.target.parentElement !== target)) return;
if (!handle && restrictToEdge) {
const rect = element.getBoundingClientRect();
const edge = 25;
if (e.clientX - rect.left > edge && e.clientX - rect.left < element.clientWidth - edge &&
e.clientY - rect.top > edge && e.clientY - rect.top < element.clientHeight - edge) return;
}
startX = e.clientX; startY = e.clientY;
const rect = element.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
const onMouseMove = (e) => {
if (!isDragging && (Math.abs(e.clientX - startX) > 5 || Math.abs(e.clientY - startY) > 5)) isDragging = true;
if (isDragging) {
element.style.left = (e.clientX - offsetX) + 'px';
element.style.top = (e.clientY - offsetY) + 'px';
element.style.right = 'auto'; element.style.bottom = 'auto'; element.style.transform = 'none';
}
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (isDragging && storageKey) localStorage.setItem(storageKey, JSON.stringify({ left: element.style.left, top: element.style.top }));
setTimeout(() => isDragging = false, 100);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
// --- 剪贴板监听逻辑 ---
let activeUploadDialog = null; // 当前激活的对话框
document.addEventListener('paste', (e) => {
if (!activeUploadDialog || activeUploadDialog.style.display === 'none') return;
// 如果用户焦点在输入框中(例如粘贴Token),不拦截
if (e.target.tagName === 'INPUT' && (e.target.type === 'text' || e.target.type === 'password' || e.target.type === 'number')) return;
if (e.target.tagName === 'TEXTAREA') return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (activeUploadDialog.handlePaste && file) {
e.preventDefault();
activeUploadDialog.handlePaste(file);
// 简单的视觉闪烁反馈
const originalBg = activeUploadDialog.style.backgroundColor;
activeUploadDialog.style.backgroundColor = 'rgba(230, 255, 230, 0.95)';
setTimeout(() => activeUploadDialog.style.backgroundColor = originalBg, 200);
}
break;
}
}
});
// --- UI 组件:基础对话框 ---
function createBaseDialog(uniqueId) {
const posKey = `DialogPos_${uniqueId}`;
const savedPos = JSON.parse(localStorage.getItem(posKey)) || null;
const dialog = createEl('div', {
position: 'fixed', width: '400px', padding: '20px',
backgroundColor: 'rgba(255, 255, 255, 0.98)', borderRadius: '12px',
backdropFilter: 'blur(10px)', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
display: 'none', opacity: '0', zIndex: '999999', fontFamily: 'Arial, sans-serif',
transition: 'opacity 0.2s ease',
left: savedPos ? savedPos.left : '50%', top: savedPos ? savedPos.top : '50%',
transform: savedPos ? 'none' : 'translate(-50%, -50%)',
maxHeight: '85vh', overflowY: 'auto'
}, {}, document.body);
makeDraggable(dialog, posKey);
createEl('span', { position: 'absolute', top: '10px', right: '15px', cursor: 'pointer', fontSize: '24px', color: '#666', lineHeight: '20px' }, { innerHTML: '×', onclick: () => closeDialog(dialog) }, dialog);
return dialog;
}
const commonStyles = {
label: { fontWeight: 'bold', color: '#333', display: 'inline-block', fontSize: '13px', marginBottom: '4px' },
input: { padding: '8px', border: '1px solid #ccc', borderRadius: '4px', width: '95%', fontSize: '13px', boxSizing: 'border-box' },
btn: { padding: '8px 16px', border: 'none', borderRadius: '4px', cursor: 'pointer', transition: '0.3s' }
};
function createInputRow(form, labelText, inputName, value = '', placeholder = '', type = 'text') {
const wrapper = createEl('div', { marginBottom: '8px' }, {}, form);
createEl('label', commonStyles.label, { innerText: labelText }, wrapper);
return createEl('input', commonStyles.input, { type: type, name: inputName, value: value, placeholder: placeholder }, wrapper);
}
function createScopeSwitch(parent, baseKey, onSwitch) {
const wrapper = createEl('div', { marginBottom: '15px', padding: '8px', backgroundColor: '#f0f4f8', borderRadius: '6px', fontSize: '13px', display: 'flex', alignItems: 'center' }, {}, parent);
const stateKey = `ConfigScopeState_${baseKey}_${window.location.hostname}`;
const isChecked = localStorage.getItem(stateKey) === 'true';
const cbId = `scope-cb-${baseKey}`;
const checkbox = createEl('input', { marginRight: '8px', cursor: 'pointer' }, { type: 'checkbox', id: cbId, checked: isChecked }, wrapper);
const label = createEl('label', { cursor: 'pointer', userSelect: 'none', color: '#0056b3', fontWeight: 'bold' }, { htmlFor: cbId, innerText: '为当前域名启用独立配置' }, wrapper);
checkbox.onchange = () => {
localStorage.setItem(stateKey, checkbox.checked);
onSwitch(checkbox.checked);
wrapper.style.backgroundColor = checkbox.checked ? '#e3f2fd' : '#f0f4f8';
label.innerText = checkbox.checked ? `正在使用: ${window.location.hostname} 独立配置` : '正在使用: 全局通用配置';
};
wrapper.style.backgroundColor = isChecked ? '#e3f2fd' : '#f0f4f8';
label.innerText = isChecked ? `正在使用: ${window.location.hostname} 独立配置` : '正在使用: 全局通用配置';
return checkbox;
}
function createThumbnailControl(parent, data, onSave) {
const container = createEl('div', { marginTop: '5px', marginBottom: '5px', display: 'flex', alignItems: 'center' }, {}, parent);
const cbId = 'cb-thumb-' + Math.random().toString(36).substr(2, 5);
const checkbox = createEl('input', { marginRight: '5px' }, { type: 'checkbox', id: cbId, checked: data.enableThumbnail || false }, container);
createEl('label', { color: '#333', cursor: 'pointer', marginRight: '10px', fontSize: '13px' }, { innerText: '缩略图', htmlFor: cbId }, container);
const sizeInput = createEl('input', { width: '60px', padding: '4px', border: '1px solid #ccc', borderRadius: '4px', fontSize: '13px' },
{ type: 'number', value: data.thumbnailSize || 128, min: 1, disabled: !data.enableThumbnail }, container);
createEl('span', { fontSize: '13px', color: '#666', marginLeft: '5px' }, { innerText: 'px' }, container);
checkbox.onchange = () => { data.enableThumbnail = checkbox.checked; sizeInput.disabled = !checkbox.checked; onSave(); };
sizeInput.oninput = () => { if (sizeInput.value > 0) { data.thumbnailSize = parseInt(sizeInput.value); onSave(); } };
}
// --- 文件选择与粘贴处理组件 ---
function createFileSelector(parent, labelText) {
const wrapper = createEl('div', { marginBottom: '8px' }, {}, parent);
createEl('label', commonStyles.label, { innerText: labelText + ' (支持Ctrl+V粘贴)' }, wrapper);
const fileInput = createEl('input', commonStyles.input, { type: 'file' }, wrapper);
const statusSpan = createEl('div', { fontSize: '12px', color: '#666', marginTop: '2px', height: '16px', lineHeight: '16px', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }, { innerText: '未选择文件' }, wrapper);
let pastedFile = null;
fileInput.onchange = () => {
if (fileInput.files.length) {
pastedFile = null; // 清除粘贴的文件
statusSpan.innerText = `已选文件: ${fileInput.files[0].name}`;
statusSpan.style.color = '#333';
} else {
statusSpan.innerText = '未选择文件';
}
};
const handlePaste = (file) => {
pastedFile = file;
fileInput.value = ''; // 清空 input 选择
const size = (file.size / 1024).toFixed(1) + 'KB';
statusSpan.innerHTML = `📷 <b>已捕获剪贴板图片</b> (大小: ${size})`;
statusSpan.style.color = '#28a745';
};
const getFile = () => {
return pastedFile || (fileInput.files.length ? fileInput.files[0] : null);
};
const clear = () => {
fileInput.value = '';
pastedFile = null;
statusSpan.innerText = '未选择文件';
statusSpan.style.color = '#666';
};
return { wrapper, getFile, handlePaste, clear };
}
// --- 悬浮球布局 ---
const savedBallPos = JSON.parse(localStorage.getItem('floatingBallPosition')) || { right: '30px', bottom: '30px' };
const floatingContainer = createEl('div', {
position: 'fixed', right: savedBallPos.right, bottom: savedBallPos.bottom,
left: savedBallPos.left || 'auto', top: savedBallPos.top || 'auto',
width: '50px', height: '50px', zIndex: '99990'
}, {}, document.body);
makeDraggable(floatingContainer, 'floatingBallPosition', null, false);
const floatingBall = createEl('div', {
width: '50px', height: '50px', borderRadius: '50%', backgroundColor: '#007bff',
color: '#fff', textAlign: 'center', lineHeight: '50px', cursor: 'pointer',
fontSize: '24px', userSelect: 'none', boxShadow: '2px 2px 8px rgba(0,0,0,0.2)', position: 'relative'
}, { innerHTML: '+' }, floatingContainer);
const createSubBtn = (icon, x, y, onClick) => {
const btn = createEl('div', {
position: 'absolute', left: x, top: y, width: '40px', height: '40px',
background: icon.startsWith('http') ? `url('${icon}') no-repeat center center` : 'white',
backgroundSize: 'contain', backgroundColor: '#fff', borderRadius: '50%',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)', cursor: 'pointer', display: 'none', zIndex: '-1',
textAlign: 'center', lineHeight: '40px', fontSize: '12px', color: '#333', fontWeight: 'bold'
}, { innerText: icon.startsWith('http') ? '' : icon }, floatingBall);
btn.onclick = (e) => { e.stopPropagation(); onClick(); };
return btn;
};
const imgUrlBtn = createSubBtn('https://www.imgurl.org/favicon.ico', '-35px', '-15px', () => openDialog(initImgUrlDialog()));
const smmsBtn = createSubBtn('https://smms.app/favicon-32x32.png', '5px', '-40px', () => openDialog(initSmmsDialog()));
const s3Btn = createSubBtn('S3', '47px', '-15px', () => openDialog(initS3Dialog()));
Object.assign(s3Btn.style, { color: '#ff9900' });
floatingContainer.onmouseenter = () => [imgUrlBtn, smmsBtn, s3Btn].forEach(b => { b.style.display = 'block'; setTimeout(() => b.style.zIndex = '99999', 0); });
floatingContainer.onmouseleave = () => [imgUrlBtn, smmsBtn, s3Btn].forEach(b => b.style.display = 'none');
// --- 窗口管理 ---
let dialogs = {};
function openDialog(dialog) {
Object.values(dialogs).forEach(d => { if (d && d !== dialog) closeDialog(d); });
if (dialog.dataset.closeTimer) { clearTimeout(dialog.dataset.closeTimer); delete dialog.dataset.closeTimer; }
dialog.style.display = 'block';
dialog.offsetHeight;
dialog.style.opacity = '1';
activeUploadDialog = dialog; // 标记当前活动窗口,供粘贴事件使用
}
function closeDialog(dialog) {
dialog.style.opacity = '0';
const timerId = setTimeout(() => {
dialog.style.display = 'none';
delete dialog.dataset.closeTimer;
if (activeUploadDialog === dialog) activeUploadDialog = null;
}, 300);
dialog.dataset.closeTimer = timerId;
}
function setupResultArea(dialog, initialTab, onTabChange) {
const tabBox = createEl('div', { display: 'flex', marginTop: '10px' }, {}, dialog);
const resBox = createEl('div', { marginTop: '5px' }, {}, dialog);
const input = createEl('input', { width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px', cursor: 'pointer', boxSizing: 'border-box' }, { readOnly: true, placeholder: '上传结果' }, resBox);
input.onclick = () => { if(input.value) { GM_setClipboard(input.value); const old = input.value; input.value = '已复制!'; setTimeout(() => input.value = old, 1000); }};
let curTab = initialTab;
const tabs = ['MarkDown', 'HTML', 'imgURL', 'BBCode'];
const btns = [];
const update = () => {
const url = input.dataset.url; if(!url) return;
const map = { HTML: `<img src="${url}" alt="img">`, imgURL: url, MarkDown: ``, BBCode: `[IMG]${url}[/IMG]` };
input.value = map[curTab] || url;
};
tabs.forEach(t => {
const b = createEl('button', { flex: '1', padding: '5px', border: '1px solid #ccc', background: t===curTab?'#007bff':'#f8f9fa', color: t===curTab?'#fff':'#333', cursor: 'pointer', fontSize:'12px' }, { textContent: t }, tabBox);
b.onclick = (e) => { e.preventDefault(); curTab = t; onTabChange(t); btns.forEach(btn => Object.assign(btn.style, {background: btn.textContent===t?'#007bff':'#f8f9fa', color: btn.textContent===t?'#fff':'#333'})); update(); };
btns.push(b);
});
return { input, update };
}
function createProgress() {
const div = createEl('div', { marginTop: '10px', display: 'none' });
const bar = createEl('progress', { width: '100%', height: '15px' }, { value: 0, max: 100 }, div);
return { div, bar };
}
// --- S3 Dialog ---
function initS3Dialog() {
if (dialogs.s3) return dialogs.s3;
const BASE_KEY = 'S3Config';
const defaultData = {
endpoint: '', region: 'auto', bucket: '', accessKeyId: '', secretAccessKey: '', folder: 'img/',
customDomain: '', renamePattern: '{Y}{m}{d}_{md5-16}', enableThumbnail: false, thumbnailSize: 128,
uploadCount: 0, uploadDate: dayjs().format('YYYY-MM-DD'), selectedTab: 'MarkDown', autoIncrement: 0
};
const dialog = createBaseDialog('S3');
createEl('h3', { textAlign: 'center', margin: '0 0 10px 0', fontSize: '16px' }, { innerText: 'S3 兼容对象存储' }, dialog);
let currentData = {};
let isScopedMode = false;
const onModeSwitch = (isScoped) => {
isScopedMode = isScoped;
currentData = loadConfig(BASE_KEY, isScoped, defaultData);
epInput.value = currentData.endpoint || ''; bucketInput.value = currentData.bucket || '';
regionInput.value = currentData.region || ''; akInput.value = currentData.accessKeyId || '';
skInput.value = currentData.secretAccessKey || ''; folderInput.value = currentData.folder || '';
domainInput.value = currentData.customDomain || ''; renameInput.value = currentData.renamePattern || '';
countLabel.textContent = `今日: ${getDateCount(currentData)}`;
};
const switchEl = createScopeSwitch(dialog, BASE_KEY, onModeSwitch);
const form = createEl('form', {}, { method: 'post' }, dialog);
const details = createEl('details', { border: '1px solid #eee', padding: '5px', borderRadius: '4px', marginBottom: '10px' }, { open: true }, form);
createEl('summary', { cursor: 'pointer', fontSize: '13px', fontWeight: 'bold' }, { innerText: '参数配置' }, details);
const epInput = createInputRow(details, 'Endpoint:', 'ep');
const bucketInput = createInputRow(details, 'Bucket:', 'bucket');
const regionInput = createInputRow(details, 'Region:', 'region');
const akInput = createInputRow(details, 'AccessKey:', 'ak');
const skInput = createInputRow(details, 'SecretKey:', 'sk', '', '', 'password');
const folderInput = createInputRow(details, '路径:', 'folder');
const domainInput = createInputRow(details, '自定义域名:', 'domain');
const renameInput = createInputRow(form, '重命名规则:', 'rename');
createThumbnailControl(form, defaultData, () => {});
// 使用新的文件选择器组件
const fileSelector = createFileSelector(form, '选择文件');
// 绑定粘贴处理到对话框对象,供全局事件调用
dialog.handlePaste = fileSelector.handlePaste;
const btnBox = createEl('div', { marginTop: '10px', textAlign: 'right' }, {}, form);
const countLabel = createEl('span', { fontSize: '12px', color: '#666', marginRight: '10px' }, {}, btnBox);
const upBtn = createEl('input', { ...commonStyles.btn, background: '#ff9900', color: '#fff', marginRight: '5px' }, { type: 'submit', value: '上传' }, btnBox);
const clBtn = createEl('input', { ...commonStyles.btn, background: '#6c757d', color: '#fff' }, { type: 'button', value: '清空' }, btnBox);
const { div: progDiv, bar: progBar } = createProgress(); dialog.appendChild(progDiv);
const { input: resInput, update: resUpdate } = setupResultArea(dialog, 'MarkDown', (t) => { currentData.selectedTab = t; saveData(); });
function getDateCount(data) {
if (data.uploadDate !== dayjs().format('YYYY-MM-DD')) { data.uploadDate = dayjs().format('YYYY-MM-DD'); data.uploadCount = 0; }
return data.uploadCount;
}
function saveData() { saveConfig(BASE_KEY, isScopedMode, currentData); }
onModeSwitch(switchEl.checked);
clBtn.onclick = () => { fileSelector.clear(); resInput.value = ''; delete resInput.dataset.url; };
form.onsubmit = (e) => {
e.preventDefault();
currentData.endpoint = epInput.value.trim(); currentData.bucket = bucketInput.value.trim();
currentData.region = regionInput.value.trim(); currentData.accessKeyId = akInput.value.trim();
currentData.secretAccessKey = skInput.value.trim(); currentData.folder = folderInput.value.trim();
currentData.customDomain = domainInput.value.trim().replace(/\/$/, ''); currentData.renamePattern = renameInput.value.trim();
currentData.autoIncrement = (currentData.autoIncrement || 0) + 1;
saveData();
if (!currentData.endpoint || !currentData.bucket) return alertRes(resInput, '配置不全', 'red');
const file = fileSelector.getFile();
if (!file) return alertRes(resInput, '请选文件', 'red');
processImage(file, currentData, (blob) => {
const fname = superRename(file.name || 'image.png', currentData.renamePattern, currentData.autoIncrement);
uploadToS3(blob, fname, currentData, {
onProgress: (p) => { progDiv.style.display = 'block'; progBar.value = p; },
onSuccess: (url) => {
progDiv.style.display = 'none'; currentData.uploadCount++; saveData();
countLabel.textContent = `今日: ${currentData.uploadCount}`; handleSuccess(resInput, resUpdate, url);
},
onError: (msg) => { progDiv.style.display = 'none'; alertRes(resInput, msg, 'red'); }
});
});
};
dialogs.s3 = dialog; return dialog;
}
// --- SM.MS Dialog ---
function initSmmsDialog() {
if (dialogs.smms) return dialogs.smms;
const BASE_KEY = 'SmmsConfig';
const defaultData = { token: '', water: '', renamePattern: '', selectedTab: 'imgURL', uploadCount: 0, enableThumbnail: false, thumbnailSize: 128 };
const dialog = createBaseDialog('SMMS');
createEl('h3', { textAlign: 'center', margin: '0 0 10px 0' }, { innerText: 'SM.MS 图床' }, dialog);
let currentData = {};
let isScopedMode = false;
const onModeSwitch = (isScoped) => {
isScopedMode = isScoped;
currentData = loadConfig(BASE_KEY, isScoped, defaultData);
tokenInput.value = currentData.token || ''; waterInput.value = currentData.water || ''; renameInput.value = currentData.renamePattern || '';
};
const switchEl = createScopeSwitch(dialog, BASE_KEY, onModeSwitch);
const form = createEl('form', { display: 'grid', gap: '8px' }, { method: 'post' }, dialog);
const tokenInput = createInputRow(form, 'Token:', 'token');
const waterInput = createInputRow(form, '水印:', 'water');
const renameInput = createInputRow(form, '重命名:', 'rename');
createThumbnailControl(form, defaultData, () => {});
const fileSelector = createFileSelector(form, '文件');
dialog.handlePaste = fileSelector.handlePaste;
const upBtn = createEl('input', { ...commonStyles.btn, background: '#007bff', color: '#fff', justifySelf: 'end' }, { type: 'submit', value: '上传' }, form);
const { div: prog, bar } = createProgress(); dialog.appendChild(prog);
const { input: resInput, update: resUpdate } = setupResultArea(dialog, 'imgURL', t => { currentData.selectedTab = t; saveConfig(BASE_KEY, isScopedMode, currentData); });
onModeSwitch(switchEl.checked);
form.onsubmit = (e) => {
e.preventDefault();
currentData.token = tokenInput.value.trim(); currentData.water = waterInput.value.trim(); currentData.renamePattern = renameInput.value.trim();
saveConfig(BASE_KEY, isScopedMode, currentData);
const file = fileSelector.getFile();
if (!file) return alertRes(resInput, 'No File', 'red');
processImage(file, currentData, (blob) => {
prog.style.display = 'block'; const fd = new FormData();
fd.append('smfile', blob, superRename(file.name || 'image.png', currentData.renamePattern, Date.now()));
fd.append('format', 'json');
GM_xmlhttpRequest({ method: 'POST', url: 'https://sm.ms/api/v2/upload', headers: { 'Authorization': currentData.token }, data: fd, upload: { onprogress: e => bar.value = (e.loaded/e.total)*100 }, onload: r => {
prog.style.display = 'none'; try { const d = JSON.parse(r.responseText);
if(d.success) handleSuccess(resInput, resUpdate, d.data.url);
else if(d.code==='image_repeated') handleSuccess(resInput, resUpdate, d.images);
else alertRes(resInput, d.message, 'red'); } catch(e){ alertRes(resInput, 'Error', 'red'); }
}});
});
};
dialogs.smms = dialog; return dialog;
}
// --- ImgURL Dialog ---
function initImgUrlDialog() {
if (dialogs.imgurl) return dialogs.imgurl;
const BASE_KEY = 'ImgUrlConfig';
const defaultData = { uid: '', token: '', water: '', selectedTab: 'imgURL', albumList: [] };
const dialog = createBaseDialog('ImgURL');
createEl('h3', { textAlign: 'center', margin: '0 0 10px 0' }, { innerText: 'ImgURL 图床' }, dialog);
let currentData = {};
let isScopedMode = false;
const onModeSwitch = (isScoped) => {
isScopedMode = isScoped;
currentData = loadConfig(BASE_KEY, isScoped, defaultData);
uidInput.value = currentData.uid || ''; tokenInput.value = currentData.token || ''; waterInput.value = currentData.water || '';
loadAlbums();
};
const switchEl = createScopeSwitch(dialog, BASE_KEY, onModeSwitch);
const form = createEl('form', { display: 'grid', gap: '8px' }, { method: 'post' }, dialog);
const uidInput = createInputRow(form, 'UID:', 'uid');
const tokenInput = createInputRow(form, 'Token:', 'token');
const albumSelect = createEl('select', { width: '100%', padding: '5px', marginBottom: '5px' }, {}, form);
const loadAlbums = () => {
albumSelect.innerHTML = '<option value="default">默认相册</option>';
(currentData.albumList||[]).forEach(a => createEl('option', {}, { value: a.album_id, textContent: a.name }, albumSelect));
albumSelect.value = currentData.selectedAlbumId || 'default';
};
createEl('button', { ...commonStyles.btn, background: '#eee', fontSize: '12px', padding: '4px' }, { type: 'button', innerText: '刷新相册', onclick: () => {
const fd = new FormData(); fd.append('uid', uidInput.value); fd.append('token', tokenInput.value);
GM_xmlhttpRequest({ method: 'POST', url: 'https://www.imgurl.org/api/v2/albums', data: fd, onload: r => { try{ const d=JSON.parse(r.responseText); if(d.data){ currentData.albumList = d.data; saveConfig(BASE_KEY, isScopedMode, currentData); loadAlbums(); } }catch(e){} } });
}}, form);
const waterInput = createInputRow(form, '水印:', 'water');
createThumbnailControl(form, defaultData, () => {});
const fileSelector = createFileSelector(form, '文件');
dialog.handlePaste = fileSelector.handlePaste;
const upBtn = createEl('input', { ...commonStyles.btn, background: '#007bff', color: '#fff', justifySelf: 'end' }, { type: 'submit', value: '上传' }, form);
const { div: prog, bar } = createProgress(); dialog.appendChild(prog);
const { input: resInput, update: resUpdate } = setupResultArea(dialog, 'imgURL', t => { currentData.selectedTab = t; saveConfig(BASE_KEY, isScopedMode, currentData); });
onModeSwitch(switchEl.checked);
form.onsubmit = (e) => {
e.preventDefault();
currentData.uid = uidInput.value; currentData.token = tokenInput.value; currentData.water = waterInput.value;
currentData.selectedAlbumId = albumSelect.value; saveConfig(BASE_KEY, isScopedMode, currentData);
const file = fileSelector.getFile();
if (!file) return alertRes(resInput, 'No File', 'red');
processImage(file, currentData, (blob) => {
prog.style.display = 'block'; const fd = new FormData();
fd.append('file', blob, file.name || 'image.png'); fd.append('uid', currentData.uid); fd.append('token', currentData.token);
if(currentData.selectedAlbumId !== 'default') fd.append('album_id', currentData.selectedAlbumId);
GM_xmlhttpRequest({ method: 'POST', url: 'https://www.imgurl.org/api/v2/upload', data: fd, upload: { onprogress: e => bar.value = (e.loaded/e.total)*100 }, onload: r => {
prog.style.display = 'none'; try{ const d=JSON.parse(r.responseText); if(d.data?.url) handleSuccess(resInput, resUpdate, d.data.url); else alertRes(resInput, d.msg, 'red'); }catch(e){ alertRes(resInput, 'Error', 'red'); }
}});
});
};
dialogs.imgurl = dialog; return dialog;
}
// --- S3 Upload Core ---
function uploadToS3(blob, name, conf, cbs) {
let ep = conf.endpoint.startsWith('http') ? conf.endpoint : 'https://'+conf.endpoint; ep = ep.replace(/\/$/, '');
const host = new URL(ep).host; const key = (conf.folder.replace(/^\/|\/$/g, '') + '/' + name).replace(/^\//, '');
const url = `${ep}/${conf.bucket}/${key}`;
const now = dayjs(); const amzDate = now.utc().format('YYYYMMDD[T]HHmmss[Z]'); const dateStr = now.utc().format('YYYYMMDD');
const payload = 'UNSIGNED-PAYLOAD';
const canReq = `PUT\n/${conf.bucket}/${encodeURI(key)}\n\nhost:${host}\nx-amz-content-sha256:${payload}\nx-amz-date:${amzDate}\n\nhost;x-amz-content-sha256;x-amz-date\n${payload}`;
const scope = `${dateStr}/${conf.region||'us-east-1'}/s3/aws4_request`;
const signKey = (k, d, r, s) => {
const h = (d, k) => CryptoJS.HmacSHA256(d, k);
return h("aws4_request", h("s3", h(r, h(d, "AWS4" + k))));
};
const signature = CryptoJS.HmacSHA256(`AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${CryptoJS.SHA256(canReq).toString(CryptoJS.enc.Hex)}`, signKey(conf.secretAccessKey, dateStr, conf.region||'us-east-1', 's3')).toString(CryptoJS.enc.Hex);
const auth = `AWS4-HMAC-SHA256 Credential=${conf.accessKeyId}/${scope}, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=${signature}`;
GM_xmlhttpRequest({
method: 'PUT', url: url, headers: { 'Authorization': auth, 'x-amz-date': amzDate, 'x-amz-content-sha256': payload, 'Content-Type': blob.type },
data: blob, upload: { onprogress: e => cbs.onProgress((e.loaded/e.total)*100) },
onload: r => (r.status>=200&&r.status<300) ? cbs.onSuccess(conf.customDomain ? `${conf.customDomain}/${key}`.replace(/([^:]\/)\/+/g, "$1") : url) : cbs.onError('Err:'+r.status),
onerror: () => cbs.onError('Net Err')
});
}
dayjs.prototype.utc = function() { return this.add(new Date().getTimezoneOffset(), 'minute'); };
function alertRes(el, m, c) { el.value = m; el.style.color = c; }
function handleSuccess(el, upd, url) { el.dataset.url = url; upd(); el.style.color = 'green'; }
function processImage(f, c, cb) {
if(!c.water && !c.enableThumbnail) return cb(f);
const r = new FileReader(); r.onload = e => {
const i = new Image(); i.src = e.target.result;
i.onload = () => {
let w=i.width, h=i.height;
if(c.enableThumbnail) { const m = c.thumbnailSize||128; if(w>m||h>m){ const r=Math.min(m/w,m/h); w=Math.round(w*r); h=Math.round(h*r); } }
const cv = document.createElement('canvas'); cv.width=w; cv.height=h; const ctx=cv.getContext('2d'); ctx.drawImage(i,0,0,w,h);
if(c.water){
const fs = Math.max(12, w*0.05); ctx.font=`${fs}px Arial`; ctx.fillStyle='rgba(255,255,255,0.6)'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.shadowColor="rgba(0,0,0,0.8)"; ctx.shadowBlur=4; ctx.translate(w/2, h/2); ctx.rotate(-Math.PI/4); ctx.fillText(c.water,0,0);
}
cv.toBlob(cb, f.type, 0.9);
};
}; r.readAsDataURL(f);
}
function superRename(n, p, idx) {
if(!p) return n;
const ext = n.substring(n.lastIndexOf('.')); const base = n.substring(0, n.lastIndexOf('.')); const now = dayjs();
const rnd = (l) => { const c='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let r=''; for(let i=0;i<l;i++) r+=c.charAt(Math.floor(Math.random()*c.length)); return r; };
return p.replace(/{Y}/g,now.format('YYYY')).replace(/{m}/g,now.format('MM')).replace(/{d}/g,now.format('DD')).replace(/{h}/g,now.format('HH'))
.replace(/{i}/g,now.format('mm')).replace(/{s}/g,now.format('ss')).replace(/{ms}/g,now.format('SSS')).replace(/{timestamp}/g,now.valueOf())
.replace(/{md5}/g,CryptoJS.MD5(rnd(32)).toString()).replace(/{md5-16}/g,CryptoJS.MD5(rnd(16)).toString().substring(0,16))
.replace(/{uuid}/g,uuid.v4()).replace(/{filename}/g,base).replace(/{auto}/g,idx) + ext;
}
})();