Patreon 网站单个、当前页面所有图片下载
// ==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();
})();