Patreon Image Downloader

Download individual or all images on the current Patreon page

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name                Patreon 图片下载工具
// @name:zh-CN          Patreon 图片下载工具
// @name:zh-TW          Patreon 圖片下載工具
// @name:ja             Patreon 画像ダウンローダー
// @name:en             Patreon Image Downloader
// @name:ko             Patreon 이미지 다운로더
// @namespace           https://greasyfork.org/users/1271023
// @version             2.0.0
// @author              朧月猫
// @description         Patreon 网站单个、当前页面所有图片下载
// @description:zh-CN   Patreon 网站单个、当前页面所有图片下载
// @description:zh-TW   Patreon 網站單個、當前頁面所有圖片下載
// @description:ja      Patreonサイトの現在のページにある個々の画像またはすべての画像をダウンロードします
// @description:en      Download individual or all images on the current Patreon page
// @description:ko      Patreon 웹사이트의 현재 페이지에 있는 단일 또는 모든 이미지를 다운로드합니다
// @match               https://www.patreon.com/*
// @icon                https://www.patreon.com/favicon.ico
// @license             CC-BY-NC-SA-4.0
// @grant               GM_xmlhttpRequest
// @grant               GM_setValue
// @grant               GM_getValue
// @grant               GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 词典与探测逻辑
    // ==========================================
    const i18n = {
        'zh-CN': {
            noImages: "当前页面没有未下载的图片",
            toastStarting: "开始下载 {count} 个目标",
            dirUpdated: "下载目录已更新,后续图片将保存在: {dir}/patreon/[作者名]/",
            toastSuccess: "✅ Post: {pid} 全部下载成功!",
            toastPartial: "❌ Post: {pid} 下载完成,但有 {fail} 个失败",
            btnTitleBulk: "一键下载所有图片 (右键切换功能)",
            btnTitleBulkAlt: "一键下载所有图片 (右键切换'设置盘符')",
            btnTitleDir: "设置/更改保存盘符 (右键切换'全部下载')",
            dashTitle: "下载总览",
            dashClearTip: "清除成功记录",
            dashExpandTip: "展开面板",
            dashCollapseTip: "折叠面板",
            dashSettingsTip: "下载器设置",
            dashRetry: "重试",
            dashRetrying: "重试中",
            dashFailTip: "{fail} 张失败",
            bulkBtnIdle: "全部下载",
            bulkBtnLoading: "下载中...",
            bulkBtnSuccess: "已完成",

            settingTitle: "设置",
            settingToast: "底部提示框",
            settingJpeg: "JPEG 自动转 JPG",
            settingFormat: "名称自定义",
            settingDupAction: "重复文件",
            dupActionSaveAs: "另存为",
            dupActionSkip: "跳过",
            dupActionOverwrite: "覆盖",
            varCreator: "作者名称",
            varDate: "发布日期 (YYYYMMDD)",
            varPostId: "帖子 ID",
            varPostname: "帖子标题",
            varNum: "图片序号",
            varName: "原图片名 / Web / Preview",
            btnReset: "恢复默认",
            menuSettings: "⚙️ 下载设置",
            settingClearHistory: "清除记忆",
            btnCleared: " 已清空"
        },
        'zh-HK': {
            noImages: "目前頁面沒有未下載的圖片",
            toastStarting: "開始下載 {count} 個目標",
            dirUpdated: "下載目錄已更新,後續圖片將儲存在: {dir}/patreon/[作者名]/",
            toastSuccess: "✅ Post: {pid} 全部下載成功!",
            toastPartial: "❌ Post: {pid} 下載完成,但有 {fail} 個失敗",
            btnTitleBulk: "一鍵下載所有圖片 (右鍵切換功能)",
            btnTitleBulkAlt: "一鍵下載所有圖片 (右鍵切換'設定磁碟機')",
            btnTitleDir: "設定/變更儲存磁碟機 (右鍵切換'全部下載')",
            dashTitle: "下載總覽",
            dashClearTip: "清除成功紀錄",
            dashExpandTip: "展開面板",
            dashCollapseTip: "折疊面板",
            dashSettingsTip: "下載器設定",
            dashRetry: "重試",
            dashRetrying: "重試中",
            dashFailTip: "{fail} 張失敗",
            bulkBtnIdle: "全部下載",
            bulkBtnLoading: "下載中...",
            bulkBtnSuccess: "已完成",

            settingTitle: "設定",
            settingToast: "底部提示框",
            settingJpeg: "JPEG 自動轉 JPG",
            settingFormat: "名稱自訂",
            settingDupAction: "重複檔案",
            dupActionSaveAs: "另存為",
            dupActionSkip: "跳過",
            dupActionOverwrite: "覆蓋",
            varCreator: "作者名稱",
            varDate: "發布日期 (YYYYMMDD)",
            varPostId: "貼文 ID",
            varPostname: "貼文標題",
            varNum: "圖片序號",
            varName: "原圖片名 / Web / Preview",
            btnReset: "恢復預設",
            menuSettings: "⚙️ 下載設定",
            settingClearHistory: "清除記憶",
            btnCleared: " 已清空"
        },
        'ja-JP': {
            noImages: "現在のページに未ダウンロードの画像はありません",
            toastStarting: "{count} 個のターゲットのダウンロードを開始します",
            dirUpdated: "ダウンロード先が更新されました。今後の画像は {dir}/patreon/[クリエイター名]/ に保存されます",
            toastSuccess: "✅ Post: {pid} すべてダウンロード完了!",
            toastPartial: "❌ Post: {pid} ダウンロード完了しましたが、{fail} 個のエラーがあります",
            btnTitleBulk: "すべての画像をワンクリックダウンロード (右クリックで機能切り替え)",
            btnTitleBulkAlt: "すべての画像をワンクリックダウンロード (右クリックで「保存先設定」に切り替え)",
            btnTitleDir: "保存先の設定/変更 (右クリックで「すべてダウンロード」に切り替え)",
            dashTitle: "ダウンロード概要",
            dashClearTip: "成功履歴をクリア",
            dashExpandTip: "パネルを展開",
            dashCollapseTip: "パネルを折りたたむ",
            dashSettingsTip: "設定",
            dashRetry: "再試行",
            dashRetrying: "再試行中",
            dashFailTip: "{fail} 枚失敗",
            bulkBtnIdle: "すべてダウンロード",
            bulkBtnLoading: "ダウンロード中...",
            bulkBtnSuccess: "完了",

            settingTitle: "設定",
            settingToast: "下部のトースト通知",
            settingJpeg: "JPEGをJPGに自動変換",
            settingFormat: "名前のカスタマイズ",
            settingDupAction: "重複ファイル",
            dupActionSaveAs: "名前を付けて保存",
            dupActionSkip: "スキップ",
            dupActionOverwrite: "上書き",
            varCreator: "クリエイター名",
            varDate: "公開日 (YYYYMMDD)",
            varPostId: "ポスト ID",
            varPostname: "ポストのタイトル",
            varNum: "画像番号",
            varName: "元のファイル名 / Web / Preview",
            btnReset: "デフォルトに戻す",
            menuSettings: "⚙️ ダウンロード設定",
            settingClearHistory: "履歴クリア",
            btnCleared: " クリア済"
        },
        'en': {
            noImages: "No remaining images to download on the current page",
            toastStarting: "Starting download of {count} targets",
            dirUpdated: "Download directory updated. Future images will be saved in: {dir}/patreon/[Creator Name]/",
            toastSuccess: "✅ Post: {pid} all downloaded successfully!",
            toastPartial: "❌ Post: {pid} downloaded, but {fail} failed",
            btnTitleBulk: "One-click download all images (Right-click to toggle function)",
            btnTitleBulkAlt: "One-click download all images (Right-click to toggle 'Set Save Drive')",
            btnTitleDir: "Set/change save drive (Right-click to toggle 'Download All')",
            dashTitle: "Download Overview",
            dashClearTip: "Clear success records",
            dashExpandTip: "Expand panel",
            dashCollapseTip: "Collapse panel",
            dashSettingsTip: "Settings",
            dashRetry: "Retry",
            dashRetrying: "Retrying",
            dashFailTip: "{fail} failed",
            bulkBtnIdle: "Download All",
            bulkBtnLoading: "Downloading...",
            bulkBtnSuccess: "Completed",

            settingTitle: "Settings",
            settingToast: "Bottom Toast",
            settingJpeg: "Auto-convert JPEG to JPG",
            settingFormat: "Custom Naming",
            settingDupAction: "Duplicate File",
            dupActionSaveAs: "Save As",
            dupActionSkip: "Skip",
            dupActionOverwrite: "Overwrite",
            varCreator: "Creator Name",
            varDate: "Publish Date (YYYYMMDD)",
            varPostId: "Post ID",
            varPostname: "Post Title",
            varNum: "Image Index",
            varName: "Original Name / Web / Preview",
            btnReset: "Restore Defaults",
            menuSettings: "⚙️ Settings",
            settingClearHistory: "Clear History",
            btnCleared: " Cleared"
        },
        'ko-KR': {
            noImages: "현재 페이지에 아직 다운로드하지 않은 이미지가 없습니다",
            toastStarting: "{count}개의 목표 다운로드 시작 중",
            dirUpdated: "다운로드 경로가 업데이트되었습니다. 이후 이미지는 {dir}/patreon/[크리에이터 이름]/ 에 저장됩니다.",
            toastSuccess: "✅ Post: {pid} 모두 다운로드 완료!",
            toastPartial: "❌ Post: {pid} 다운로드 완료, 하지만 {fail}개 실패",
            btnTitleBulk: "모든 이미지 원클릭 다운로드 (우클릭하여 기능 전환)",
            btnTitleBulkAlt: "모든 이미지 원클릭 다운로드 (우클릭하여 '저장 드라이브 설정' 전환)",
            btnTitleDir: "저장 드라이브 설정/변경 (우클릭하여 '모두 다운로드' 전환)",
            dashTitle: "다운로드 개요",
            dashClearTip: "성공 기록 지우기",
            dashExpandTip: "패널 펼치기",
            dashCollapseTip: "패널 접기",
            dashSettingsTip: "설정",
            dashRetry: "재시도",
            dashRetrying: "재시도 중",
            dashFailTip: "{fail}개 실패",
            bulkBtnIdle: "모두 다운로드",
            bulkBtnLoading: "다운로드 중...",
            bulkBtnSuccess: "완료",

            settingTitle: "설정",
            settingToast: "하단 토스트 알림",
            settingJpeg: "JPEG를 JPG로 자동 변환",
            settingFormat: "이름 사용자 정의",
            settingDupAction: "중복 파일",
            dupActionSaveAs: "다른 이름으로 저장",
            dupActionSkip: "건너뛰기",
            dupActionOverwrite: "덮어쓰기",
            varCreator: "크리에이터 이름",
            varDate: "게시 날짜 (YYYYMMDD)",
            varPostId: "포스트 ID",
            varPostname: "게시물 제목",
            varNum: "이미지 번호",
            varName: "원본 파일명 / Web / Preview",
            btnReset: "기본값 복원",
            menuSettings: "⚙️ 다운로드 설정",
            settingClearHistory: "기록 지우기",
            btnCleared: " 비워짐"
        }
    };

    const userLang = document.documentElement.lang || navigator.language || navigator.userLanguage || 'en';
    let currentLang = 'en';
    if (userLang.startsWith('zh')) {
        currentLang = (userLang.includes('TW') || userLang.includes('HK') || userLang.includes('MO') || userLang.toLowerCase().includes('hant')) ? 'zh-HK' : 'zh-CN';
    } else if (userLang.startsWith('ja')) {
        currentLang = 'ja-JP';
    } else if (userLang.startsWith('ko')) {
        currentLang = 'ko-KR';
    }

    const t = (key, params = {}) => {
        let str = i18n[currentLang][key] || i18n['en'][key] || key;
        for (const [k, v] of Object.entries(params)) {
            str = str.replace(`{${k}}`, v);
        }
        return str;
    };

    const apiCache = new Map();

    // ==========================================
    // 视觉持久化缓存
    // ==========================================
    let ptrDownloadHistory = new Set();
    try {
        const hist = JSON.parse(GM_getValue('Patreon_FSA_History', '[]'));
        ptrDownloadHistory = new Set(hist);
    } catch(e) {}

    let historyTimer = null;
    const saveHistory = (fp) => {
        if(fp && !ptrDownloadHistory.has(fp)) {
            ptrDownloadHistory.add(fp);
            if(historyTimer) clearTimeout(historyTimer);
            historyTimer = setTimeout(() => {
                GM_setValue('Patreon_FSA_History', JSON.stringify(Array.from(ptrDownloadHistory)));
            }, 500);
        }
    };

    // ==========================================
    // 队列管理器
    // ==========================================
    window.ptrQueueManager = {
        process: async function(buttons, concurrency = 10) {
            const groups = [];
            buttons.forEach(btn => {
                const pid = btn.dataset.postid || 'Unknown';
                let group = groups.find(g => g.pid === pid);
                if (!group) {
                    group = { pid: pid, btns: [] };
                    groups.push(group);
                }
                group.btns.push(btn);
            });

            for (const group of groups) {
                await new Promise(resolve => {
                    let i = 0;
                    const timer = setInterval(() => {
                        let active = group.btns.filter(b => b.dataset.state === 'loading').length;
                        while (active < concurrency && i < group.btns.length) {
                            const btn = group.btns[i];
                            if (btn.dataset.state !== 'loading' && btn.dataset.state !== 'success') {
                                btn.click();
                                active++;
                            }
                            i++;
                        }
                        const doneCount = group.btns.filter(b => b.dataset.state === 'success' || b.dataset.state === 'error').length;
                        if (doneCount === group.btns.length) {
                            clearInterval(timer);
                            resolve();
                        }
                    }, 300);
                });
            }
        }
    };

    // ==========================================
    // 设置中心 (UI及持久化模块)
    // ==========================================
    window.PtrSettings = (function() {
        const DEFAULT_CONFIG = {
            showToast: true,
            convertJpeg: true,
            dupAction: 'saveAs',
            nameFormat: "Patreon_{creator}_{date}_{postid}_{num}_{name}"
        };

        function getConfig() {
            try {
                const conf = GM_getValue('Patreon_FSA_Config');
                return conf ? { ...DEFAULT_CONFIG, ...JSON.parse(conf) } : DEFAULT_CONFIG;
            } catch (e) {
                return DEFAULT_CONFIG;
            }
        }

        function saveConfig(conf) {
            GM_setValue('Patreon_FSA_Config', JSON.stringify(conf));
        }

        function open() {
            const existingPanel = document.getElementById('ptr-acrylic-setting-panel');
            if (existingPanel) existingPanel.remove();

            const currentConf = getConfig();

            const mockData = {
                creator: 'Artist',
                date: '20230318',
                postid: '80207727',
                postname: 'Post a post',
                num: '0',
                name: 'NAMELESS'
            };

            const uiTheme = {
                panelBg: "rgba(28, 28, 30, 0.85)", blur: "blur(30px)",
                panelBorder: "1px solid rgba(255,255,255,0.1)", shadow: "0 24px 50px rgba(0,0,0,0.6)",
                radius: "24px", boxRadius: "16px", innerRadius: "12px",
                textPrimary: "#fff", textSecondary: "rgba(255,255,255,0.55)", textMuted: "rgba(255,255,255,0.4)",
                accent: "#FF9FB5", toggleOff: "rgba(255,255,255,0.1)",
                boxBg: "rgba(255, 255, 255, 0.04)", boxBorder: "none", divider: "1px solid rgba(255,255,255,0.04)",
                segBg: "rgba(0,0,0,0.2)", segActiveBg: "#FF9FB5", segActiveText: "#2c2c2e", segText: "rgba(255,255,255,0.6)",
                inputBg: "rgba(0,0,0,0.4)", varBg: "rgba(255,255,255,0.06)", varWrapBg: "rgba(255,255,255,0.02)"
            };

            const DEFAULT_FORMAT = DEFAULT_CONFIG.nameFormat;

            const wrapper = document.createElement('div');
            wrapper.id = 'ptr-acrylic-setting-panel';
            wrapper.style.cssText = "position:fixed; inset:0; z-index:2147483647; background:rgba(0,0,0,0.4); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; font-family:-apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;";

            const panelContainer = document.createElement('div');
            panelContainer.style.cssText = `width: 450px; background: ${uiTheme.panelBg}; color: ${uiTheme.textPrimary}; border-radius: ${uiTheme.radius}; padding: 24px; box-shadow: ${uiTheme.shadow}; border: ${uiTheme.panelBorder}; box-sizing: border-box; backdrop-filter: ${uiTheme.blur}; -webkit-backdrop-filter: ${uiTheme.blur}; display:flex; flex-direction:column; opacity: 0; transform: translateY(10px); transition: 0.3s cubic-bezier(0.25, 1, 0.5, 1);`;

            panelContainer.innerHTML = `
                <div style="display:flex; align-items:center; justify-content:space-between; padding-bottom:12px; margin-bottom:16px; border-bottom:1px solid ${uiTheme.divider};">
                    <div style="font-weight:700; font-size:18px; display:flex; align-items:center; gap:6px;">
                        <svg viewBox="0 0 24 24" width="20" height="20" stroke="${uiTheme.accent}" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
                            <circle cx="12" cy="12" r="3"></circle>
                            <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
                        </svg>
                        ${t('settingTitle')}
                    </div>
                    <div style="display:flex; align-items:center; gap:8px;">
                        <div id="ptr-setting-btn-reset" style="cursor:pointer; color:${uiTheme.textPrimary}; font-size:12px; font-weight:600; display:flex; align-items:center; gap:4px; padding:4px 10px; background:rgba(255,255,255,0.1); border-radius:12px; transition:0.2s;">
                            <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><polyline points="3 3 3 8 8 8"></polyline></svg>
                            ${t('btnReset')}
                        </div>
                        <div id="ptr-setting-btn-close" style="width:26px; height:26px; border-radius:50%; background:rgba(255,255,255,0.1); display:flex; align-items:center; justify-content:center; cursor:pointer; color:${uiTheme.textPrimary}; font-size:12px; transition:0.2s;">✖</div>
                    </div>
                </div>

                <div style="position: relative; background: ${uiTheme.boxBg}; border-radius: ${uiTheme.boxRadius}; padding: 4px 16px; margin-bottom: 16px;">

                    <div id="ptr-btn-clear-history" style="position:absolute; top:12px; right:12px; display:flex; align-items:center; cursor:pointer; color:rgba(255,255,255,0.35); font-size:11px; font-weight:600; background:rgba(255,255,255,0.06); border-radius:12px; transition:width 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), background 0.2s, color 0.2s; width:24px; height:24px; overflow:hidden; white-space:nowrap; box-sizing:border-box; user-select:none; z-index:10;">
                        <span id="ptr-clear-icon" style="font-size:13px; width:24px; height:24px; line-height:24px; text-align:center; flex-shrink:0; display:block;">🗑️</span>
                        <span id="ptr-clear-text" style="opacity:0.9; padding-right:8px; height:24px; line-height:24px; width:54px; display:block; box-sizing:border-box;">${t('settingClearHistory')}</span>
                    </div>

                    <div style="display: flex; flex-direction: column; padding: 12px 0; border-bottom: 1px solid ${uiTheme.divider}; gap:10px;">
                        <div style="font-size: 13px; font-weight: 500; display:flex; align-items:center; gap:8px;">
                            <span style="font-size:15px; width:22px; text-align:center; opacity:0.9;">📁</span> ${t('settingDupAction')}
                        </div>
                        <div style="display: flex; background: ${uiTheme.segBg}; border-radius: ${uiTheme.innerRadius}; padding: 3px; position:relative;">
                            <div id="ptr-seg-active-bg" style="position:absolute; top:3px; left:3px; height:calc(100% - 6px); width:calc(33.33% - 2px); background:${uiTheme.segActiveBg}; border-radius:calc(${uiTheme.innerRadius} - 2px); transition:all 0.3s cubic-bezier(0.25, 1, 0.5, 1); box-shadow:0 2px 4px rgba(0,0,0,0.15);"></div>
                            <div class="ptr-seg-btn" data-val="saveAs" style="flex:1; text-align:center; padding: 6px 0; font-size:12px; font-weight: 600; cursor: pointer; position:relative; z-index:1; transition:color 0.3s;">${t('dupActionSaveAs')}</div>
                            <div class="ptr-seg-btn" data-val="skip" style="flex:1; text-align:center; padding: 6px 0; font-size:12px; font-weight: 600; cursor: pointer; position:relative; z-index:1; transition:color 0.3s;">${t('dupActionSkip')}</div>
                            <div class="ptr-seg-btn" data-val="overwrite" style="flex:1; text-align:center; padding: 6px 0; font-size:12px; font-weight: 600; cursor: pointer; position:relative; z-index:1; transition:color 0.3s;">${t('dupActionOverwrite')}</div>
                        </div>
                    </div>

                    <div class="ptr-tog-row" id="ptr-tog-toast" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid ${uiTheme.divider}; cursor: pointer; user-select: none;">
                        <div style="font-size: 13px; font-weight: 500; display:flex; align-items:center; gap:8px;">
                            <span style="font-size:15px; width:22px; text-align:center; opacity:0.9;">🔔</span> ${t('settingToast')}
                        </div>
                        <div style="position: relative; width: 40px; height: 22px;">
                            <input type="checkbox" id="ptr-input-toast" style="display:none;">
                            <div class="ptr-tog-bg" style="position:absolute; inset:0; border-radius:11px; transition:.3s;"></div>
                            <div class="ptr-tog-dot" style="position:absolute; top:2px; left:20px; width:18px; height:18px; background:#fff; border-radius:50%; box-shadow:0 2px 4px rgba(0,0,0,0.2); transition:.3s;"></div>
                        </div>
                    </div>

                    <div class="ptr-tog-row" id="ptr-tog-jpeg" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; cursor: pointer; user-select: none;">
                        <div style="font-size: 13px; font-weight: 500; display:flex; align-items:center; gap:8px;">
                            <span style="font-size:15px; width:22px; text-align:center; opacity:0.9;">🔄</span> ${t('settingJpeg')}
                        </div>
                        <div style="position: relative; width: 40px; height: 22px;">
                            <input type="checkbox" id="ptr-input-jpeg" style="display:none;">
                            <div class="ptr-tog-bg" style="position:absolute; inset:0; border-radius:11px; transition:.3s;"></div>
                            <div class="ptr-tog-dot" style="position:absolute; top:2px; left:20px; width:18px; height:18px; background:#fff; border-radius:50%; box-shadow:0 2px 4px rgba(0,0,0,0.2); transition:.3s;"></div>
                        </div>
                    </div>
                </div>

                <div style="background: ${uiTheme.boxBg}; border-radius: ${uiTheme.boxRadius}; padding: 14px 16px; margin-bottom: 12px;">
                    <div style="font-size: 13px; font-weight: 500; margin-bottom: 10px; display:flex; align-items:center; gap:8px;">
                        <span style="font-size:15px; width:22px; text-align:center; opacity:0.9;">📄</span> ${t('settingFormat')}
                    </div>

                    <input type="text" id="ptr-setting-input-format" spellcheck="false" style="width: 100%; box-sizing: border-box; padding: 12px 14px; background: ${uiTheme.inputBg}; border: none; color: ${uiTheme.textPrimary}; border-radius: ${uiTheme.innerRadius}; font-family: 'SF Mono', 'Consolas', monospace; font-size: 13px; font-weight: 500; outline: none; margin-bottom: 8px;">

                    <div style="display: flex; align-items: flex-start; gap: 6px; font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; color: ${uiTheme.accent};">
                        <svg style="margin-top:1px; flex-shrink:0;" viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round">
                            <polyline points="9 18 15 12 9 6"></polyline>
                        </svg>
                        <span id="ptr-setting-preview-text" style="word-break: break-all; opacity:0.95; line-height: 1.4;"></span>
                    </div>
                </div>

                <div style="padding: 0 8px; opacity: 0.85;">
                    <div id="ptr-setting-var-grid" style="display:grid; grid-template-columns: 1fr 1fr; grid-auto-rows: 1fr; gap:4px 8px;">
                    </div>
                </div>
            `;

            wrapper.appendChild(panelContainer);
            document.body.appendChild(wrapper);

            // =========================
            // 节点获取与数据回显绑定
            // =========================
            const toastInput = document.getElementById('ptr-input-toast');
            const jpegInput = document.getElementById('ptr-input-jpeg');
            const formatInput = document.getElementById('ptr-setting-input-format');
            const previewText = document.getElementById('ptr-setting-preview-text');
            const segBtns = panelContainer.querySelectorAll('.ptr-seg-btn');
            const activeBg = document.getElementById('ptr-seg-active-bg');

            // 1. 初始化赋值
            toastInput.checked = currentConf.showToast;
            jpegInput.checked = currentConf.convertJpeg;
            formatInput.value = currentConf.nameFormat;

            // 即时保存核心函数
            const triggerSave = () => {
                const activeSeg = Array.from(segBtns).find(b => b.classList.contains('active'));
                saveConfig({
                    showToast: toastInput.checked,
                    convertJpeg: jpegInput.checked,
                    dupAction: activeSeg ? activeSeg.dataset.val : 'saveAs',
                    nameFormat: formatInput.value.trim() || DEFAULT_FORMAT
                });
            };

            const updateToggleUI = (inputNode) => {
                const bg = inputNode.nextElementSibling;
                const dot = bg.nextElementSibling;
                bg.style.background = inputNode.checked ? uiTheme.accent : uiTheme.toggleOff;
                dot.style.left = inputNode.checked ? '20px' : '2px';
            };
            updateToggleUI(toastInput);
            updateToggleUI(jpegInput);

            const updateSegUI = (val) => {
                const idx = ['saveAs', 'skip', 'overwrite'].indexOf(val) !== -1 ? ['saveAs', 'skip', 'overwrite'].indexOf(val) : 0;
                segBtns.forEach((b, i) => {
                    b.style.color = i === idx ? uiTheme.segActiveText : uiTheme.segText;
                    b.classList.toggle('active', i === idx);
                });
                activeBg.style.transform = `translateX(${idx * 100}%)`;
            };
            updateSegUI(currentConf.dupAction);

            const updatePreview = () => {
                let text = formatInput.value;
                text = text.replace(/{creator}/g, mockData.creator)
                    .replace(/{date}/g, mockData.date)
                    .replace(/{postid}/g, mockData.postid)
                    .replace(/{postname}/g, mockData.postname)
                    .replace(/{num}/g, mockData.num)
                    .replace(/{name}/g, mockData.name);
                previewText.innerText = text + (jpegInput.checked ? '.jpg' : '.jpeg');
            };
            updatePreview();

            // 2. 交互事件绑定
            document.getElementById('ptr-tog-toast').addEventListener('click', () => {
                toastInput.checked = !toastInput.checked;
                updateToggleUI(toastInput);
                triggerSave();
            });

            // [新增] 清除记忆胶囊按钮交互 (放在 Toast 事件的外面,和它并列)
            const clearBtn = document.getElementById('ptr-btn-clear-history');
            const clearIcon = document.getElementById('ptr-clear-icon');
            const clearText = document.getElementById('ptr-clear-text');
            if (clearBtn) {
                // 悬停展开
                clearBtn.onmouseover = () => {
                    clearBtn.style.width = "78px";
                    clearBtn.style.background = "rgba(255,66,77,0.15)";
                    clearBtn.style.color = "#FF424D";
                };
                // 移出收缩
                clearBtn.onmouseout = () => {
                    clearBtn.style.width = "24px";
                    clearBtn.style.background = "rgba(255,255,255,0.06)";
                    clearBtn.style.color = "rgba(255,255,255,0.35)";
                };
                // 点击清理与动画
                clearBtn.onclick = () => {
                    // 1. 核心清理逻辑:清空内存Set与本地存储
                    if (typeof ptrDownloadHistory !== 'undefined') ptrDownloadHistory.clear();
                    GM_setValue('Patreon_FSA_History', '[]');

                    // 2. 触发文本切换
                    clearIcon.innerText = '💨';
                    clearText.innerText = t('btnCleared'); // "已清空"
                    clearBtn.style.color = "#4cd964";

                    // 3. 延时恢复原始状态
                    setTimeout(() => {
                        clearIcon.innerText = '🗑️';
                        clearText.innerText = t('settingClearHistory');
                        // 防御性恢复颜色:判断当前是否仍被悬停
                        if (clearBtn.style.width === "24px") {
                            clearBtn.style.color = "rgba(255,255,255,0.35)";
                        } else {
                            clearBtn.style.color = "#FF424D";
                        }
                    }, 1500);
                };
            }

            document.getElementById('ptr-tog-jpeg').addEventListener('click', () => {
                jpegInput.checked = !jpegInput.checked;
                updateToggleUI(jpegInput);
                updatePreview();
                triggerSave();
            });

            formatInput.addEventListener('input', () => {
                updatePreview();
                triggerSave();
            });

            segBtns.forEach(btn => {
                btn.addEventListener('click', () => {
                    updateSegUI(btn.dataset.val);
                    triggerSave();
                });
            });

            const resetBtn = document.getElementById('ptr-setting-btn-reset');
            resetBtn.addEventListener('click', () => {
                formatInput.value = DEFAULT_FORMAT;
                toastInput.checked = true;
                jpegInput.checked = true;
                updateToggleUI(toastInput);
                updateToggleUI(jpegInput);
                updateSegUI('saveAs');
                updatePreview();
                triggerSave();
                resetBtn.style.opacity = '0.3';
                setTimeout(() => resetBtn.style.opacity = '1', 200);
            });
            resetBtn.onmouseover = function() { this.style.background = "rgba(255,255,255,0.15)"; };
            resetBtn.onmouseout = function() { this.style.background = "rgba(255,255,255,0.1)"; };

            // 3. 关闭逻辑
            const closePanel = () => {
                panelContainer.style.opacity = "0";
                panelContainer.style.transform = "translateY(10px)";
                setTimeout(() => wrapper.remove(), 300);
            };

            const closeBtn = document.getElementById('ptr-setting-btn-close');
            closeBtn.onclick = closePanel;
            closeBtn.onmouseover = function() { this.style.background = 'rgba(255,255,255,0.2)'; };
            closeBtn.onmouseout = function() { this.style.background = 'rgba(255,255,255,0.1)'; };

            wrapper.addEventListener('mousedown', (e) => {
                if (e.target === wrapper) closePanel();
            });

            const varsData = [
                { id: '{creator}', label: t('varCreator') },
                { id: '{date}', label: t('varDate') },
                { id: '{postid}', label: t('varPostId') },
                { id: '{postname}', label: t('varPostname') },
                { id: '{num}', label: t('varNum') },
                { id: '{name}', label: t('varName') }
            ];

            const varGrid = document.getElementById('ptr-setting-var-grid');
            varsData.forEach(v => {
                const item = document.createElement('div');
                item.style.cssText = `height:100%; display:flex; align-items:center; gap:6px; cursor:pointer; padding:4px 6px; border-radius:6px; transition:all 0.2s; user-select:none; box-sizing:border-box;`;

                item.innerHTML = `
                    <span class="var-tag" style="color:${uiTheme.textPrimary}; font-family:monospace; background:rgba(255,255,255,0.05); padding:2px 0; width:64px; text-align:center; border-radius:4px; flex-shrink:0; font-size:10.5px; transition:all 0.2s;">${v.id}</span>
                    <span class="var-label" style="color:rgba(255,255,255,0.45); font-size:11px; line-height:1.3; word-break:break-word; transition:0.2s;">${v.label}</span>
                `;

                item.onmouseover = () => {
                    item.style.background = 'rgba(255,255,255,0.04)';
                    item.querySelector('.var-label').style.color = 'rgba(255,255,255,0.7)';
                };
                item.onmouseout = () => {
                    item.style.background = 'transparent';
                    item.querySelector('.var-label').style.color = 'rgba(255,255,255,0.45)';
                };

                item.onclick = () => {
                    navigator.clipboard.writeText(v.id);
                    const tag = item.querySelector('.var-tag');
                    tag.style.background = uiTheme.accent;
                    tag.style.color = '#000';
                    setTimeout(() => {
                        tag.style.background = 'rgba(255,255,255,0.05)';
                        tag.style.color = uiTheme.textPrimary;
                    }, 400);
                };
                varGrid.appendChild(item);
            });

            setTimeout(() => {
                panelContainer.style.opacity = "1";
                panelContainer.style.transform = "translateY(0)";
            }, 10);
        }

        return { getConfig, saveConfig, open };
    })();

    GM_registerMenuCommand(t('menuSettings'), window.PtrSettings.open);

    // ==========================================
    // FSA API 与记忆持久化逻辑
    // ==========================================
    let currentRootHandle = null;
    const dbName = 'PatreonFSA_DB';
    const storeName = 'dirHandles';

    async function initDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(dbName, 1);
            request.onupgradeneeded = (e) => { e.target.result.createObjectStore(storeName); };
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async function saveHandle(handle) {
        const db = await initDB();
        return new Promise((resolve) => {
            const tx = db.transaction(storeName, 'readwrite');
            tx.objectStore(storeName).put(handle, 'root');
            tx.oncomplete = resolve;
        });
    }

    async function getHandle() {
        const db = await initDB();
        return new Promise((resolve) => {
            const tx = db.transaction(storeName, 'readonly');
            const req = tx.objectStore(storeName).get('root');
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => resolve(null);
        });
    }

    async function verifyPermission(fileHandle, readWrite) {
        const options = { mode: readWrite ? 'readwrite' : 'read' };
        if ((await fileHandle.queryPermission(options)) === 'granted') return true;
        if ((await fileHandle.requestPermission(options)) === 'granted') return true;
        return false;
    }

    async function ensureRootHandle() {
        if (currentRootHandle) return true;
        let stored = await getHandle();
        if (stored) {
            if (await verifyPermission(stored, true)) {
                currentRootHandle = stored;
                return true;
            }
        }
        try {
            currentRootHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
            await saveHandle(currentRootHandle);
            return true;
        } catch (e) {
            console.warn("未选取保存目录", e);
            return false;
        }
    }

    async function fetchBlobAndType(url) {
        try {
            const res = await fetch(url);
            if (!res.ok) throw new Error("Fetch failed: " + res.status);
            const blob = await res.blob();
            const type = res.headers.get('content-type');
            return { blob, type };
        } catch (e) {
            console.warn("原生 fetch 失败,尝试降级使用 GM_xmlhttpRequest", e);
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'blob',
                    onload: (res) => {
                        if (res.status === 200) {
                            const match = res.responseHeaders.match(/content-type:\s*(.*)/i);
                            const type = match ? match[1].trim() : '';
                            resolve({ blob: res.response, type });
                        } else {
                            reject(new Error("HTTP " + res.status));
                        }
                    },
                    onerror: reject,
                    ontimeout: reject
                });
            });
        }
    }

    window.ToastManager = (function() {
        return {
            show: function(msg, isError = false) {
                if (!window.PtrSettings.getConfig().showToast) return;

                const toast = document.createElement('div');
                toast.innerText = msg;
                toast.style.cssText = `position:fixed; bottom:-60px; left:50%; transform:translateX(-50%); background:${isError ? '#FF424D' : '#4CAF50'}; color:#fff; padding:12px 24px; border-radius:30px; font-size:15px; font-weight:bold; box-shadow:0 4px 16px rgba(0,0,0,0.25); z-index:2147483640; transition:bottom 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); pointer-events:none;`;
                document.body.appendChild(toast);
                requestAnimationFrame(() => toast.style.bottom = '40px');
                setTimeout(() => {
                    toast.style.bottom = '-60px';
                    setTimeout(() => toast.remove(), 400);
                }, 3500);
            }
        };
    })();

    let customTooltip = document.getElementById('ptr-custom-tooltip');
    if (!customTooltip) {
        customTooltip = document.createElement('div');
        customTooltip.id = 'ptr-custom-tooltip';
        customTooltip.style.cssText = "position:fixed; background:rgba(40,40,40,0.6); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); border:1px solid rgba(255,255,255,0.15); color:#ff6b6b; padding:6px 12px; border-radius:8px; font-size:12px; pointer-events:none; z-index:2147483647; opacity:0; visibility:hidden; transition:opacity 0.2s ease, transform 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28); transform:translateY(4px); white-space:nowrap; box-shadow:0 8px 24px rgba(0,0,0,0.2); font-weight:500;";
        document.body.appendChild(customTooltip);
    }

    window.ptrShowTip = function(el, msg) {
        customTooltip.textContent = msg;
        customTooltip.style.visibility = 'visible';
        customTooltip.style.opacity = '1';
        customTooltip.style.transform = 'translateY(0)';
        const rect = el.getBoundingClientRect();
        customTooltip.style.left = (rect.left + rect.width / 2 - customTooltip.offsetWidth / 2) + 'px';
        customTooltip.style.top = (rect.top - customTooltip.offsetHeight - 8) + 'px';
    };

    window.ptrHideTip = function() {
        customTooltip.style.opacity = '0';
        customTooltip.style.transform = 'translateY(4px)';
        setTimeout(() => { if (customTooltip.style.opacity === '0') customTooltip.style.visibility = 'hidden'; }, 200);
    };

    window.DownloadDashboard = (function() {
        let container = null;
        let trackedPosts = new Map();
        let isMinimized = false;

        function init() {
            if(document.getElementById('ptr-download-dashboard')) return;
            container = document.createElement('div');
            container.id = 'ptr-download-dashboard';
            container.style.cssText = "position:fixed; top:20px; left:50%; transform:translateX(-50%); width:320px; background:rgba(25,25,25,0.65); color:#fff; border-radius:16px; border:1px solid rgba(255,255,255,0.08); font-size:13px; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; z-index:2147483630; display:none; flex-direction:column; backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px); box-shadow:0 12px 32px rgba(0,0,0,0.3); padding:14px 16px; overflow:hidden;";

            container.addEventListener('click', (e) => {
                if (e.target.closest('.ptr-dash-btn-min')) {
                    isMinimized = !isMinimized;
                    render();
                }
                else if (e.target.closest('.ptr-dash-btn-clear')) {
                    for (let [pid, btnSet] of trackedPosts.entries()) {
                        let doneCount = 0; let failCount = 0;
                        btnSet.forEach(b => {
                            if (b.dataset.state === 'success') doneCount++;
                            if (b.dataset.state === 'error') { doneCount++; failCount++; }
                        });
                        if (doneCount === btnSet.size && failCount === 0) {
                            trackedPosts.delete(pid);
                        }
                    }
                    render();
                }
                else if (e.target.closest('.ptr-dash-btn-settings')) {
                    window.PtrSettings.open();
                }
                else if (e.target.closest('.ptr-dash-btn-retry')) {
                    const retryBtn = e.target.closest('.ptr-dash-btn-retry');
                    const pid = retryBtn.dataset.pid;
                    const btnSet = trackedPosts.get(pid);

                    if (btnSet) {
                        retryBtn.innerText = t('dashRetrying');
                        retryBtn.style.opacity = '0.5';
                        retryBtn.style.pointerEvents = 'none';

                        const errBtns = Array.from(btnSet).filter(b => b.dataset.state === 'error');
                        window.ptrQueueManager.process(errBtns, 5);
                    }
                }
            });

            document.body.appendChild(container);
        }

        function render() {
            if (!container) init();
            if (trackedPosts.size === 0) { container.style.display = 'none'; return; }

            let currentScrollTop = 0;
            const scrollList = document.getElementById('ptr-dash-list');
            if (scrollList) {
                currentScrollTop = scrollList.scrollTop;
            }

            container.style.display = 'flex';

            if (isMinimized) {
                container.classList.add('ptr-dash-minimized');
            } else {
                container.classList.remove('ptr-dash-minimized');
            }

            let html = `<div style="font-weight:600; font-size:15px; padding-bottom:${isMinimized ? '0' : '14px'}; border-bottom:${isMinimized ? 'none' : '1px solid rgba(255,255,255,0.08)'}; display:flex; justify-content:space-between; align-items:center;">
                <div style="display:flex; align-items:center;">
                    <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px; opacity:0.85;">
                        <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
            <polyline points="17 21 17 13 7 13 7 21"></polyline>
            <polyline points="7 3 7 8 15 8"></polyline>
            </svg>
            <span style="letter-spacing:0.5px;">${t('dashTitle')}</span>
            </div>
            <div style="display:flex; gap:16px; align-items:center;">
                <span class="ptr-dash-icon ptr-dash-btn-settings" style="cursor:pointer; color:rgba(255,255,255,0.5); transition:all 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,0.5)'" title="${t('dashSettingsTip') || '设置'}">
                    <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
                        <circle cx="12" cy="12" r="3"></circle>
            <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
            </svg>
            </span>
            <span class="ptr-dash-icon ptr-dash-btn-clear" style="cursor:pointer; color:rgba(255,255,255,0.5); transition:all 0.2s;" onmouseover="this.style.color='#ff6b6b'" onmouseout="this.style.color='rgba(255,255,255,0.5)'" title="${t('dashClearTip')}">
                <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
                    <polyline points="3 6 5 6 21 6"></polyline>
            <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2-2v2"></path>
            <line x1="10" y1="11" x2="10" y2="17"></line>
            <line x1="14" y1="11" x2="14" y2="17"></line>
            </svg>
            </span>
            <span class="ptr-dash-icon ptr-dash-btn-min" style="cursor:pointer; color:rgba(255,255,255,0.5); transition:all 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,0.5)'" title="${isMinimized ? t('dashExpandTip') : t('dashCollapseTip')}">
                <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
                    <line x1="5" y1="12" x2="19" y2="12"></line>
            ${isMinimized ? '<line x1="12" y1="5" x2="12" y2="19"></line>' : ''}
            </svg>
            </span>
            </div>
            </div>`;

            if (!isMinimized) {
                html += `<div id="ptr-dash-list" style="display:flex; flex-direction:column; gap:8px; margin-top:10px; max-height:300px; overflow-y:auto; padding-right:4px;">`;

                trackedPosts.forEach((btnSet, postId) => {
                    const total = btnSet.size;
                    let success = 0; let fail = 0;

                    btnSet.forEach(btn => {
                        if(btn.dataset.state === 'success') success++;
                        if(btn.dataset.state === 'error') fail++;
                    });

                    const done = success + fail;
                    const isDone = (done === total);

                    let statusStr = `<span style="color:rgba(255,255,255,0.6); font-weight:500;">${success}/${total}</span>`;
                    let actionBtn = '';

                    if (isDone) {
                        if (fail > 0) {
                            statusStr = `<span onmouseenter="ptrShowTip(this, '${t('dashFailTip', {fail: fail})}')" onmouseleave="ptrHideTip()" style="color:#ff6b6b; cursor:help; font-weight:600;">❌ ${success}/${total}</span>`;
                            actionBtn = `<button class="ptr-dash-btn-retry" data-pid="${postId}" style="margin-left:12px; padding:4px 10px; font-size:12px; border-radius:6px; border:1px solid rgba(255,107,107,0.3); background:rgba(255,107,107,0.15); color:#ff6b6b; cursor:pointer; font-weight:600; flex-shrink:0; transition:all 0.2s;" onmouseover="this.style.background='rgba(255,107,107,0.25)'" onmouseout="this.style.background='rgba(255,107,107,0.15)'">${t('dashRetry')}</button>`;
                        } else {
                            statusStr = `<span style="color:#4cd964; font-weight:600;">✅ ${success}/${total}</span>`;
                        }
                    }

                    let linkStr = postId !== 'External'
                    ? `<a href="https://www.patreon.com/posts/${postId}" target="_blank" style="color:#fff; text-decoration:none; border-bottom:1px solid rgba(255,255,255,0.3); padding-bottom:1px; transition:border-color 0.2s;" onmouseover="this.style.borderColor='#fff'" onmouseout="this.style.borderColor='rgba(255,255,255,0.3)'">${postId}</a>`
                    : `External`;

                    // 【最小修改 1:为文字元素打上专有 ID 标签】
                    html += `<div style="display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.06); padding:10px 12px; border-radius:10px; transition:background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.09)'" onmouseout="this.style.background='rgba(255,255,255,0.06)'">
                        <div id="ptr-dash-title-${postId}" style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-right:8px; opacity:${isDone?0.6:1}; font-weight:500;">Post: ${linkStr}</div>
                        <div id="ptr-dash-status-${postId}" style="display:flex; align-items:center; font-variant-numeric:tabular-nums; flex-shrink:0;">${statusStr}${actionBtn}</div>
                    </div>`;
                });
                html += `</div>`;
            }
            container.innerHTML = html;

            if (!isMinimized) {
                const newScrollList = document.getElementById('ptr-dash-list');
                if (newScrollList) {
                    newScrollList.scrollTop = currentScrollTop;
                }
            }
        }

        return {
            init,
            addTaskButtons: function(buttonsArray) {
                buttonsArray.forEach(btn => {
                    const pid = btn.dataset.postid || 'Unknown';
                    if (!trackedPosts.has(pid)) {
                        const newSet = new Set();
                        newSet._hasNotified = false;
                        trackedPosts.set(pid, newSet);
                    }
                    trackedPosts.get(pid).add(btn);
                });
                render();
            },
            checkCompletion: function(pid, silent = false) {
                const btnSet = trackedPosts.get(pid);
                if (!btnSet) return;

                let done = 0; let fail = 0;
                btnSet.forEach(b => {
                    if (b.dataset.state === 'success') done++;
                    if (b.dataset.state === 'error') { done++; fail++; }
                });

                const total = btnSet.size;
                const isDone = (done === total);

                if (isDone) {
                    if (!btnSet._hasNotified) {
                        btnSet._hasNotified = true;
                        if (!silent) {
                            if (fail === 0) window.ToastManager.show(t('toastSuccess', {pid: pid}));
                            else window.ToastManager.show(t('toastPartial', {pid: pid, fail: fail}), true);
                        }
                    }
                }

                // 【最小修改 2:靶向更新进度,绝不摧毁滚动容器】
                const statusBox = document.getElementById(`ptr-dash-status-${pid}`);
                const titleBox = document.getElementById(`ptr-dash-title-${pid}`);

                if (statusBox && titleBox) {
                    let statusStr = `<span style="color:rgba(255,255,255,0.6); font-weight:500;">${done}/${total}</span>`;
                    let actionBtn = '';

                    if (isDone) {
                        titleBox.style.opacity = "0.6"; // 变暗
                        if (fail > 0) {
                            statusStr = `<span onmouseenter="ptrShowTip(this, '${t('dashFailTip', {fail: fail})}')" onmouseleave="ptrHideTip()" style="color:#ff6b6b; cursor:help; font-weight:600;">❌ ${done}/${total}</span>`;
                            actionBtn = `<button class="ptr-dash-btn-retry" data-pid="${pid}" style="margin-left:12px; padding:4px 10px; font-size:12px; border-radius:6px; border:1px solid rgba(255,107,107,0.3); background:rgba(255,107,107,0.15); color:#ff6b6b; cursor:pointer; font-weight:600; flex-shrink:0; transition:all 0.2s;" onmouseover="this.style.background='rgba(255,107,107,0.25)'" onmouseout="this.style.background='rgba(255,107,107,0.15)'">${t('dashRetry')}</button>`;
                        } else {
                            statusStr = `<span style="color:#4cd964; font-weight:600;">✅ ${done}/${total}</span>`;
                        }
                    }
                    statusBox.innerHTML = statusStr + actionBtn;
                } else {
                    render(); // 保底策略:如果DOM中找不到这一行,再执行完整重建
                }
            }
        };
    })();

    function extractPostId(img) {
        const src = img.src;
        const match = src.match(/\/post\/(\d+)\//);
        if (match) return match[1];

        const postWrapper = img.closest('[data-tag="post-card"]') || img.closest('.ContentCard-module__4Ygkza__postWrapper');
        if (postWrapper) {
            const postLink = postWrapper.querySelector('a[data-tag="post-published-at"]');
            if (postLink && postLink.href) {
                const m = postLink.href.match(/-(\d+)(?:\?|#|$)/) || postLink.href.match(/\/(\d+)(?:\?|#|$)/);
                if (m) return m[1];
            }
        }

        const canonical = document.querySelector('link[rel="canonical"]')?.href || window.location.href;
        const m = canonical.match(/-(\d+)(?:\?|#|$)/) || canonical.match(/\/(\d+)(?:\?|#|$)/);
        if (m) return m[1];

        return "External";
    }

    const FloatingConfigManager = (function() {
        let isDragging = false;
        let hasDragged = false;
        let isProcessingBulk = false;
        let currentBtnState = 0;

        return {
            init: function() {
                if (document.getElementById('ptr-fsa-config')) return;

                const fsaBtn = document.createElement('div');
                fsaBtn.id = 'ptr-fsa-config';
                fsaBtn.innerHTML = `💾`;
                fsaBtn.title = t('btnTitleBulk');

                fsaBtn.style.cssText = "position:fixed; z-index:2147483647; font-size:24px; cursor:grab; background:rgba(255,255,255,0.9); border-radius:50%; padding:10px; box-shadow:0 2px 10px rgba(0,0,0,0.2); transition:transform 0.2s; user-select:none; touch-action:none;";

                const savedPos = localStorage.getItem('Patreon_FSA_Btn_Pos');
                if (savedPos) {
                    try {
                        const pos = JSON.parse(savedPos);
                        fsaBtn.style.left = pos.left;
                        fsaBtn.style.top = pos.top;
                    } catch(e) {
                        fsaBtn.style.bottom = '20px';
                        fsaBtn.style.left = '20px';
                    }
                } else {
                    fsaBtn.style.bottom = '20px';
                    fsaBtn.style.left = '20px';
                }

                fsaBtn.addEventListener('mouseover', () => { if (!isDragging) fsaBtn.style.transform = "scale(1.1)"; });
                fsaBtn.addEventListener('mouseout', () => { if (!isDragging) fsaBtn.style.transform = "scale(1)"; });

                let startX, startY, initialX, initialY;
                fsaBtn.addEventListener('mousedown', (e) => {
                    if (e.button !== 0) return;
                    isDragging = false;
                    hasDragged = false;
                    startX = e.clientX;
                    startY = e.clientY;

                    const rect = fsaBtn.getBoundingClientRect();
                    initialX = rect.left;
                    initialY = rect.top;

                    const onMouseMove = (moveEvent) => {
                        const dx = moveEvent.clientX - startX;
                        const dy = moveEvent.clientY - startY;

                        if (!isDragging && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
                            isDragging = true;
                            hasDragged = true;
                            fsaBtn.style.left = initialX + 'px';
                            fsaBtn.style.top = initialY + 'px';
                            fsaBtn.style.bottom = 'auto';
                            fsaBtn.style.cursor = 'grabbing';
                            fsaBtn.style.transition = 'none';
                        }

                        if (isDragging) {
                            let targetX = initialX + dx;
                            let targetY = initialY + dy;
                            const btnWidth = fsaBtn.offsetWidth;
                            const btnHeight = fsaBtn.offsetHeight;
                            const maxX = window.innerWidth - btnWidth;
                            const maxY = window.innerHeight - btnHeight;

                            targetX = Math.max(0, Math.min(targetX, maxX));
                            targetY = Math.max(0, Math.min(targetY, maxY));

                            fsaBtn.style.left = `${targetX}px`;
                            fsaBtn.style.top = `${targetY}px`;
                        }
                    };

                    const onMouseUp = () => {
                        document.removeEventListener('mousemove', onMouseMove);
                        document.removeEventListener('mouseup', onMouseUp);

                        if (hasDragged) {
                            fsaBtn.style.cursor = 'grab';
                            fsaBtn.style.transition = 'transform 0.2s';
                            localStorage.setItem('Patreon_FSA_Btn_Pos', JSON.stringify({ left: fsaBtn.style.left, top: fsaBtn.style.top }));
                            isDragging = false;
                            setTimeout(() => { hasDragged = false; }, 100);
                        }
                    };

                    document.addEventListener('mousemove', onMouseMove);
                    document.addEventListener('mouseup', onMouseUp);
                });

                fsaBtn.addEventListener('click', async (e) => {
                    if (hasDragged) { e.preventDefault(); return; }
                    if (isProcessingBulk) return;

                    if (currentBtnState === 0) {
                        if (!(await ensureRootHandle())) return;

                        const rawSingles = Array.from(document.querySelectorAll('.ptr-download-btn:not([data-state="success"]):not([data-state="loading"])'));

                        const uniqueMap = new Map();
                        rawSingles.forEach(btn => {
                            const fp = btn.dataset.fingerprint;
                            if (fp && !uniqueMap.has(fp)) uniqueMap.set(fp, btn);
                            else updateUI(btn, 'success', true);
                        });

                        const singles = Array.from(uniqueMap.values());

                        if (singles.length === 0) {
                            window.ToastManager.show(t('noImages'), true);
                            return;
                        }

                        isProcessingBulk = true;
                        window.DownloadDashboard.addTaskButtons(singles);
                        window.ToastManager.show(t('toastStarting', { count: singles.length }));
                        await window.ptrQueueManager.process(singles);
                        isProcessingBulk = false;
                    } else if (currentBtnState === 1) {
                        try {
                            currentRootHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
                            await saveHandle(currentRootHandle);
                            window.ToastManager.show(t('dirUpdated', { dir: currentRootHandle.name }));
                        } catch (err) { console.warn("已取消更改目录"); }
                    }
                });

                fsaBtn.addEventListener('contextmenu', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    if (isDragging) return;

                    currentBtnState = currentBtnState === 0 ? 1 : 0;
                    if (currentBtnState === 0) {
                        fsaBtn.innerHTML = `💾`;
                        fsaBtn.title = t('btnTitleBulkAlt');
                    } else {
                        fsaBtn.innerHTML = `📁`;
                        fsaBtn.title = t('btnTitleDir');
                    }
                });

                document.body.appendChild(fsaBtn);

                if (fsaBtn.style.bottom !== '20px') {
                    requestAnimationFrame(() => {
                        const rect = fsaBtn.getBoundingClientRect();
                        const maxX = window.innerWidth - rect.width;
                        const maxY = window.innerHeight - rect.height;
                        let safeX = Math.max(0, Math.min(rect.left, maxX));
                        let safeY = Math.max(0, Math.min(rect.top, maxY));
                        if (safeX !== rect.left || safeY !== rect.top) {
                            fsaBtn.style.left = safeX + 'px';
                            fsaBtn.style.top = safeY + 'px';
                            localStorage.setItem('Patreon_FSA_Btn_Pos', JSON.stringify({ left: safeX + 'px', top: safeY + 'px' }));
                        }
                    });
                }
            }
        };
    })();

    const injectConfigBtn = () => FloatingConfigManager.init();

    const startDownload = async (img, btn, isManualClick = true) => {
        try {
            const src = img.src;
            let isPreview = false;
            let isPatreonNative = src.includes('patreonusercontent.com');

            const inContainer = img.closest('div[data-image-container="true"]');
            if (inContainer) {
                if (src.includes('c10.patreonusercontent.com')) { isPreview = true; } else { isPatreonNative = false; }
            }

            const match = src.match(/\/post\/(\d+)\/([a-f0-9]{32})/);
            let postId = null; let uuid = null;
            const postWrapper = img.closest('[data-tag="post-card"]') || img.closest('.ContentCard-module__4Ygkza__postWrapper') || document.body;

            // ==========================================
            // 🎁 {postname} 帖子标题提取逻辑
            // ==========================================
            let postTitleRaw = "Untitled";
            const titleEl = postWrapper?.querySelector('span[data-tag="post-title"]') || postWrapper?.querySelector('[data-tag="post-title"]') || document.querySelector('[data-tag="post-title"]');
            if (titleEl) {
                postTitleRaw = titleEl.textContent.trim() || titleEl.innerText.trim();
            }
            const safePostTitle = postTitleRaw.replace(/[\\/:*?"<>|]/g, "_");

            if (match) { postId = match[1]; uuid = match[2]; } else {
                const postLink = postWrapper?.querySelector('a[data-tag="post-published-at"]') || document.querySelector('a[data-tag="post-published-at"]');
                if (postLink && postLink.href) { const m = postLink.href.match(/-(\d+)(?:\?|#|$)/) || postLink.href.match(/\/(\d+)(?:\?|#|$)/); if (m) postId = m[1]; }
                if (!postId) { const canonical = document.querySelector('link[rel="canonical"]')?.href || window.location.href; const m = canonical.match(/-(\d+)(?:\?|#|$)/) || canonical.match(/\/(\d+)(?:\?|#|$)/); if (m) postId = m[1]; }
            }

            if (!postId) {
                if (!(await ensureRootHandle())) throw new Error("Directory access denied");

                let ext = src.split('?')[0].split('.').pop().toLowerCase();
                if (ext === 'jpeg') ext = 'jpg';
                if (!ext || ext.length > 4) ext = "jpg";

                const finalName = `[External]_${Date.now()}.${ext}`;
                const patreonDir = await currentRootHandle.getDirectoryHandle('patreon', { create: true });
                const creatorDir = await patreonDir.getDirectoryHandle('External', { create: true });
                const fileHandle = await creatorDir.getFileHandle(finalName, { create: true });

                const { blob } = await fetchBlobAndType(src);
                const writable = await fileHandle.createWritable();
                await writable.write(blob); await writable.close();
                updateUI(btn, 'success');
                return;
            }

            let finalUrl = src; let originalFileName = ""; let creatorName = "Unknown"; let dateStr = "00000000"; let canView = true;

            if (apiCache.has(postId)) {
                const json = apiCache.get(postId);
                canView = json.data?.attributes?.current_user_can_view;
                const pubDate = json.data?.attributes?.published_at; if (pubDate) dateStr = new Date(pubDate).toISOString().split('T')[0].replace(/-/g, '');
                const creator = json.included?.find(i => i.type === 'user')?.attributes?.full_name; if (creator) creatorName = creator.trim().replace(/[\\/:*?"<>|]/g, "_");
                if (canView !== false && isPatreonNative && uuid) { const mediaData = json.included?.find(i => i.type === 'media' && (i.attributes?.image_urls?.original?.includes(uuid) || i.id === uuid)); if (mediaData?.attributes?.image_urls?.original) { finalUrl = mediaData.attributes.image_urls.original; originalFileName = mediaData.attributes.file_name; } }
            } else {
                try {
                    const resp = await fetch(`https://www.patreon.com/api/posts/${postId}?include=media,user`, { credentials: 'include' });
                    if (resp.ok) {
                        const json = await resp.json(); apiCache.set(postId, json);
                        canView = json.data?.attributes?.current_user_can_view;
                        const pubDate = json.data?.attributes?.published_at; if (pubDate) dateStr = new Date(pubDate).toISOString().split('T')[0].replace(/-/g, '');
                        const creator = json.included?.find(i => i.type === 'user')?.attributes?.full_name; if (creator) creatorName = creator.trim().replace(/[\\/:*?"<>|]/g, "_");
                        if (canView !== false && isPatreonNative && uuid) { const mediaData = json.included?.find(i => i.type === 'media' && (i.attributes?.image_urls?.original?.includes(uuid) || i.id === uuid)); if (mediaData?.attributes?.image_urls?.original) { finalUrl = mediaData.attributes.image_urls.original; originalFileName = mediaData.attributes.file_name; } }
                    }
                } catch (e) { console.warn("API 限制或访问失败"); }
            }

            if (creatorName === "Unknown" || dateStr === "00000000") {
                console.warn(`[拦截] 帖子 ${postId} 数据获取失败 (可能是API限流)`);
                throw new Error("Missing Meta Data");
            }

            if (!(await ensureRootHandle())) throw new Error("Directory permission denied or not selected");

            let ext = finalUrl.split('?')[0].split('.').pop().toLowerCase();
            if (ext === 'jpeg' && window.PtrSettings.getConfig().convertJpeg) {
                ext = 'jpg';
            }
            if (ext.length > 4 || !['jpg', 'png', 'gif', 'webp', 'jpeg'].includes(ext)) ext = 'jpg';

            let numVar = "";
            if (inContainer) {
                const galleryImgs = Array.from(postWrapper.querySelectorAll('div[data-image-container="true"] img')).filter(i => {
                    const b64 = i.src.split('/').find(s => s.startsWith('ey')) || "";
                    return !b64.includes('3IjozMjB9') && !b64.includes('eyJiI');
                });
                const currentIdx = galleryImgs.indexOf(img);
                if (currentIdx !== -1) { numVar = String(currentIdx + 1); }
            } else {
                const coverOrGridImgs = Array.from(postWrapper.querySelectorAll('img[src*="patreon-media/p/post/"]')).filter(i => {
                    const b64 = i.src.split('/').find(s => s.startsWith('ey')) || "";
                    if (b64.includes('3IjozMjB9') || b64.includes('eyJiI')) return false;
                    return !i.closest('div[data-image-container="true"]');
                });
                const currentIdx = coverOrGridImgs.indexOf(img);
                if (currentIdx !== -1) { numVar = String(currentIdx); }
            }

            let origVar = "";
            if (canView === false) { origVar = `Preview`; }
            else if (originalFileName) { origVar = originalFileName.replace(/\.[^/.]+$/, ""); }
            else if (!isPatreonNative) { origVar = `Web`; }
            else if (isPreview) { origVar = `Preview`; }

            const formatTemplate = window.PtrSettings.getConfig().nameFormat;
            let finalName = formatTemplate
            .replace(/{creator}/g, creatorName)
            .replace(/{date}/g, dateStr)
            .replace(/{postid}/g, postId)
            .replace(/{postname}/g, safePostTitle)
            .replace(/{num}/g, numVar)
            .replace(/{name}/g, origVar);

            finalName = finalName.replace(/_+/g, '_').replace(/_$/, '');
            finalName += `.${ext}`;
            finalName = finalName.replace(/[\\/:*?"<>|]/g, "_");

            const patreonDir = await currentRootHandle.getDirectoryHandle('patreon', { create: true });
            const creatorDir = await patreonDir.getDirectoryHandle(creatorName, { create: true });

            let fileHandle;
            let fileExists = false;
            try {
                await creatorDir.getFileHandle(finalName);
                fileExists = true;
            } catch (e) {
                if (e.name === 'NotFoundError') {
                    fileExists = false;
                } else throw e;
            }

            // ==========================================
            // 读取面板设置的“重复文件”行为 (跳过/覆盖/另存为)
            // ==========================================
            if (fileExists) {
                const dupAction = window.PtrSettings.getConfig().dupAction;

                if (dupAction === 'skip') {
                    // [静默跳过] 记为成功,推进度条
                    updateUI(btn, 'success', true);
                    saveHistory(btn.dataset.fingerprint);
                    return;
                }
                else if (dupAction === 'overwrite') {
                    // [直接覆盖] 获取现有文件句柄并创建覆盖流
                    fileHandle = await creatorDir.getFileHandle(finalName, { create: true });
                }
                else {
                    let isCanceled = false;

                    await new Promise((resolve, reject) => {
                        let container = document.getElementById('ptr-prompt-container');
                        if (!container) {
                            container = document.createElement('div');
                            container.id = 'ptr-prompt-container';
                            container.style.cssText = "position:fixed; bottom:90px; left:50%; transform:translateX(-50%); display:flex; flex-direction:column; gap:10px; z-index:2147483647; pointer-events:none; align-items:center;";
                            document.body.appendChild(container);
                        }

                        const promptCard = document.createElement('div');
                        promptCard.style.cssText = "pointer-events:auto; background:rgba(28,28,30,0.85); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); color:#fff; padding:6px 6px 6px 20px; border-radius:32px; border:1px solid rgba(255,255,255,0.08); box-shadow:0 10px 30px rgba(0,0,0,0.3); display:flex; align-items:center; gap:14px; transform:translateY(30px) scale(0.9); opacity:0; transition:all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);";

                        const shortName = finalName.length > 20 ? finalName.substring(0, 10) + '...' + finalName.slice(-8) : finalName;

                        promptCard.innerHTML = `
                        <div style="font-size:13px; font-weight:bold; display:flex; align-items:center; gap:6px;">
                            <span style="color:#FFC107; font-size:15px;">⚠️</span>
                            <span style="opacity:0.95; max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${finalName}">${shortName}</span>
                        </div>
                        <div style="display:flex; gap:6px; align-items:center;">
                            <button class="ptr-btn-saveas" style="padding:6px 16px; background:#4CAF50; color:#fff; border:none; border-radius:20px; font-size:12px; font-weight:bold; cursor:pointer; box-shadow:0 2px 6px rgba(76,175,80,0.3); transition:background 0.2s;" onmouseover="this.style.background='#45a049'" onmouseout="this.style.background='#4CAF50'">${t('dupActionSaveAs')}</button>
                            <div class="ptr-btn-skip" style="width:28px; height:28px; border-radius:50%; background:rgba(255,255,255,0.1); display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:12px; transition:0.2s; margin-left:2px; color:#ccc;" onmouseover="this.style.background='#FF424D';this.style.color='#fff'" onmouseout="this.style.background='rgba(255,255,255,0.1)';this.style.color='#ccc'">✖</div>
                        </div>
                    `;

                        container.appendChild(promptCard);

                        requestAnimationFrame(() => {
                            promptCard.style.transform = "translateY(0) scale(1)";
                            promptCard.style.opacity = "1";
                        });

                        const closeCard = () => {
                            promptCard.style.transform = "scale(0.8)";
                            promptCard.style.opacity = "0";
                            setTimeout(() => promptCard.remove(), 200);
                        };

                        promptCard.querySelector('.ptr-btn-saveas').onclick = async () => {
                            try {
                                const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
                                fileHandle = await win.showSaveFilePicker.call(win, {
                                    suggestedName: finalName
                                });
                                closeCard();
                                resolve();
                            } catch (saveErr) {
                                if (saveErr.name === 'AbortError') {
                                    console.log('用户在原生另存为窗口取消了保存');
                                    isCanceled = true;
                                    closeCard();
                                    resolve();
                                } else {
                                    closeCard();
                                    reject(saveErr);
                                }
                            }
                        };

                        promptCard.querySelector('.ptr-btn-skip').onclick = () => {
                            isCanceled = true;
                            closeCard();
                            resolve();
                        };
                    });

                    if (isCanceled) {
                        // 用户点击 ✖ 取消保存:视为静默成功跳过(红叉进度条不再出现)
                        updateUI(btn, 'success', true);
                        return;
                    }
                }
            } else {
                fileHandle = await creatorDir.getFileHandle(finalName, { create: true });
            }

            const { blob } = await fetchBlobAndType(finalUrl);

            const writable = await fileHandle.createWritable();
            await writable.write(blob);
            await writable.close();
            updateUI(btn, 'success');
            saveHistory(btn.dataset.fingerprint);
        } catch (e) {
            console.error("FSA Download Error:", e);
            updateUI(btn, 'error');
        }
    };

    const updateUI = (btn, state, silent = false) => {
        const svg = { idle: `<path d="M12 3v13m0 0l4-4m-4 4l-4-4" stroke-width="2.5"/><path d="M5 20h14" stroke-width="2.5"/>`, loading: `<circle cx="12" cy="12" r="10" stroke-width="3" opacity=".3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-width="3" class="ptr-spin"/>`, success: `<path d="M5 12l5 5L20 7" stroke-width="3.5"/>`, error: `<path d="M18 6L6 18M6 6l12 12" stroke-width="3.5"/>` };
        const ok = state === 'success';
        const err = state === 'error';
        btn.innerHTML = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:none;stroke:${ok?'#FFF':(err?'#FFF':'#FF424D')};stroke-linecap:round;stroke-linejoin:round;">${svg[state]}</svg>`;
        btn.style.backgroundColor = ok ? '#FF424D' : (err ? '#FF8C00' : 'rgba(255,255,255,0.95)');
        btn.dataset.state = state;

        if(state === 'success' || state === 'error') {
            const postId = btn.dataset.postid || "Unknown";
            if(window.DownloadDashboard) window.DownloadDashboard.checkCompletion(postId, silent);
        }
    };

    const updateBulkUI = (btn, state) => {
        const svgIcon = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`;
        const svgSpin = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><circle cx="12" cy="12" r="10" stroke-width="3" opacity=".3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-width="3" class="ptr-spin"/></svg>`;
        const svgCheck = `<svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:none;stroke:#FF424D;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;"><path d="M5 12l5 5L20 7"/></svg>`;

        let currentSvg = svgIcon;
        if (state === 'loading') currentSvg = svgSpin;
        if (state === 'success') currentSvg = svgCheck;

        btn.dataset.bulkState = state;
        const isIconOnly = btn.dataset.iconOnly === 'true';

        if (isIconOnly) {
            // 【无字模式】:不论什么状态,永远只显示居中的图标,绝不显示文字
            btn.innerHTML = currentSvg;
        } else {
            // 【有字模式】:显示文字,并根据状态变换文本
            let text = t('bulkBtnIdle');
            if (state === 'loading') text = t('bulkBtnLoading');
            if (state === 'success') text = t('bulkBtnSuccess');
            let textColor = state === 'success' ? '#FF424D' : 'inherit';

            btn.innerHTML = `${currentSvg} <span style="font-size:14px; font-weight:600; color:${textColor}; white-space:nowrap; margin-left:6px;">${text}</span>`;
        }
    };

    const scan = () => {
        injectConfigBtn();
        const isHomePage = window.location.pathname === '/' || window.location.pathname.startsWith('/home');

        const processImg = (img) => {
            const src = img.src;
            const b64Segment = src.split('/').find(s => s.startsWith('ey')) || "";
            if (b64Segment.includes('3IjozMjB9') || b64Segment.includes('eyJiI')) { img.dataset.p = "skip"; return; }

            if (!img.parentElement || img.parentElement.querySelector('.ptr-download-btn')) { return; }
            if (window.getComputedStyle(img.parentElement).position === 'static') { img.parentElement.style.position = 'relative'; }

            const btn = document.createElement('div');
            btn.className = 'ptr-download-btn';

            btn.dataset.fingerprint = src.split('?')[0];
            btn.dataset.postid = extractPostId(img);

            // 【注入点】:页面加载时,如果缓存里有这个图的特征码,直接变为完成状态
            if (ptrDownloadHistory.has(btn.dataset.fingerprint)) {
                updateUI(btn, 'success', true);
            } else {
                updateUI(btn, 'idle');
            }

            btn.addEventListener('click', (e) => {

                e.stopPropagation(); e.preventDefault();
                if (btn.dataset.state === 'loading') return;
                updateUI(btn, 'loading');
                startDownload(img, btn, e.isTrusted);
            }, true);
            img.parentElement.appendChild(btn);

            const wrapper = img.closest('.ContentCard-module__4Ygkza__postWrapper') || img.closest('[data-tag="post-card"]');
            if (wrapper) {
                const shareIcon = wrapper.querySelector('svg[data-tag="IconShare"]');
                if (shareIcon) {
                    const actionBtn = shareIcon.closest('button') || shareIcon.closest('div[role="button"]');
                    if (actionBtn) {
                        // 寻找真实的 Flex 容器和响应式外壳
                        let targetNode = actionBtn;
                        let flexRow = actionBtn.parentElement;
                        let responsiveWrapper = null;

                        if (flexRow && flexRow.className && typeof flexRow.className === 'string' && flexRow.className.includes('ResponsiveCssVisibility')) {
                            responsiveWrapper = flexRow;
                            targetNode = responsiveWrapper;
                            flexRow = flexRow.parentElement;
                        }

                        // 如果父级是 flexDirectionRow 且当前容器还没被插入过我们的按钮,才执行
                        if (flexRow && flexRow.className.includes('flexDirectionRow') && !flexRow.querySelector('.ptr-bulk-btn-wrapper')) {

                            const isIconOnly = actionBtn.className && actionBtn.className.includes('variantIconOnly');

                            // 创建响应式外壳(如果原生按钮有的话),保证同隐同显
                            const btnWrapper = document.createElement('div');
                            btnWrapper.className = 'ptr-bulk-btn-wrapper';
                            if (responsiveWrapper) {
                                btnWrapper.className += ' ' + responsiveWrapper.className;
                            }

                            // 创建真实的下载按钮
                            const bulkBtn = document.createElement('div');
                            bulkBtn.className = 'ptr-bulk-btn';
                            bulkBtn.dataset.iconOnly = isIconOnly ? 'true' : 'false';

                            if (isIconOnly) {
                                // 无字版 (圆圈)
                                bulkBtn.style.cssText = `
                                    display: flex; align-items: center; justify-content: center;
                                    width: 40px; height: 40px; border-radius: 50%;
                                    background: transparent; color: inherit; cursor: pointer;
                                    transition: background 0.2s; border: 1px solid rgba(128,128,128,0.25);
                                    flex-shrink: 0;
                                `;
                            } else {
                                // 有字版 (胶囊)
                                bulkBtn.style.cssText = `
                                    display: flex; align-items: center; justify-content: center;
                                    padding: 6px 12px; border-radius: 8px;
                                    background: transparent; color: inherit; cursor: pointer;
                                    transition: background 0.2s; flex-shrink: 0;
                                `;
                            }

                            bulkBtn.onmouseover = () => bulkBtn.style.background = "rgba(128,128,128,0.15)";
                            bulkBtn.onmouseout = () => bulkBtn.style.background = "transparent";

                            updateBulkUI(bulkBtn, 'idle');

                            bulkBtn.onclick = async (e) => {
                                e.stopPropagation(); e.preventDefault();
                                if (bulkBtn.dataset.bulkState === 'loading' || bulkBtn.dataset.bulkState === 'success') return;
                                if (!(await ensureRootHandle())) return;

                                const rawSingles = Array.from(wrapper.querySelectorAll('.ptr-download-btn:not([data-state="success"]):not([data-state="loading"])'));

                                const uniqueMap = new Map();
                                rawSingles.forEach(btn => {
                                    const fp = btn.dataset.fingerprint;
                                    if (fp && !uniqueMap.has(fp)) uniqueMap.set(fp, btn);
                                    else updateUI(btn, 'success', true);
                                });

                                const singles = Array.from(uniqueMap.values());

                                if (singles.length === 0) {
                                    updateBulkUI(bulkBtn, 'success');
                                    return;
                                }

                                updateBulkUI(bulkBtn, 'loading');
                                window.DownloadDashboard.addTaskButtons(singles);

                                await window.ptrQueueManager.process(singles);

                                const hasError = singles.some(b => b.dataset.state === 'error');
                                updateBulkUI(bulkBtn, hasError ? 'idle' : 'success');
                            };

                            // 组装并依靠原生的 gap 属性插入
                            btnWrapper.appendChild(bulkBtn);
                            flexRow.insertBefore(btnWrapper, targetNode);
                        }
                    }
                }
            }
        };

        if (isHomePage) {
            document.querySelectorAll('img[src*="patreon-media/p/post/"]:not([data-p="skip"]), div[data-image-container="true"] img:not([data-p="skip"])').forEach(processImg);
        } else {
            const wrappers = document.querySelectorAll('.ContentCard-module__4Ygkza__postWrapper, [data-tag="post-card"]');
            wrappers.forEach(wrapper => {
                wrapper.querySelectorAll('img[src*="patreon-media/p/post/"]:not([data-p="skip"]), div[data-image-container="true"] img:not([data-p="skip"])').forEach(processImg);
            });
        }
    };

    const s = document.createElement('style');
    s.innerHTML = `
        .ptr-download-btn { position: absolute; right: 10px; bottom: 10px; z-index: 999; width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; background: rgba(255,255,255,0.95); box-shadow: 0 2px 8px rgba(0,0,0,0.2); transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
        .ptr-download-btn:not([data-state="success"]):hover { transform: scale(1.15); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
        .ptr-spin { animation: ptr-spin 1s linear infinite; transform-origin: center; }
        @keyframes ptr-spin { to { transform: rotate(360deg); } }
        .ptr-dash-icon { cursor: pointer; opacity: 0.7; transition: opacity 0.2s; user-select: none; }
        .ptr-dash-icon:hover { opacity: 1; }

        #ptr-dash-list::-webkit-scrollbar { width: 8px; }
        #ptr-dash-list::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.15); border-radius: 4px; }
        #ptr-dash-list::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
        #ptr-dash-list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }

        #ptr-dash-list::-webkit-scrollbar-button:single-button { background-color: rgba(255, 255, 255, 0.05); display: block; height: 8px; border-radius: 2px; }
        #ptr-dash-list::-webkit-scrollbar-button:single-button:hover { background-color: rgba(255, 255, 255, 0.1); }

        #ptr-download-dashboard { transition: opacity 0.3s ease !important; }
        #ptr-download-dashboard.ptr-dash-minimized:not(:hover) { opacity: 0.35 !important; }
    `;
    document.head.appendChild(s);
    new MutationObserver(scan).observe(document.body, { childList: true, subtree: true }); scan();
})();