Greasy Fork

Greasy Fork is available in English.

图床上传脚本

在右下角添加悬浮球,支持 S3/OSS/R2/SMMS/ImgURL 上传,支持剪贴板直接粘贴图片(Ctrl+V)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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: '&times;', 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: `![image](${url})`, 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;
    }
})();