Patreon 이미지 다운로더

Patreon 웹사이트의 현재 페이지에 있는 단일 또는 모든 이미지를 다운로드합니다

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();