您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
批量下载QQ邮箱附件,支持筛选、排序和批量操作
// ==UserScript== // @name QQ邮箱附件管理助手 // @namespace https://gf.qytechs.cn/zh-CN/scripts/535160 // @version 1.0 // @description 批量下载QQ邮箱附件,支持筛选、排序和批量操作 // @author XHXIAIEIN // @homepage https://github.com/XHXIAIEIN/Auto-Download-QQMail-Attach/ // @match https://wx.mail.qq.com/* // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_notification // @connect mail.qq.com // @connect wx.mail.qq.com // @license MIT // @connect gzc-dfsdown.mail.ftn.qq.com // ==/UserScript== (function () { 'use strict'; class StyleManager { static getStyles() { return { base: { panel: `position: fixed; background: var(--bg_white_web, #FFFFFF); border-radius: 8px; box-shadow: var(--shadow_4, 0 8px 12px 0 rgba(19, 24, 29, 0.14)); z-index: 10000; display: flex; flex-direction: column;`, overlay: `position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--mask_gray_030, rgba(0, 0, 0, 0.3)); z-index: 9999; display: flex; align-items: center; justify-content: center;`, toolbar: `display: flex; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--base_gray_007, rgba(21, 46, 74, 0.07)); background: var(--bg_white_web, #FFFFFF); border-radius: 8px 8px 0 0;`, content: `flex: 1; min-height: 0; overflow: auto; padding: 20px;` }, buttons: { primary: `padding: 8px 16px; background: var(--theme_primary, #0F7AF5); color: var(--base_white_100, #FFFFFF); border: 1px solid var(--theme_darken_2, #0E66CB); border-radius: 4px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 4px; transition: all 0.2s; box-shadow: var(--material_BlueButton_Small);`, secondary: `padding: 8px 16px; background: var(--bg_white_web, #FFFFFF); color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); border-radius: 4px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 4px; transition: all 0.2s;`, icon: `width: 32px; height: 32px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); border-radius: 4px; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s;` }, cards: { attachment: `background: var(--bg_white_web, #FFFFFF); border: 1px solid var(--border_gray_010, rgba(22, 46, 74, 0.05)); border-radius: 8px; overflow: hidden; cursor: pointer; transition: box-shadow 0.2s, border 0.2s; position: relative; aspect-ratio: 1; box-shadow: none;`, mail: `background: var(--bg_white_web, #FFFFFF); border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); border-radius: 8px; margin-bottom: 16px; overflow: hidden; transition: box-shadow 0.2s;` }, controls: { checkbox: `position: absolute; top: 8px; left: 8px; width: 20px; height: 20px; z-index: 10; accent-color: var(--theme_primary, #0F7AF5); cursor: pointer; background: rgba(255,255,255,0.9); border-radius: 4px; box-shadow: 0 1px 4px 0 rgba(21,46,74,0.08); opacity: 0.9; transition: opacity 0.2s, box-shadow 0.2s; border: 1px solid var(--border_gray_020, #e0e6ed);` }, menus: { dropdown: `position: fixed; background: var(--bg_white_web, #FFFFFF); border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.08)); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); padding: 8px 0; min-width: 160px; z-index: 1001;`, item: `padding: 8px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--base_gray_100, #13181D); transition: background 0.2s;` }, states: { loading: `position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--mask_white_095, rgba(255, 255, 255, 0.95)); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 1000;`, empty: `text-align: center; padding: 40px 20px; color: var(--base_gray_030, rgba(25, 38, 54, 0.3)); font-size: 14px;`, spinner: `width: 32px; height: 32px; border: 2px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); border-top: 2px solid var(--theme_primary, #0F7AF5); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px;` }, toasts: { base: `position: fixed; top: 20px; right: 20px; padding: 12px 16px; border-radius: 6px; color: white; font-size: 13px; z-index: 10002; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); display: flex; align-items: center; gap: 8px; max-width: 320px; word-wrap: break-word; opacity: 0; transform: translateX(100%); transition: all 0.3s ease;`, info: `background: var(--theme_primary, #0F7AF5);`, success: `background: var(--chrome_green, #00A755);`, warning: `background: var(--chrome_yellow, #FFA500);`, error: `background: var(--chrome_red, #F73116);` }, media: { preview: `width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); overflow: hidden;`, image: `width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease;`, icon: `font-size: 24px; color: var(--theme_primary, #0F7AF5); display: flex; align-items: center; justify-content: center; height: 100%; transition: transform 0.3s ease;` }, layouts: { grid: `display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 16px; padding: 16px; background: transparent;`, floatingWindow: `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 75%; max-width: 900px; height: 95%; max-height: 95vh; min-width: 600px; min-height: 700px; background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 32px rgba(0, 0, 0, 0.1); z-index: 10000; display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; resize: both;`, floatingOverlay: `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(4px); z-index: 9999; opacity: 0; transition: opacity 0.3s ease;`, windowHeader: `display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: #fff; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: move; user-select: none; flex-shrink: 0;`, windowContent: `flex: 1; overflow: hidden; display: flex; flex-direction: column; background: #f5f5f5;`, windowControls: `display: flex; align-items: center; gap: 8px;`, windowButton: `width: 12px; height: 12px; border-radius: 50%; cursor: pointer; transition: all 0.2s ease;`, closeButton: `background: #ff5f57; border: 1px solid #e0443e;`, minimizeButton: `background: #ffbd2e; border: 1px solid #dea123;`, maximizeButton: `background: #28ca42; border: 1px solid #1aab29;`, bentoGrid: `display: flex; flex-direction: column; gap: 24px; padding: 32px; max-width: 800px; margin: 0 auto; min-height: 100%; background: #f8f9fa;`, bentoCard: `background: var(--bg_white_web, #FFFFFF); border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); border-radius: 12px; padding: 24px; transition: all 0.2s ease; position: relative; overflow: hidden; min-height: 120px; display: flex; flex-direction: column;`, bentoCardPrimary: `background: rgba(25, 118, 210, 0.05); color: #1976d2; border: 2px solid #1976d2; border-radius: 12px; padding: 24px; position: relative; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(25, 118, 210, 0.08); min-height: 160px; display: flex; flex-direction: column;`, bentoCardWarning: `background: rgba(245, 124, 0, 0.05); color: #f57c00; border: 2px solid #f57c00; border-radius: 12px; padding: 24px; position: relative; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(245, 124, 0, 0.08); min-height: 160px; display: flex; flex-direction: column;`, bentoCardHeader: `background: var(--bg_white_web, #FFFFFF); border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); border-radius: 12px; padding: 20px; display: flex; justify-content: space-between; align-items: center; min-height: 80px;`, bentoRow: `display: grid; grid-template-columns: 1fr 1fr; gap: 20px; width: 100%;`, bentoRowResponsive: `display: grid; grid-template-columns: 1fr 1fr; gap: 20px; width: 100%;`, bentoRowThree: `display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; width: 100%;`, bentoRowFour: `display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; width: 100%;`, bentoStatsGrid: `display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 20px; width: 100%;`, bentoStatsResponsive: `display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 20px; width: 100%;` }, dialogs: { compareDialog: `padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); position: relative;`, detailDialog: `position: relative; width: 90%; max-width: 1000px; max-height: 90vh; transform: scale(0.95); transition: transform 0.3s ease;`, settingsOverlay: `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;`, settingsContent: `background: white; border-radius: 16px; padding: 32px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);`, settingsSection: `margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1));`, settingsTitle: `font-size: 24px; font-weight: 700; color: var(--base_gray_100, #13181D); margin-bottom: 24px;`, settingsSectionTitle: `font-size: 18px; font-weight: 600; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); margin-bottom: 16px;`, settingsItem: `margin-bottom: 20px;`, settingsLabel: `font-size: 14px; font-weight: 600; color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); margin-bottom: 8px; display: block;`, settingsInput: `width: 100%; padding: 12px 16px; border: 1px solid var(--base_gray_020, rgba(22, 46, 74, 0.2)); border-radius: 8px; font-size: 14px; transition: all 0.2s ease; box-sizing: border-box;`, settingsSelect: `width: 100%; padding: 12px 16px; border: 1px solid var(--base_gray_020, rgba(22, 46, 74, 0.2)); border-radius: 8px; font-size: 14px; background: white; transition: all 0.2s ease; box-sizing: border-box;`, settingsCheckbox: `margin-right: 8px; transform: scale(1.2);`, settingsDescription: `font-size: 13px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); margin-top: 4px; line-height: 1.4;`, settingsButtonGroup: `display: flex; gap: 12px; justify-content: flex-end; margin-top: 32px; padding-top: 24px; border-top: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1));`, settingsRow: `display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;`, settingsCheckboxItem: `display: flex; align-items: center; margin-bottom: 12px;` }, comparison: { summaryTitle: `display: flex; align-items: center; gap: 12px; margin-bottom: 20px;`, titleAccent: `width: 4px; height: 24px; background: linear-gradient(135deg, #0F7AF5, #40a9ff); border-radius: 2px;`, statsGrid: `display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px;`, statCard: `border-radius: 12px; padding: 20px; color: white; position: relative; overflow: hidden;`, statCardDecor: `position: absolute; top: -10px; right: -10px; width: 60px; height: 60px; background: rgba(255,255,255,0.1); border-radius: 50%; opacity: 0.3;`, statNumber: `font-size: 28px; font-weight: 800; margin-bottom: 4px;`, statLabel: `font-size: 13px; opacity: 0.9;`, statExtra: `font-size: 11px; opacity: 0.7; margin-top: 4px;`, statusCard: `border-radius: 12px; padding: 20px; position: relative; overflow: hidden;`, statusIcon: `position: absolute; top: -20px; right: -20px; font-size: 80px; opacity: 0.1;`, statusHeader: `display: flex; align-items: center; gap: 12px; margin-bottom: 16px;`, statusBadge: `width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 20px; font-weight: bold;`, statusGrid: `display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;`, statusSubCard: `background: rgba(255,255,255,0.6); border-radius: 8px; padding: 16px;`, sectionCard: `background: #fff; border-radius: 12px; overflow: hidden; margin-bottom: 20px;`, sectionHeader: `padding: 16px 20px; display: flex; align-items: center; justify-content: space-between;`, sectionIcon: `width: 36px; height: 36px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px;`, sectionTitle: `margin: 0; font-size: 18px; font-weight: 700;`, sectionSubtitle: `font-size: 13px; opacity: 0.9; margin-top: 2px;`, sectionCount: `text-align: right;`, sectionNumber: `font-size: 24px; font-weight: 800;`, sectionUnit: `font-size: 11px; opacity: 0.8;`, itemList: `max-height: 300px; overflow-y: auto;`, itemRow: `padding: 16px 20px; display: flex; align-items: center; gap: 16px; transition: background 0.2s;`, itemDot: `width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;`, itemContent: `flex: 1; min-width: 0;`, itemName: `font-weight: 600; color: #13181D; margin-bottom: 4px; word-break: break-all;`, itemMeta: `display: flex; align-items: center; gap: 12px; font-size: 12px; color: #666;`, itemTag: `font-size: 11px; padding: 4px 8px; background: #f8f9fa; border-radius: 4px; margin-top: 4px;`, emptyState: `border-radius: 12px; padding: 20px; text-align: center; margin-bottom: 20px;`, emptyIcon: `width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; color: white; font-size: 24px;`, emptyTitle: `margin: 0 0 8px 0; font-size: 18px; font-weight: 700;`, emptyDesc: `font-size: 14px;` }, progress: { area: `display: block; padding: 16px 20px; border-top: 1px solid var(--base_gray_010, #e9e9e9); background: var(--bg_white_web, #FFFFFF); position: fixed; bottom: 0; left: 0; right: 0; z-index: 999;`, text: `color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); font-size: 13px;` }, errors: { centered: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;` }, interactions: { menuItemHover: `background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); font-weight: 500;`, cardHover: `transform: translateY(-2px); box-shadow: 0 4px 12px 0 rgba(19, 24, 29, 0.12); border-color: var(--base_gray_020, rgba(22, 46, 74, 0.2));`, cardPrimaryHover: `transform: translateY(-2px); box-shadow: 0 4px 16px rgba(25, 118, 210, 0.15); background: rgba(25, 118, 210, 0.08); border-color: #1565c0;`, cardWarningHover: `transform: translateY(-2px); box-shadow: 0 4px 16px rgba(245, 124, 0, 0.15); background: rgba(245, 124, 0, 0.08); border-color: #ef6c00;`, buttonHover: `background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); border-color: var(--base_gray_015, rgba(23, 46, 71, 0.15));`, actionButtonHover: `background: var(--theme_primary, #0F7AF5); color: white; transform: translateY(-1px); box-shadow: 0 4px 12px 0 rgba(15, 122, 245, 0.3);`, actionButtonSecondaryHover: `background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); border-color: var(--base_gray_030, rgba(22, 46, 74, 0.3)); transform: translateY(-1px);`, primaryButtonHover: `background: var(--theme_lighten_1, #328DF6);`, downloadButtonHover: `background: #0e66cb; box-shadow: 0 4px 16px 0 rgba(15,122,245,0.13);`, checkboxHover: `opacity: 1; box-shadow: 0 2px 8px 0 rgba(15,122,245,0.12);`, iconHover: `transform: scale(1.05);`, imageHover: `transform: scale(1.02);` }, toolbars: { overlay: `height: 56px; flex-shrink: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); position: relative; z-index: 1000; background: var(--bg_white_web, #FFFFFF); border-bottom: 1px solid var(--base_gray_007, rgba(21, 46, 74, 0.07));`, section: `display: flex; align-items: center;`, leftSection: `flex: 1;`, rightSection: `gap: 12px;`, middleSection: `gap: 8px;` }, texts: { title: `margin: 0; font-size: 18px; font-weight: 600; color: #333;`, subtitle: `margin-left: 12px; font-size: 14px; color: #666;`, errorIcon: `color: var(--chrome_red, #F73116); font-size: 20px; margin-bottom: 8px;`, errorText: `color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); font-size: 13px;`, headerTitle: `font-size: 24px; font-weight: 700; color: var(--base_gray_100, #13181D); margin: 0; line-height: 1.2;`, headerSubtitle: `font-size: 14px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); margin: 4px 0 0 0; line-height: 1.3;`, bentoNumber: `font-size: 32px; font-weight: 700; color: var(--base_gray_100, #13181D); margin: 0; line-height: 1.1; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; letter-spacing: -0.02em;`, bentoNumberLarge: `font-size: 42px; font-weight: 800; color: inherit; margin: 0; line-height: 1; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; letter-spacing: -0.02em;`, bentoNumberWarning: `font-size: 38px; font-weight: 800; color: inherit; margin: 0; line-height: 1; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; letter-spacing: -0.02em;`, bentoLabel: `font-size: 14px; font-weight: 600; color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); margin: 6px 0 0 0; line-height: 1.3;`, bentoLabelLarge: `font-size: 15px; font-weight: 600; color: inherit; opacity: 0.8; margin: 8px 0 0 0; line-height: 1.3;`, bentoLabelWarning: `font-size: 14px; font-weight: 600; color: inherit; opacity: 0.8; margin: 6px 0 0 0; line-height: 1.3;`, bentoDesc: `font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); line-height: 1.4; margin: 4px 0 0 0;`, bentoDescLarge: `font-size: 13px; color: inherit; opacity: 0.7; line-height: 1.4; margin: 6px 0 0 0;`, bentoDescWarning: `font-size: 12px; color: inherit; opacity: 0.7; line-height: 1.4; margin: 4px 0 0 0;`, bentoTitle: `font-size: 16px; font-weight: 600; color: var(--base_gray_100, #13181D); margin: 0 0 12px 0; line-height: 1.3;`, actionButton: `font-size: 13px; font-weight: 600; color: var(--theme_primary, #0F7AF5); text-decoration: none; padding: 8px 16px; border: 1px solid var(--theme_primary, #0F7AF5); border-radius: 6px; transition: all 0.2s ease; display: inline-flex; align-items: center; gap: 6px;`, actionButtonSecondary: `font-size: 13px; font-weight: 600; color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); text-decoration: none; padding: 8px 16px; border: 1px solid var(--base_gray_020, rgba(22, 46, 74, 0.2)); border-radius: 6px; transition: all 0.2s ease; display: inline-flex; align-items: center; gap: 6px; background: var(--bg_white_web, #FFFFFF);` }, toastExtensions: { custom: `background: var(--bg_white_web, #FFFFFF); color: var(--base_gray_100, #13181D); bottom: 20px; animation: slideIn 0.3s ease-out;` }, layout: { overlay: `position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 10000; background: #f5f5f5;` } }; } static applyStyle(element, styleKey, subKey) { const styles = this.getStyles(); if (subKey) { element.style.cssText = styles[styleKey][subKey]; } else { element.style.cssText = styles[styleKey]; } } static addSpinnerAnimation() { if (!document.getElementById('attachment-spinner-style')) { const style = document.createElement('style'); style.id = 'attachment-spinner-style'; style.textContent = ` @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); } } static addLayoutStyles() { if (!document.getElementById('attachment-layout-style')) { const style = document.createElement('style'); style.id = 'attachment-layout-style'; const layouts = this.getStyles().layouts; style.textContent = ` .attachment-floating-overlay { ${layouts.floatingOverlay} } .attachment-floating-overlay.show { opacity: 1; } .attachment-floating-window { ${layouts.floatingWindow} opacity: 0; transform: translate(-50%, -50%) scale(0.9); transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } .attachment-floating-window.show { opacity: 1; transform: translate(-50%, -50%) scale(1); } .attachment-window-header { ${layouts.windowHeader} } .attachment-window-content { ${layouts.windowContent} } .attachment-window-controls { ${layouts.windowControls} } .attachment-window-button { ${layouts.windowButton} } .attachment-window-button.close { ${layouts.closeButton} } .attachment-window-button.close:hover { background: #e0443e; transform: scale(1.1); } .attachment-window-button.minimize { ${layouts.minimizeButton} } .attachment-window-button.minimize:hover { background: #dea123; transform: scale(1.1); } .attachment-window-button.maximize { ${layouts.maximizeButton} } .attachment-window-button.maximize:hover { background: #1aab29; transform: scale(1.1); } .attachment-floating-window.maximized { top: 0 !important; left: 0 !important; transform: none !important; width: 100% !important; height: 100% !important; border-radius: 0 !important; max-width: none !important; max-height: none !important; } .attachment-floating-window.minimized { height: 60px !important; overflow: hidden; } .attachment-floating-window.minimized .attachment-window-content { display: none; } @keyframes attachmentWindowSlideIn { from { opacity: 0; transform: translate(-50%, -60%) scale(0.8); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } /* 响应式布局 */ @media (max-width: 1024px) { .attachment-floating-window { width: 85% !important; height: 95% !important; min-width: 500px !important; } } @media (max-width: 768px) { .attachment-floating-window { width: 100% !important; height: 100% !important; min-width: 320px !important; border-radius: 0 !important; top: 0 !important; left: 0 !important; transform: none !important; } /* 移动端卡片布局调整 */ .bento-row-responsive { grid-template-columns: 1fr !important; gap: 16px !important; } .bento-stats-responsive { grid-template-columns: repeat(2, 1fr) !important; gap: 12px !important; } } @media (max-width: 480px) { .bento-stats-responsive { grid-template-columns: 1fr !important; } } `; document.head.appendChild(style); } } static applyInteractionStyle(element, styleKey, reset = false) { const styles = this.getStyles(); if (reset) { element.style.cssText = element.getAttribute('data-original-style') || ''; } else { if (!element.getAttribute('data-original-style')) { element.setAttribute('data-original-style', element.style.cssText); } if (styles.interactions && styles.interactions[styleKey]) { const originalStyle = element.style.cssText; element.style.cssText = originalStyle + '; ' + styles.interactions[styleKey]; } } } } const MAIL_CONSTANTS = { BASE_URL: 'https://wx.mail.qq.com', API_ENDPOINTS: { MAIL_LIST: '/list/maillist', ATTACH_DOWNLOAD: '/attach/download', ATTACH_THUMBNAIL: '/attach/thumbnail', ATTACH_PREVIEW: '/attach/preview' } }; class AttachmentManager { constructor(downloader) { this.downloader = downloader; this.isVisible = false; this.selectedAttachments = new Set(); this.downloadQueue = []; this.downloading = false; this.retryCount = 3; this.attachments = []; this.filters = { date: 'all', dateRange: { start: null, end: null }, minSize: 0, maxSize: 0, allowedTypes: [], excludedTypes: [] }; this.sortBy = { field: 'date', direction: 'desc' }; this.groupBy = 'mail'; this.isLoading = false; this.currentFilter = 'all'; this.currentSort = 'date'; // 面板状态管理 this.isViewActive = false; this.toggleInProgress = false; this.currentFolderId = null; this.overlayPanel = null; this.smartDownloadButton = null; this.downloadProgressTimer = null; this.globalKeyHandler = null; this.buttonObserver = null; this.isMinimized = false; this.isMaximized = false; this.dragState = { isDragging: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 }; this.totalMailCount = 0; this.detailData = { noAttachmentMails: [], invalidNamingAttachments: [] }; this.downloadSettings = { fileNaming: { prefix: '', suffix: '', includeMailId: false, includeAttachmentId: false, includeMailSubject: false, includeFileType: false, separator: '_', useCustomPattern: false, customPattern: '{date}_{subject}_{fileName}', validation: { enabled: true, pattern: '\\d{6,}', fallbackPattern: 'auto', fallbackTemplate: '{subject}_{fileName}', replacementChar: '_', removeInvalidChars: true } }, folderStructure: 'flat', dateFormat: 'YYYY-MM-DD', createDateSubfolders: false, folderNaming: { customTemplate: '{date}/{senderName}' }, conflictResolution: 'rename', downloadBehavior: { showProgress: true, retryOnFail: true, verifyDownloads: true, notifyOnComplete: true, concurrentDownloads: 'auto', autoCompareAfterDownload: true }, smartGrouping: { enabled: false, maxGroupSize: 5, groupByType: true, groupByDate: true } }; this.downloadQueue = { high: [], normal: [], low: [] }; this.downloadStats = { startTime: null, completedSize: 0, totalSize: 0, speed: 0, lastUpdate: null }; this.concurrentControl = { minConcurrent: 2, maxConcurrent: 5, currentConcurrent: 3, successCount: 0, failCount: 0, lastAdjustTime: null, adjustInterval: 10000 }; this.totalTasksForProgress = 0; this.completedTasksForProgress = 0; this.autoSaveTimeout = null; this.currentComparisonResult = null; this.init(); } init() { window.attachmentManager = this; this.loadSavedSettings(); this.initUrlChangeListener(); // 清理可能存在的旧按钮 const existingButtons = document.querySelectorAll('#attachment-downloader-btn, [data-attachment-manager-btn="true"], .attachment-floating-btn'); existingButtons.forEach(btn => btn.remove()); // 延迟创建按钮,确保页面元素已加载 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { setTimeout(() => this.createAndInjectButton(), 500); }); } else { setTimeout(() => this.createAndInjectButton(), 500); } } initUrlChangeListener() { window.addEventListener('hashchange', () => { const newFolderId = this.downloader.getCurrentFolderId(); if (this.currentFolderId !== newFolderId) { console.log('[URL变化] 检测到文件夹变化:', this.currentFolderId, '->', newFolderId); this.currentFolderId = newFolderId; // 立即清理旧按钮 const existingButtons = document.querySelectorAll('#attachment-downloader-btn, [data-attachment-manager-btn="true"], .attachment-floating-btn'); existingButtons.forEach(btn => btn.remove()); // 延迟创建按钮,等待页面更新完成 setTimeout(() => this.createAndInjectButton(), 500); // 只有在面板激活时才重新初始化,避免冲突 if (this.isViewActive) { console.log('[URL变化] 面板处于激活状态,准备重新初始化'); // 添加延迟,确保页面完全加载 setTimeout(() => { if (this.isViewActive) { // 再次检查状态 this.reinitializeForFolderChange(); } }, 1000); } } }); } hidePanel() { try { this.hideAttachmentView(); } catch (error) { this.cleanupAttachmentManager(true); } } // 获取文件夹显示名称 getFolderDisplayName() { // 获取文件夹名称 const nativeFolderName = document.querySelector('.toolbar-folder-name'); if (nativeFolderName && nativeFolderName.textContent.trim()) { return nativeFolderName.textContent.trim(); } } // 显示设置对话框 async showSettingsDialog() { const dialog = this.createUI('div', { styles: StyleManager.getStyles().dialogs.settingsOverlay, content: ` <div style="${StyleManager.getStyles().dialogs.settingsContent}"> <div style="${StyleManager.getStyles().dialogs.settingsTitle}"> <span>下载设置</span> <button id="settings-close-x" style="position: absolute; right: 24px; top: 24px; background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s;">×</button> </div> <!-- 选项卡导航 --> <div style="display: flex; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); margin: 0 -32px; padding: 0 32px; margin-top: 20px;"> <div class="settings-tab active" data-tab="basic" style="padding: 12px 24px; cursor: pointer; border-bottom: 2px solid var(--theme_primary, #0F7AF5); color: var(--theme_primary, #0F7AF5); font-weight: 600; transition: all 0.2s;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 8px;"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <polyline points="14 2 14 8 20 8"/> <line x1="16" y1="13" x2="8" y2="13"/> <line x1="16" y1="17" x2="8" y2="17"/> <polyline points="10 9 9 9 8 9"/> </svg> 基础设置 </div> <div class="settings-tab" data-tab="advanced" style="padding: 12px 24px; cursor: pointer; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); transition: all 0.2s;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 8px;"> <circle cx="12" cy="12" r="3"/> <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"/> </svg> 高级设置 </div> <div class="settings-tab" data-tab="download" style="padding: 12px 24px; cursor: pointer; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); transition: all 0.2s;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 8px;"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7 10 12 15 17 10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> 下载设置 </div> <div class="settings-tab" data-tab="filter" style="padding: 12px 24px; cursor: pointer; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); transition: all 0.2s;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 8px;"> <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/> </svg> 过滤规则 </div> </div> <!-- 选项卡内容区域 --> <div style="margin-top: 24px; max-height: calc(80vh - 200px); overflow-y: auto; padding-right: 8px;"> <!-- 基础设置面板 --> <div id="settings-panel-basic" class="settings-panel" style="display: block;"> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <polyline points="14 2 14 8 20 8"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">文件命名</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">命名模式</label> <select id="naming-mode" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="original">原始文件名</option> <option value="custom">自定义文件名</option> </select> </div> <div id="custom-naming-options" style="display: none; background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); padding: 16px; border-radius: 8px; margin-top: 12px;"> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">命名模板</label> <div style="position: relative;"> <input type="text" id="naming-pattern" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="{date}_{subject}_{fileName}" value="{date}_{subject}_{fileName}"> <button type="button" id="show-variables-btn" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: var(--theme_primary, #0F7AF5); color: white; border: none; border-radius: 4px; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 500;">选择变量</button> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 使用变量构建自定义文件名,点击按钮查看所有可用变量 </div> </div> <div style="${StyleManager.getStyles().dialogs.settingsRow}; margin-top: 16px;"> <div> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">前缀</label> <input type="text" id="naming-prefix" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="可选前缀"> </div> <div> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">后缀</label> <input type="text" id="naming-suffix" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="可选后缀"> </div> </div> </div> </div> <!-- 文件夹结构 --> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">文件夹结构</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">组织方式</label> <select id="folder-structure" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="flat" selected>平铺(所有文件在同一文件夹)</option> <option value="subject">主题</option> <option value="sender">发件人</option> <option value="date">日期</option> <option value="custom">自定义文件夹结构</option> </select> </div> <!-- 自定义文件夹结构配置 --> <div id="folder-naming-options" style="display: none; background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); padding: 16px; border-radius: 8px; margin-top: 12px;"> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">文件夹结构模板</label> <div style="position: relative;"> <input type="text" id="folder-custom-template" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="{date:YYYY-MM}/{senderName}" value="{date:YYYY-MM}/{senderName}"> <button type="button" id="show-folder-custom-variables-btn" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: var(--theme_primary, #0F7AF5); color: white; border: none; border-radius: 4px; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 500;">选择变量</button> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 使用"/"分隔创建多级文件夹。常用模板:<br> • {date}/{senderName} - 按日期和发件人<br> • {subject}/{fileType} - 按主题和文件类型<br> • {senderName}/{date} - 按发件人和日期 </div> </div> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="smart-grouping" style="${StyleManager.getStyles().dialogs.settingsCheckbox}"> <label for="smart-grouping" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">启用智能分组</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">根据文件名模式自动分组相关文件</div> </div> </div> <!-- 高级设置面板 --> <div id="settings-panel-advanced" class="settings-panel" style="display: none;"> <!-- 文件名规范检查 --> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <path d="M9 11l3 3L22 4"/> <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">命名规则验证</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">启用验证</label> <select id="filename-validation" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="enabled">启用</option> <option value="disabled">禁用</option> </select> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">使用正则表达式检查文件名是否符合规范</div> </div> <div id="filename-validation-options" style="display: block; background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); padding: 16px; border-radius: 8px; margin-top: 12px;"> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">验证正则表达式</label> <input type="text" id="validation-pattern" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="\\d{6,}" value="\\d{6,}"> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 用于验证文件名的正则表达式。例如:\\d{6,} 表示至少6位数字 </div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">不符合规则时的处理</label> <select id="fallback-pattern" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="auto" selected>自动分析</option> <option value="mailSubject">添加邮件主题</option> <option value="senderEmail">添加发件人邮箱</option> <option value="toTime">添加时间戳</option> <option value="customTemplate">自定义模板</option> </select> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">当文件名不符合验证规则时的处理方式</div> </div> <div id="fallback-template-container" style="${StyleManager.getStyles().dialogs.settingsItem}; display: none;"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">备用命名模板</label> <div style="position: relative;"> <input type="text" id="fallback-template" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="{date}_{subject}_{fileName}" value="{subject}_{fileName}"> <button type="button" id="show-fallback-variables-btn" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: var(--theme_primary, #0F7AF5); color: white; border: none; border-radius: 4px; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 500;">选择变量</button> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 不符合规则时使用的命名模板 </div> </div> </div> </div> <!-- 内容替换 --> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">内容替换</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="enable-content-replacement" style="${StyleManager.getStyles().dialogs.settingsCheckbox}"> <label for="enable-content-replacement" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">启用内容替换</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">对文件名进行自定义内容替换,支持字符串和正则表达式</div> <div id="content-replacement-options" style="display: none; background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); padding: 16px; border-radius: 8px; margin-top: 12px;"> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">匹配模式</label> <select id="replacement-mode" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="string" selected>字符串匹配</option> <option value="regex">正则表达式</option> </select> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">查找内容</label> <input type="text" id="replacement-search" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="要替换的内容或正则表达式"> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 字符串模式:直接输入要替换的文字 | 正则模式:如 \\d{4} 匹配4位数字 </div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">替换内容</label> <div style="position: relative;"> <input type="text" id="replacement-replace" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="替换后的内容,支持变量"> <button type="button" id="show-replacement-variables-btn" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: var(--theme_primary, #0F7AF5); color: white; border: none; border-radius: 4px; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 500;">选择变量</button> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 支持变量如 {subject}、{date} 等,正则模式还支持捕获组 $1、$2 </div> </div> <div style="display: flex; gap: 24px; margin-top: 12px;"> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="replacement-global" style="${StyleManager.getStyles().dialogs.settingsCheckbox}" checked> <label for="replacement-global" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">全局替换</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="replacement-case-sensitive" style="${StyleManager.getStyles().dialogs.settingsCheckbox}"> <label for="replacement-case-sensitive" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">区分大小写</label> </div> </div> </div> </div> <!-- 文件名字符处理 --> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">字符处理</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">无效字符处理方式</label> <div style="${StyleManager.getStyles().dialogs.settingsRow}"> <div style="flex: 1;"> <select id="invalid-char-handling" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="replace" selected>替换无效字符</option> <option value="remove">移除无效字符</option> <option value="keep">保留原字符</option> </select> </div> <div style="flex: 0 0 120px; margin-left: 10px;"> <input type="text" id="invalid-char-replacement" style="${StyleManager.getStyles().dialogs.settingsInput}" value="_" placeholder="_" maxlength="3"> </div> </div> <div style="${StyleManager.getStyles().dialogs.settingsDescription}"> 自动处理系统不允许的文件名字符(如 < > : " | ? * 等) </div> </div> </div> </div> <!-- 下载设置面板 --> <div id="settings-panel-download" class="settings-panel" style="display: none;"> <!-- 下载行为 --> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7 10 12 15 17 10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">下载行为</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsRow}"> <div> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">并发下载数</label> <select id="concurrent-downloads" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="auto" selected>自动调节 (推荐)</option> <option value="1">1个文件</option> <option value="2">2个文件</option> <option value="3">3个文件</option> <option value="5">5个文件</option> </select> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">自动调节模式会根据下载成功率动态调整并发数(2-5个文件),提供最佳下载性能</div> </div> <div> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">冲突处理</label> <select id="conflict-resolution" style="${StyleManager.getStyles().dialogs.settingsSelect}"> <option value="rename" selected>自动重命名</option> <option value="skip">跳过已存在</option> <option value="overwrite">覆盖文件</option> </select> </div> </div> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;"> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="show-progress" style="${StyleManager.getStyles().dialogs.settingsCheckbox}" checked> <label for="show-progress" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">显示下载进度</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="retry-on-fail" style="${StyleManager.getStyles().dialogs.settingsCheckbox}" checked> <label for="retry-on-fail" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">失败时自动重试</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="verify-downloads" style="${StyleManager.getStyles().dialogs.settingsCheckbox}" checked> <label for="verify-downloads" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">验证下载完整性</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="notify-complete" style="${StyleManager.getStyles().dialogs.settingsCheckbox}" checked> <label for="notify-complete" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">完成时通知</label> </div> <div style="${StyleManager.getStyles().dialogs.settingsCheckboxItem}"> <input type="checkbox" id="auto-compare" style="${StyleManager.getStyles().dialogs.settingsCheckbox}" checked> <label for="auto-compare" style="${StyleManager.getStyles().dialogs.settingsLabel}; margin-bottom: 0;">下载完成后自动对比本地</label> </div> </div> </div> </div> <!-- 过滤规则面板 --> <div id="settings-panel-filter" class="settings-panel" style="display: none;"> <div style="${StyleManager.getStyles().dialogs.settingsSection}"> <div style="display: flex; align-items: center; margin-bottom: 20px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--theme_primary, #0F7AF5)" stroke-width="2" style="margin-right: 12px;"> <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/> </svg> <div style="${StyleManager.getStyles().dialogs.settingsSectionTitle}; margin-bottom: 0;">文件过滤</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsRow}"> <div> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">最小文件大小 (KB)</label> <input type="number" id="min-file-size" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="0" min="0"> </div> <div> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">最大文件大小 (MB)</label> <input type="number" id="max-file-size" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="无限制" min="0"> </div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">允许的文件类型</label> <input type="text" id="allowed-types" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="jpg,png,pdf,doc,zip (留空表示允许所有类型)"> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">用逗号分隔多个文件扩展名,留空表示允许所有类型</div> </div> <div style="${StyleManager.getStyles().dialogs.settingsItem}"> <label style="${StyleManager.getStyles().dialogs.settingsLabel}">排除的文件类型</label> <input type="text" id="excluded-types" style="${StyleManager.getStyles().dialogs.settingsInput}" placeholder="tmp,log,cache"> <div style="${StyleManager.getStyles().dialogs.settingsDescription}">用逗号分隔多个文件扩展名</div> </div> </div> </div> </div> <!-- 按钮组 --> <div style="${StyleManager.getStyles().dialogs.settingsButtonGroup}"> <button id="settings-reset" style="${StyleManager.getStyles().texts.actionButtonSecondary}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 6px;"> <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> </svg> 重置默认 </button> <button id="settings-save" style="${StyleManager.getStyles().texts.actionButton}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 6px;"> <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> <polyline points="17 21 17 13 7 13 7 21"/> <polyline points="7 3 7 8 15 8"/> </svg> 保存设置 </button> </div> </div> `, events: { click: (e) => { if (e.target === dialog) { document.body.removeChild(dialog); } else if (e.target.id === 'settings-close-x' || e.target.closest('#settings-close-x')) { // 更新文件夹结构预览(如果当前有附件数据) if (this.attachments && this.attachments.length > 0) { this.updateFolderStructurePreview(this.attachments); } document.body.removeChild(dialog); } else if (e.target.id === 'settings-save' || e.target.closest('#settings-save')) { this.saveSettings(dialog); document.body.removeChild(dialog); } else if (e.target.id === 'settings-reset' || e.target.closest('#settings-reset')) { this.resetSettings(dialog); } else if (e.target.classList.contains('settings-tab')) { this.switchSettingsTab(e.target.dataset.tab); } } } }); document.body.appendChild(dialog); // 添加事件监听器(避免CSP限制) const namingModeSelect = dialog.querySelector('#naming-mode'); const fallbackPatternSelect = dialog.querySelector('#fallback-pattern'); const showVariablesBtn = dialog.querySelector('#show-variables-btn'); const showFallbackVariablesBtn = dialog.querySelector('#show-fallback-variables-btn'); const filenameValidationSelect = dialog.querySelector('#filename-validation'); if (namingModeSelect) { namingModeSelect.addEventListener('change', () => { if (window.toggleNamingOptions) { window.toggleNamingOptions(); } }); } if (fallbackPatternSelect) { fallbackPatternSelect.addEventListener('change', () => { if (window.toggleFallbackTemplate) { window.toggleFallbackTemplate(); } }); } if (showVariablesBtn) { showVariablesBtn.addEventListener('click', () => { this.showVariableSelector('naming-pattern'); }); } if (showFallbackVariablesBtn) { showFallbackVariablesBtn.addEventListener('click', () => { this.showVariableSelector('fallback-template'); }); } if (filenameValidationSelect) { filenameValidationSelect.addEventListener('change', () => { if (window.toggleFilenameValidationOptions) { window.toggleFilenameValidationOptions(); } }); } const charHandlingSelect = dialog.querySelector('#invalid-char-handling'); if (charHandlingSelect) { charHandlingSelect.addEventListener('change', () => { if (window.toggleCharHandlingInput) { window.toggleCharHandlingInput(); } }); } const enableContentReplacementCheckbox = dialog.querySelector('#enable-content-replacement'); if (enableContentReplacementCheckbox) { enableContentReplacementCheckbox.addEventListener('change', () => { if (window.toggleContentReplacementOptions) { window.toggleContentReplacementOptions(); } }); } const showReplacementVariablesBtn = dialog.querySelector('#show-replacement-variables-btn'); if (showReplacementVariablesBtn) { showReplacementVariablesBtn.addEventListener('click', () => { this.showVariableSelector('replacement-replace'); }); } // 定义全局函数 window.toggleFallbackTemplate = () => { // 使用document.querySelector而不是dialog.querySelector const fallbackPattern = document.querySelector('#fallback-pattern').value; const templateContainer = document.querySelector('#fallback-template-container'); if (fallbackPattern === 'customTemplate') { templateContainer.style.display = 'block'; } else { templateContainer.style.display = 'none'; } }; window.toggleFilenameValidationOptions = () => { const filenameValidation = document.querySelector('#filename-validation'); const validationOptions = document.querySelector('#filename-validation-options'); if (filenameValidation && validationOptions) { if (filenameValidation.value === 'enabled') { validationOptions.style.display = 'block'; } else { validationOptions.style.display = 'none'; } } }; window.toggleCharHandlingInput = () => { const charHandling = document.querySelector('#invalid-char-handling'); const replacementInput = document.querySelector('#invalid-char-replacement'); if (charHandling && replacementInput) { if (charHandling.value === 'replace') { replacementInput.style.display = 'block'; replacementInput.disabled = false; } else { replacementInput.style.display = 'none'; replacementInput.disabled = true; } } }; window.toggleContentReplacementOptions = () => { const enableContentReplacement = document.querySelector('#enable-content-replacement'); const optionsContainer = document.querySelector('#content-replacement-options'); if (enableContentReplacement && optionsContainer) { if (enableContentReplacement.checked) { optionsContainer.style.display = 'block'; } else { optionsContainer.style.display = 'none'; } } }; window.toggleNamingOptions = () => { // 使用document.querySelector而不是dialog.querySelector const namingMode = document.querySelector('#naming-mode'); const customOptions = document.querySelector('#custom-naming-options'); if (namingMode && customOptions) { const mode = namingMode.value; // 记录修改前的状态 const beforeStyle = window.getComputedStyle(customOptions); if (mode === 'custom') { customOptions.style.display = 'block'; customOptions.style.visibility = 'visible'; customOptions.style.opacity = '1'; customOptions.style.height = 'auto'; customOptions.style.overflow = 'visible'; } else { customOptions.style.display = 'none'; customOptions.style.visibility = 'hidden'; customOptions.style.opacity = '0'; } // 强制重绘 customOptions.offsetHeight; // 验证结果 setTimeout(() => { const afterStyle = window.getComputedStyle(customOptions); if (mode === 'custom' && afterStyle.display === 'block') { } else if (mode === 'original' && afterStyle.display === 'none') { } else { console.error('❌ 状态验证失败!', { 期望模式: mode, 实际display: afterStyle.display, 期望display: mode === 'custom' ? 'block' : 'none' }); } }, 10); } else { console.error('❌ 错误:找不到元素', { namingMode: namingMode ? '找到' : '未找到', customOptions: customOptions ? '找到' : '未找到', allElements: document.querySelectorAll('[id]').length + '个带ID的元素' }); // 列出所有带ID的元素用于调试 const allIds = Array.from(document.querySelectorAll('[id]')).map(el => el.id); } }; // 加载当前设置 this.loadSettings(dialog); // 设置文件夹结构事件 this.setupFolderStructureEvents(dialog); // 添加自动保存功能 this.setupAutoSave(dialog); // 添加选项卡切换样式 this.addSettingsTabStyles(); } // 切换设置选项卡 switchSettingsTab(tabName) { // 切换选项卡激活状态 document.querySelectorAll('.settings-tab').forEach(tab => { if (tab.dataset.tab === tabName) { tab.classList.add('active'); tab.style.borderBottom = '2px solid var(--theme_primary, #0F7AF5)'; tab.style.color = 'var(--theme_primary, #0F7AF5)'; tab.style.fontWeight = '600'; } else { tab.classList.remove('active'); tab.style.borderBottom = 'none'; tab.style.color = 'var(--base_gray_070, rgba(22, 30, 38, 0.7))'; tab.style.fontWeight = 'normal'; } }); // 切换面板显示 document.querySelectorAll('.settings-panel').forEach(panel => { panel.style.display = 'none'; }); const activePanel = document.getElementById(`settings-panel-${tabName}`); if (activePanel) { activePanel.style.display = 'block'; } } // 添加选项卡样式 addSettingsTabStyles() { if (document.getElementById('settings-tab-styles')) return; const style = document.createElement('style'); style.id = 'settings-tab-styles'; style.textContent = ` .settings-tab { position: relative; user-select: none; } .settings-tab:hover:not(.active) { color: var(--theme_primary, #0F7AF5) !important; background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); } .settings-tab:active { transform: translateY(1px); } .settings-tab svg { transition: transform 0.2s; } .settings-tab:hover svg { transform: scale(1.1); } #settings-close-x:hover { background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); color: var(--base_gray_100, #13181D); } .settings-panel { animation: fadeIn 0.3s ease-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } `; document.head.appendChild(style); } loadSettings(dialog) { const settings = this.downloadSettings; // 文件命名设置 const namingModeValue = settings.fileNaming.useCustomPattern ? 'custom' : 'original'; dialog.querySelector('#naming-mode').value = namingModeValue; dialog.querySelector('#naming-prefix').value = settings.fileNaming.prefix || ''; dialog.querySelector('#naming-suffix').value = settings.fileNaming.suffix || ''; dialog.querySelector('#naming-pattern').value = settings.fileNaming.customPattern || '{date}_{subject}_{fileName}'; // 文件名规范检查设置 dialog.querySelector('#filename-validation').value = settings.fileNaming.validation?.enabled !== false ? 'enabled' : 'disabled'; dialog.querySelector('#validation-pattern').value = settings.fileNaming.validation?.pattern || '\\d{6,}'; dialog.querySelector('#fallback-pattern').value = settings.fileNaming.validation?.fallbackPattern || 'auto'; dialog.querySelector('#fallback-template').value = settings.fileNaming.validation?.fallbackTemplate || '{subject}_{fileName}'; // 内容替换设置 const contentReplacement = settings.fileNaming.validation?.contentReplacement; dialog.querySelector('#enable-content-replacement').checked = contentReplacement?.enabled || false; dialog.querySelector('#replacement-mode').value = contentReplacement?.mode || 'string'; dialog.querySelector('#replacement-search').value = contentReplacement?.search || ''; dialog.querySelector('#replacement-replace').value = contentReplacement?.replace || ''; dialog.querySelector('#replacement-global').checked = contentReplacement?.global !== false; dialog.querySelector('#replacement-case-sensitive').checked = contentReplacement?.caseSensitive || false; // 文件名字符处理设置 const removeInvalidChars = settings.fileNaming.validation?.removeInvalidChars !== false; const replacementChar = settings.fileNaming.validation?.replacementChar || '_'; if (removeInvalidChars && replacementChar) { dialog.querySelector('#invalid-char-handling').value = 'replace'; } else if (removeInvalidChars && !replacementChar) { dialog.querySelector('#invalid-char-handling').value = 'remove'; } else { dialog.querySelector('#invalid-char-handling').value = 'keep'; } dialog.querySelector('#invalid-char-replacement').value = replacementChar; // 文件夹结构设置 dialog.querySelector('#folder-structure').value = settings.folderStructure || 'flat'; dialog.querySelector('#smart-grouping').checked = settings.smartGrouping?.enabled || false; // 自定义文件夹模板设置 const folderNaming = settings.folderNaming || {}; dialog.querySelector('#folder-custom-template').value = folderNaming.customTemplate || '{date:YYYY-MM}/{senderName}'; // 下载行为设置 dialog.querySelector('#concurrent-downloads').value = settings.downloadBehavior?.concurrentDownloads || 'auto'; dialog.querySelector('#conflict-resolution').value = settings.conflictResolution || 'rename'; dialog.querySelector('#show-progress').checked = settings.downloadBehavior?.showProgress !== false; dialog.querySelector('#retry-on-fail').checked = settings.downloadBehavior?.retryOnFail !== false; dialog.querySelector('#verify-downloads').checked = settings.downloadBehavior?.verifyDownloads !== false; dialog.querySelector('#notify-complete').checked = settings.downloadBehavior?.notifyOnComplete !== false; dialog.querySelector('#auto-compare').checked = settings.downloadBehavior?.autoCompareAfterDownload !== false; // 过滤设置 dialog.querySelector('#min-file-size').value = this.filters?.minSize || ''; dialog.querySelector('#max-file-size').value = this.filters?.maxSize ? this.filters.maxSize / (1024 * 1024) : ''; dialog.querySelector('#allowed-types').value = this.filters?.allowedTypes?.join(',') || ''; dialog.querySelector('#excluded-types').value = this.filters?.excludedTypes?.join(',') || ''; // 手动触发显示状态更新(因为程序设置value不会触发onchange事件) setTimeout(() => { if (window.toggleNamingOptions) { window.toggleNamingOptions(); } if (window.toggleFallbackTemplate) { window.toggleFallbackTemplate(); } if (window.toggleFilenameValidationOptions) { window.toggleFilenameValidationOptions(); } if (window.toggleCharHandlingInput) { window.toggleCharHandlingInput(); } if (window.toggleContentReplacementOptions) { window.toggleContentReplacementOptions(); } }, 50); } setupFolderStructureEvents(dialog) { const folderStructureSelect = dialog.querySelector('#folder-structure'); const folderNamingOptions = dialog.querySelector('#folder-naming-options'); if (!folderStructureSelect || !folderNamingOptions) return; // 显示/隐藏文件夹命名选项 const toggleFolderNamingOptions = () => { const selectedValue = folderStructureSelect.value; const shouldShow = selectedValue === 'custom'; folderNamingOptions.style.display = shouldShow ? 'block' : 'none'; }; // 初始化显示状态 toggleFolderNamingOptions(); // 监听选择器变化 folderStructureSelect.addEventListener('change', toggleFolderNamingOptions); // 为自定义文件夹模板按钮添加事件监听器 const customVariableBtn = dialog.querySelector('#show-folder-custom-variables-btn'); if (customVariableBtn) { customVariableBtn.addEventListener('click', () => { this.showVariableSelector('folder-custom-template'); }); } } setupAutoSave(dialog) { // 获取所有需要监听的表单元素 const formElements = dialog.querySelectorAll('input, select, textarea'); // 为每个表单元素添加自动保存监听器 formElements.forEach(element => { const eventType = element.type === 'checkbox' ? 'change' : element.tagName === 'SELECT' ? 'change' : 'input'; element.addEventListener(eventType, () => { // 延迟保存,避免频繁保存 clearTimeout(this.autoSaveTimeout); this.autoSaveTimeout = setTimeout(() => { this.saveSettings(dialog); this.showToast('设置已自动保存', 'success', 1500); }, 500); }); }); } showVariableSelector(targetInputId) { // 定义可用变量,按类别分组 const variableGroups = [ { title: '✉️ 邮件信息', variables: [ { key: '{subject}', name: '邮件主题', description: '邮件的主题内容', example: '重要文件' }, { key: '{sender}', name: '发件人', description: '发件人姓名', example: '张三' }, { key: '{senderEmail}', name: '发件人邮箱', description: '发件人邮箱地址', example: '[email protected]' }, { key: '{senderName}', name: '发件人名称', description: '发件人显示名称', example: '张三' }, { key: '{mailIndex}', name: '邮件编号', description: '邮件在文件夹中的编号(根据总数动态补零,最少2位)', example: '0001' }, { key: '{mailId}', name: '邮件ID', description: '邮件唯一标识符', example: 'ZL0012_wnrNBwSMZGQu72gAZv4Zkf6' } ] }, { title: '📁 文件夹信息', variables: [ { key: '{folderName}', name: '文件夹名称', description: '当前文件夹的名称', example: '我的文件夹' }, { key: '{folderID}', name: '文件夹ID', description: '当前文件夹的ID', example: '2001' } ] }, { title: '📎 附件信息', variables: [ { key: '{fileName}', name: '完整文件名', description: '附件的原始文件名', example: '报告.pdf' }, { key: '{fileNameNoExt}', name: '文件名', description: '不含扩展名的文件名', example: '报告' }, { key: '{fileType}', name: '文件扩展名', description: '文件的扩展名', example: 'pdf' }, { key: '{size}', name: '文件大小', description: '文件大小 (字节)', example: '1024' }, { key: '{fileIndex}', name: '附件编号', description: '附件在文件夹中的编号(根据总数动态补零,最少2位)', example: '0001' }, { key: '{attachIndex}', name: '邮件内序号', description: '附件在本邮件中的序号(根据总数动态补零,最少2位)', example: '01' }, { key: '{fileId}', name: '附件ID', description: '附件的唯一标识符', example: 'ZF0012_wnrNBwSMZGQu72gAYvkZSf6' } ] }, { title: '📅 常用日期时间', variables: [ { key: '{date}', name: '标准日期', description: '邮件日期 (YYYY-MM-DD)', example: '2024-01-15' }, { key: '{time}', name: '标准时间', description: '邮件时间 (HH-mm-ss)', example: '14-30-25' }, { key: '{datetime}', name: '日期时间', description: '完整日期时间', example: '2024-01-15_14-30-25' }, { key: '{timestamp}', name: '时间戳', description: 'Unix时间戳 (秒)', example: '1705312225' }, { key: '{year}', name: '年份', description: '四位年份 (2024)', example: '2024' }, { key: '{month}', name: '月份', description: '两位月份 (01-12)', example: '01' }, { key: '{day}', name: '日期', description: '两位日期 (01-31)', example: '15' } ] }, { title: '🔧 自定义日期格式', variables: [ { key: '{date:YYYY-MM-DD}', name: '完整日期', description: '年-月-日 (2024-01-15)', example: '2024-01-15' }, { key: '{date:YYYY-MM}', name: '年月', description: '年-月 (2024-01)', example: '2024-01' }, { key: '{date:MM-DD}', name: '月日', description: '月-日 (01-15)', example: '01-15' }, { key: '{date:YYYY}', name: '年份', description: '四位年份 (2024)', example: '2024' }, { key: '{date:MM}', name: '月份', description: '两位月份 (01)', example: '01' }, { key: '{date:DD}', name: '日期', description: '两位日期 (15)', example: '15' }, { key: '{date:HH-mm-ss}', name: '完整时间', description: '时-分-秒 (14-30-25)', example: '14-30-25' }, { key: '{date:HH-mm}', name: '时分', description: '时-分 (14-30)', example: '14-30' }, { key: '{date:HH}', name: '24小时制', description: '24小时制 (14)', example: '14' }, { key: '{date:hh}', name: '12小时制', description: '12小时制 (02)', example: '02' }, { key: '{date:mm}', name: '分钟', description: '分钟 (30)', example: '30' }, { key: '{date:ss}', name: '秒数', description: '秒数 (25)', example: '25' } ] }, { title: '🌐 中文时间', variables: [ { key: '{weekday}', name: '星期', description: '中文星期 (星期一)', example: '星期一' }, { key: '{weekdayName}', name: '周', description: '简写星期 (周一)', example: '周一' }, { key: '{monthName}', name: '月份', description: '中文月份 (一月)', example: '一月' }, { key: '{ampm}', name: '上下午', description: '中文上下午', example: '下午' }, { key: '{AMPM}', name: 'AM/PM', description: '英文AM/PM', example: 'PM' } ] } ]; // 创建弹出层 const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); z-index: 10001; display: flex; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; `; const popup = document.createElement('div'); popup.style.cssText = ` background: white; border-radius: 16px; width: 90%; max-width: 1000px; max-height: 100vh; box-shadow: 0 12px 48px rgba(0,0,0,0.25); position: relative; animation: slideIn 0.3s ease-out; display: flex; flex-direction: column; overflow: hidden; `; // 确保变量选择器样式已添加 this.ensureVariableSelectorStyles(); popup.innerHTML = ` <!-- 弹窗头部 --> <div class="popup-header"> <div style="display: flex; justify-content: space-between; align-items: center;"> <div> <div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 2px;">变量选择器</div> <div style="color: #666; font-size: 13px;">点击变量插入到模板中</div> </div> <button id="close-variable-selector" style="background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; width: 28px; height: 28px; cursor: pointer; color: #666; font-size: 16px; display: flex; align-items: center; justify-content: center;">×</button> </div> </div> <!-- 模板编辑区域 --> <div class="template-section"> <label style="display: block; font-size: 14px; font-weight: 500; color: #333; margin-bottom: 8px;">模板编辑</label> <div style="position: relative;"> <input type="text" id="template-preview" style="width: 100%; padding: 10px 80px 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; font-family: monospace; background: white; box-sizing: border-box; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"> <button id="apply-template" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: #007bff; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; cursor: pointer; font-weight: 500;">应用</button> </div> <div style="margin-top: 8px; font-size: 12px; color: #666;"> <span style="color: #999;">预览:</span> <span id="template-preview-example" style="font-family: monospace; color: #333; background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">请输入模板</span> </div> <div id="filename-length-warning" style="margin-top: 4px; font-size: 11px; display: none;"></div> </div> <!-- 变量选择区域 --> <div class="variables-section"> <div style="margin-bottom: 12px;"> <div style="font-size: 14px; font-weight: 500; color: #333; margin-bottom: 4px;">可用变量</div> <div style="font-size: 12px; color: #666;">点击任意变量插入到模板中,模板不包含最终文件扩展名</div> </div> <div id="variables-container"> ${variableGroups.map(group => ` <div class="variable-group"> <div class="variable-group-title">${group.title}</div> <div class="variables-grid"> ${group.variables.map(variable => ` <div class="variable-item" data-variable="${variable.key}"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 3px;"> <span class="variable-key">${variable.key}</span> <span class="variable-name">${variable.name}</span> </div> <div class="variable-desc">${variable.description}</div> </div> `).join('')} </div> </div> `).join('')} </div> </div> <!-- 快捷操作区域 --> <div class="popup-footer"> <div style="display: flex; justify-content: center; gap: 8px; flex-wrap: wrap;"> <button id="clear-template" style="padding: 8px 16px; border: 1px solid #ccc; background: white; border-radius: 4px; cursor: pointer; font-size: 12px; color: #666; font-weight: 500;">清空模板</button> <button id="insert-common-pattern" style="padding: 8px 16px; border: 1px solid #007bff; background: #007bff; color: white; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;">常用模式</button> <button id="insert-detailed-pattern" style="padding: 8px 16px; border: 1px solid #28a745; background: #28a745; color: white; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;">详细模式</button> </div> </div> `; overlay.appendChild(popup); document.body.appendChild(overlay); // 添加事件监听器 const targetInput = document.querySelector(`#${targetInputId}`); const templatePreview = popup.querySelector('#template-preview'); const previewExample = popup.querySelector('#template-preview-example'); // 更新预览示例 const updatePreviewExample = () => { if (previewExample && templatePreview) { const template = templatePreview.value; if (!template || template.trim() === '') { previewExample.textContent = '请输入模板'; previewExample.title = ''; return; } // 从变量定义中提取示例数据 const sampleData = {}; variableGroups.forEach(group => { group.variables.forEach(variable => { sampleData[variable.key] = variable.example; }); }); // 为预览创建一个简化的替换函数 let example = template; Object.keys(sampleData).forEach(key => { const value = sampleData[key]; if (value !== undefined && value !== null) { // 使用全局替换,确保所有实例都被替换 example = example.replaceAll(key, String(value)); } }); // 只在自定义文件名模式下添加扩展名示例 if (targetInputId === 'naming-pattern') { // 检查最后几个字符是否已经包含扩展名,避免重复添加 const lastPart = example.slice(-10); if (!lastPart.includes('.')) { example += '.pdf'; } } previewExample.textContent = example; previewExample.title = ''; // 检查文件名长度并显示警告 const warningElement = popup.querySelector('#filename-length-warning'); if (warningElement) { const nameLength = example.length; if (nameLength > 255) { warningElement.style.display = 'block'; warningElement.style.color = '#F73116'; warningElement.innerHTML = '⚠️ 已超过系统文件名最大长度! (' + nameLength + ' 字符)' } else if (nameLength > 140) { warningElement.style.display = 'block'; warningElement.style.color = '#F7A316'; warningElement.innerHTML = '⚠️ 文件名太长 (' + nameLength + ' 字符),可能会导致保存失败'; } else if (nameLength > 80) { warningElement.style.display = 'block'; warningElement.style.color = '#F7A316'; warningElement.innerHTML = '⚠️ 文件名较长 (' + nameLength + ' 字符),建议简化模板'; } else if (nameLength > 50) { warningElement.style.display = 'block'; warningElement.style.color = '#3498db'; warningElement.innerHTML = 'ℹ️ 文件名长度:' + nameLength + ' 字符'; } else { warningElement.style.display = 'none'; } } } }; // 初始化模板预览 if (targetInput && templatePreview) { templatePreview.value = targetInput.value; updatePreviewExample(); } // 监听模板输入变化 templatePreview.addEventListener('input', updatePreviewExample); // 变量点击事件 popup.querySelectorAll('.variable-item').forEach(item => { item.addEventListener('click', () => { const variable = item.dataset.variable; if (templatePreview) { // 在模板预览中插入变量 const cursorPos = templatePreview.selectionStart || templatePreview.value.length; const currentValue = templatePreview.value; const newValue = currentValue.slice(0, cursorPos) + variable + currentValue.slice(cursorPos); templatePreview.value = newValue; // 设置新的光标位置 setTimeout(() => { templatePreview.focus(); templatePreview.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length); }, 10); this.showToast(`已插入: ${variable}`, 'success', 1000); updatePreviewExample(); // 更新预览 } }); }); // 应用模板按钮 popup.querySelector('#apply-template').addEventListener('click', () => { if (targetInput && templatePreview) { targetInput.value = templatePreview.value; targetInput.focus(); this.showToast('模板已应用', 'success', 1500); document.body.removeChild(overlay); } }); // 让模板预览可编辑 templatePreview.removeAttribute('readonly'); // 关闭按钮 popup.querySelector('#close-variable-selector').addEventListener('click', () => { document.body.removeChild(overlay); }); // 清空模板按钮 popup.querySelector('#clear-template').addEventListener('click', () => { if (templatePreview) { templatePreview.value = ''; templatePreview.focus(); updatePreviewExample(); this.showToast('模板已清空', 'info', 1000); } }); // 插入常用模式按钮 popup.querySelector('#insert-common-pattern').addEventListener('click', () => { if (templatePreview) { templatePreview.value = '{date:YYYY-MM-DD}_{subject}_{fileName}'; templatePreview.focus(); updatePreviewExample(); this.showToast('已插入常用模式', 'success', 1000); } }); // 插入详细模式按钮 popup.querySelector('#insert-detailed-pattern').addEventListener('click', () => { if (templatePreview) { templatePreview.value = '{date:YYYY-MM-DD}_{date:HH-mm}_{senderName}_{subject}_{fileIndex}_{fileName}'; templatePreview.focus(); updatePreviewExample(); this.showToast('已插入详细模式', 'success', 1000); } }); // 点击背景关闭 overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); } }); // ESC键关闭 const handleEsc = (e) => { if (e.key === 'Escape') { document.body.removeChild(overlay); document.removeEventListener('keydown', handleEsc); } }; document.addEventListener('keydown', handleEsc); } saveSettings(dialog) { try { // 基础设置 - 文件命名 const namingMode = dialog.querySelector('#naming-mode').value; this.downloadSettings.fileNaming.useCustomPattern = (namingMode === 'custom'); this.downloadSettings.fileNaming.prefix = dialog.querySelector('#naming-prefix').value; this.downloadSettings.fileNaming.suffix = dialog.querySelector('#naming-suffix').value; this.downloadSettings.fileNaming.customPattern = dialog.querySelector('#naming-pattern').value; // 基础设置 - 文件夹结构 this.downloadSettings.folderStructure = dialog.querySelector('#folder-structure').value; if (!this.downloadSettings.smartGrouping) this.downloadSettings.smartGrouping = {}; this.downloadSettings.smartGrouping.enabled = dialog.querySelector('#smart-grouping').checked; // 保存自定义文件夹模板设置 if (!this.downloadSettings.folderNaming) this.downloadSettings.folderNaming = {}; this.downloadSettings.folderNaming.customTemplate = dialog.querySelector('#folder-custom-template').value || '{date:YYYY-MM}/{senderName}'; // 文件名规范检查设置 if (!this.downloadSettings.fileNaming.validation) this.downloadSettings.fileNaming.validation = {}; this.downloadSettings.fileNaming.validation.enabled = dialog.querySelector('#filename-validation').value === 'enabled'; this.downloadSettings.fileNaming.validation.pattern = dialog.querySelector('#validation-pattern').value || '\\d{6,}'; this.downloadSettings.fileNaming.validation.fallbackPattern = dialog.querySelector('#fallback-pattern').value || 'auto'; this.downloadSettings.fileNaming.validation.fallbackTemplate = dialog.querySelector('#fallback-template').value || '{subject}_{fileName}'; // 内容替换设置 if (!this.downloadSettings.fileNaming.validation.contentReplacement) { this.downloadSettings.fileNaming.validation.contentReplacement = {}; } this.downloadSettings.fileNaming.validation.contentReplacement.enabled = dialog.querySelector('#enable-content-replacement').checked; this.downloadSettings.fileNaming.validation.contentReplacement.mode = dialog.querySelector('#replacement-mode').value || 'string'; this.downloadSettings.fileNaming.validation.contentReplacement.search = dialog.querySelector('#replacement-search').value || ''; this.downloadSettings.fileNaming.validation.contentReplacement.replace = dialog.querySelector('#replacement-replace').value || ''; this.downloadSettings.fileNaming.validation.contentReplacement.global = dialog.querySelector('#replacement-global').checked; this.downloadSettings.fileNaming.validation.contentReplacement.caseSensitive = dialog.querySelector('#replacement-case-sensitive').checked; // 文件名字符处理设置 const charHandling = dialog.querySelector('#invalid-char-handling').value; const replacementChar = dialog.querySelector('#invalid-char-replacement').value || '_'; switch (charHandling) { case 'replace': this.downloadSettings.fileNaming.validation.removeInvalidChars = true; this.downloadSettings.fileNaming.validation.replacementChar = replacementChar; break; case 'remove': this.downloadSettings.fileNaming.validation.removeInvalidChars = true; this.downloadSettings.fileNaming.validation.replacementChar = ''; break; case 'keep': default: this.downloadSettings.fileNaming.validation.removeInvalidChars = false; this.downloadSettings.fileNaming.validation.replacementChar = ''; break; } // 下载行为设置 if (!this.downloadSettings.downloadBehavior) this.downloadSettings.downloadBehavior = {}; const concurrentValue = dialog.querySelector('#concurrent-downloads').value; this.downloadSettings.downloadBehavior.concurrentDownloads = concurrentValue === 'auto' ? 'auto' : parseInt(concurrentValue); this.downloadSettings.conflictResolution = dialog.querySelector('#conflict-resolution').value; this.downloadSettings.downloadBehavior.showProgress = dialog.querySelector('#show-progress').checked; this.downloadSettings.downloadBehavior.retryOnFail = dialog.querySelector('#retry-on-fail').checked; this.downloadSettings.downloadBehavior.verifyDownloads = dialog.querySelector('#verify-downloads').checked; this.downloadSettings.downloadBehavior.notifyOnComplete = dialog.querySelector('#notify-complete').checked; this.downloadSettings.downloadBehavior.autoCompareAfterDownload = dialog.querySelector('#auto-compare').checked; // 过滤设置 const minSize = parseInt(dialog.querySelector('#min-file-size').value) || 0; const maxSize = parseInt(dialog.querySelector('#max-file-size').value) || 0; const allowedTypes = dialog.querySelector('#allowed-types').value.split(',').map(t => t.trim()).filter(t => t); const excludedTypes = dialog.querySelector('#excluded-types').value.split(',').map(t => t.trim()).filter(t => t); this.filters.minSize = minSize * 1024; // 转换为字节 this.filters.maxSize = maxSize > 0 ? maxSize * 1024 * 1024 : 0; // 转换为字节 this.filters.allowedTypes = allowedTypes; this.filters.excludedTypes = excludedTypes; // 保存到本地存储 localStorage.setItem('qqmail-downloader-settings', JSON.stringify({ downloadSettings: this.downloadSettings, filters: this.filters })); this.showToast('设置已保存', 'success'); // 更新文件夹结构预览(如果当前有附件数据) if (this.attachments && this.attachments.length > 0) { this.updateFolderStructurePreview(this.attachments); } } catch (error) { console.error('保存设置失败:', error); this.showToast('保存设置失败', 'error'); } } resetSettings(dialog) { // 重置为默认设置 this.downloadSettings = { fileNaming: { prefix: '', suffix: '', includeMailId: false, includeAttachmentId: false, includeMailSubject: false, includeFileType: false, separator: '_', useCustomPattern: false, customPattern: '{date}_{subject}_{fileName}', validation: { enabled: true, pattern: '\\d{6,}', fallbackPattern: 'auto', fallbackTemplate: '{subject}_{fileName}', replacementChar: '_', removeInvalidChars: true, contentReplacement: { enabled: false, mode: 'string', search: '', replace: '', global: true, caseSensitive: false } } }, folderStructure: 'flat', folderNaming: { mailTemplate: '{subject}', dateTemplate: '{date:YYYY-MM-DD}', senderTemplate: '{senderName}', customTemplate: '{date:YYYY-MM}/{senderName}' }, conflictResolution: 'rename', downloadBehavior: { showProgress: true, retryOnFail: true, verifyDownloads: true, notifyOnComplete: true, concurrentDownloads: 'auto', autoCompareAfterDownload: true }, smartGrouping: { enabled: false } }; this.filters = { date: 'all', dateRange: { start: null, end: null }, minSize: 0, maxSize: 0, allowedTypes: [], excludedTypes: [] }; // 重新加载设置到界面 this.loadSettings(dialog); // 自动保存重置后的设置 this.saveSettings(dialog); this.showToast('设置已重置为默认值', 'info'); } // 判断是否应该使用自定义命名 shouldUseCustomNaming() { return this.downloadSettings.fileNaming.useCustomPattern || false; } // 在初始化时加载保存的设置 loadSavedSettings() { try { const saved = localStorage.getItem('qqmail-downloader-settings'); if (saved) { const settings = JSON.parse(saved); if (settings.downloadSettings) { this.downloadSettings = { ...this.downloadSettings, ...settings.downloadSettings }; } if (settings.filters) { this.filters = { ...this.filters, ...settings.filters }; } } } catch (error) { console.warn('加载保存的设置失败:', error); } } async showCompareDialog() { try { // 检查邮件附件数据 const emailAttachments = this.attachments; if (emailAttachments.length === 0) { this.showToast('当前文件夹没有邮件附件数据,请确保已打开附件管理器并加载了邮件数据', 'warning'); return; } // 直接弹出文件夹选择器 const dirHandle = await window.showDirectoryPicker(); // 创建比对结果对话框 await this.showComparisonResults(dirHandle); } catch (error) { if (error.name !== 'AbortError') { this.showToast('比对功能执行失败: ' + error.message, 'error'); } } } // 显示比对结果对话框 async showComparisonResults(dirHandle) { // 创建比对结果对话框 const dialog = this.createUI('div', { className: 'compare-results-dialog', styles: { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0, 0, 0, 0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: '100000' } }); const dialogContent = this.createUI('div', { styles: { backgroundColor: 'white', borderRadius: '12px', padding: '24px', maxWidth: '900px', width: '95%', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0, 0, 0, 0.15)' } }); dialogContent.innerHTML = ` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <h3 style="margin: 0; font-size: 18px; color: #13181D;">文件完整性比对结果</h3> <button class="close-dialog-btn" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">×</button> </div> <div id="compare-results" style="display: none;"> <div id="compare-summary" style="background: #f8f9fa; padding: 16px; border-radius: 6px; margin-bottom: 16px;"> <div style="text-align: center; padding: 20px;">正在比对文件...</div> </div> <div id="missing-files" style="margin-bottom: 16px;"></div> <div id="duplicate-files" style="margin-bottom: 16px;"></div> <div id="matched-files" style="margin-bottom: 16px;"></div> </div> `; dialog.appendChild(dialogContent); document.body.appendChild(dialog); const closeDialog = () => document.body.removeChild(dialog); // 添加事件监听器 dialogContent.querySelector('.close-dialog-btn').addEventListener('click', closeDialog); dialog.addEventListener('click', (e) => e.target === dialog && closeDialog()); // 添加下载按钮事件监听器 dialog.addEventListener('click', (e) => { if (e.target.classList.contains('download-single-btn')) { const fileid = e.target.dataset.fileid; if (fileid) { this.downloadSingleAttachment(fileid); } } }); // 执行比对 await this.performComparison(dirHandle, dialogContent); } // 执行比对 async performComparison(dirHandle, dialogContent) { const resultsDiv = dialogContent.querySelector('#compare-results'); const summaryDiv = dialogContent.querySelector('#compare-summary'); const missingDiv = dialogContent.querySelector('#missing-files'); const duplicateDiv = dialogContent.querySelector('#duplicate-files'); const matchedDiv = dialogContent.querySelector('#matched-files'); // 创建进度显示界面 const createProgressUI = () => { return ` <div style="text-align: center; padding: 20px;"> <div style="margin-bottom: 16px;"> <div id="progress-title" style="font-size: 16px; font-weight: 500; color: #13181D; margin-bottom: 8px;">正在比对文件...</div> <div id="progress-subtitle" style="font-size: 14px; color: #666; margin-bottom: 12px;">准备中...</div> </div> <div style="background: #f5f5f5; border-radius: 8px; padding: 4px; margin-bottom: 12px;"> <div id="progress-bar" style="height: 8px; background: linear-gradient(90deg, #0F7AF5, #40a9ff); border-radius: 4px; width: 0%; transition: width 0.3s ease;"></div> </div> <div id="progress-details" style="font-size: 12px; color: #999; display: flex; justify-content: space-between;"> <span id="progress-current">0</span> <span id="progress-total">0</span> </div> </div> `; }; // 更新进度显示 const updateProgress = (title, subtitle, current, total) => { const progressTitle = summaryDiv.querySelector('#progress-title'); const progressSubtitle = summaryDiv.querySelector('#progress-subtitle'); const progressBar = summaryDiv.querySelector('#progress-bar'); const progressCurrent = summaryDiv.querySelector('#progress-current'); const progressTotal = summaryDiv.querySelector('#progress-total'); if (progressTitle) progressTitle.textContent = title; if (progressSubtitle) progressSubtitle.textContent = subtitle; if (progressBar) progressBar.style.width = total > 0 ? `${(current / total) * 100}%` : '0%'; if (progressCurrent) progressCurrent.textContent = current; if (progressTotal) progressTotal.textContent = total; }; // 显示初始进度界面 summaryDiv.innerHTML = createProgressUI(); resultsDiv.style.display = 'block'; try { // 步骤1: 获取本地文件信息 updateProgress('正在扫描本地文件', '读取文件夹结构...', 0, 4); await new Promise(resolve => setTimeout(resolve, 100)); // 让UI更新 console.log('开始扫描本地文件...'); const localFiles = await this.getLocalFilesWithProgress(dirHandle, updateProgress); console.log(`扫描完成,找到 ${localFiles.length} 个本地文件`); // 步骤2: 获取邮件附件信息 const emailAttachments = this.attachments; console.log(`获取邮件附件信息,共 ${emailAttachments.length} 个附件`); updateProgress('准备比对数据', `本地文件: ${localFiles.length} 个,邮件附件: ${emailAttachments.length} 个`, 2, 4); await new Promise(resolve => setTimeout(resolve, 200)); if (emailAttachments.length === 0) { throw new Error('当前邮件文件夹中没有附件数据,请确保已打开附件管理器并加载了邮件数据'); } // 步骤3: 执行比对分析 console.log('开始执行比对分析...'); const comparisonResult = await this.compareFilesWithProgress(localFiles, emailAttachments, updateProgress); console.log('比对分析完成:', comparisonResult.summary); // 步骤4: 显示比对结果 updateProgress('生成比对报告', '正在整理结果...', 4, 4); await new Promise(resolve => setTimeout(resolve, 100)); this.displayComparisonResults(comparisonResult, summaryDiv, missingDiv, duplicateDiv, matchedDiv); // 添加事件监听器 this.setupComparisonDialogEvents(dialogContent); } catch (error) { console.error('比对过程中出错:', error); summaryDiv.innerHTML = `<div style="color: #e74c3c; text-align: center; padding: 20px;"> <div style="font-size: 16px; font-weight: 500; margin-bottom: 8px;">比对失败</div> <div style="font-size: 14px; margin-bottom: 12px;">${error.message}</div> <div style="font-size: 12px; color: #999;"> 请检查:<br> 1. 是否已选择正确的文件夹<br> 2. 文件夹是否有访问权限<br> 3. 浏览器控制台是否有错误信息 </div> </div>`; } } // 获取本地文件信息 async getLocalFiles(dirHandle, path = '') { const files = []; for await (const [name, handle] of dirHandle.entries()) { const fullPath = path ? `${path}/${name}` : name; if (handle.kind === 'file') { try { const file = await handle.getFile(); files.push({ name: name, path: fullPath, size: file.size, type: this.processFileName(name, 'getExtension')?.toLowerCase() || '', lastModified: file.lastModified, handle: handle }); } catch (error) { // 静默忽略文件读取错误 } } else if (handle.kind === 'directory') { // 递归读取子文件夹 const subFiles = await this.getLocalFiles(handle, fullPath); files.push(...subFiles); } } return files; } // 带进度显示的获取本地文件信息 async getLocalFilesWithProgress(dirHandle, updateProgress, path = '') { const files = []; let processedCount = 0; let totalCount = 0; const visitedPaths = new Set(); // 防止循环引用 const maxDepth = 10; // 最大递归深度 const maxFiles = 10000; // 最大文件数限制 const startTime = Date.now(); const maxTime = 30000; // 30秒超时 try { // 首先统计总文件数(带限制) const countFiles = async (handle, currentPath = '', depth = 0) => { // 检查超时 if (Date.now() - startTime > maxTime) { throw new Error('文件扫描超时,请选择文件数量较少的文件夹'); } // 检查深度限制 if (depth > maxDepth) { console.warn(`文件夹层级过深,跳过: ${currentPath}`); return 0; } // 检查路径循环 const normalizedPath = currentPath.toLowerCase(); if (visitedPaths.has(normalizedPath)) { console.warn(`检测到循环引用,跳过: ${currentPath}`); return 0; } visitedPaths.add(normalizedPath); let count = 0; try { const entries = []; for await (const [name, subHandle] of handle.entries()) { entries.push([name, subHandle]); // 限制单个文件夹的条目数 if (entries.length > 1000) { console.warn(`文件夹条目过多,仅处理前1000个: ${currentPath}`); break; } } for (const [name, subHandle] of entries) { if (count > maxFiles) { console.warn(`文件数量超过限制(${maxFiles}),停止扫描`); break; } if (subHandle.kind === 'file') { count++; } else if (subHandle.kind === 'directory') { try { const subPath = currentPath ? `${currentPath}/${name}` : name; count += await countFiles(subHandle, subPath, depth + 1); } catch (error) { console.warn(`无法访问子文件夹: ${currentPath}/${name}`, error); } } } } catch (error) { console.warn(`无法读取文件夹内容: ${currentPath}`, error); } visitedPaths.delete(normalizedPath); return count; }; updateProgress('正在扫描本地文件', '统计文件数量...', 0, 1); console.log('开始统计文件数量...'); totalCount = await countFiles(dirHandle); console.log(`统计完成,发现 ${totalCount} 个文件`); if (totalCount === 0) { updateProgress('扫描完成', '未找到任何文件', 1, 1); return files; } if (totalCount > maxFiles) { throw new Error(`文件数量过多(${totalCount}),请选择包含文件较少的文件夹(建议少于${maxFiles}个)`); } updateProgress('正在扫描本地文件', `发现 ${totalCount} 个文件,开始处理...`, 0, totalCount); // 重置访问路径记录 visitedPaths.clear(); // 递归处理文件(带限制) const processFiles = async (handle, currentPath = '', depth = 0) => { // 检查超时 if (Date.now() - startTime > maxTime) { throw new Error('文件处理超时'); } // 检查深度限制 if (depth > maxDepth) { return; } // 检查路径循环 const normalizedPath = currentPath.toLowerCase(); if (visitedPaths.has(normalizedPath)) { return; } visitedPaths.add(normalizedPath); try { const entries = []; for await (const [name, subHandle] of handle.entries()) { entries.push([name, subHandle]); if (entries.length > 1000) break; } for (const [name, subHandle] of entries) { if (files.length >= maxFiles) { console.warn(`已达到文件数量限制(${maxFiles}),停止处理`); break; } const fullPath = currentPath ? `${currentPath}/${name}` : name; if (subHandle.kind === 'file') { try { const file = await subHandle.getFile(); files.push({ name: name, path: fullPath, size: file.size, type: this.processFileName(name, 'getExtension')?.toLowerCase() || '', lastModified: file.lastModified, handle: subHandle }); processedCount++; // 更新进度 if (processedCount % 50 === 0 || processedCount === totalCount) { updateProgress('正在扫描本地文件', `已处理 ${processedCount}/${totalCount} 个文件`, processedCount, totalCount); await new Promise(resolve => setTimeout(resolve, 10)); // 让UI更新 } } catch (error) { console.warn(`无法读取文件: ${fullPath}`, error); processedCount++; } } else if (subHandle.kind === 'directory') { try { await processFiles(subHandle, fullPath, depth + 1); } catch (error) { console.warn(`无法处理子文件夹: ${fullPath}`, error); } } } } catch (error) { console.warn(`处理文件夹时出错: ${currentPath}`, error); } visitedPaths.delete(normalizedPath); }; await processFiles(dirHandle, path); updateProgress('扫描完成', `成功处理 ${files.length} 个文件`, files.length, totalCount); console.log(`文件扫描完成,用时: ${Date.now() - startTime}ms`); } catch (error) { console.error('扫描本地文件时出错:', error); throw new Error(`扫描本地文件失败: ${error.message}`); } return files; } // 比对文件 // 优化的文件比对算法(减少重复计算和内存占用) compareFiles(localFiles, emailAttachments) { const result = { missing: [], duplicates: [], matched: [], localOnly: [], summary: { totalEmail: emailAttachments.length, totalLocal: localFiles.length, missingCount: 0, duplicateCount: 0, matchedCount: 0, emailTotalSize: 0, localTotalSize: 0, matchedTotalSize: 0, missingTotalSize: 0 } }; // 预处理本地文件映射(合并两个映射表逻辑) const localFileMap = new Map(); const usedLocalFiles = new Set(); localFiles.forEach(file => { const normalizedKey = this.normalizeFileName(file.name); const sizeTypeKey = `${file.size}_${file.type}`; if (!localFileMap.has(normalizedKey)) localFileMap.set(normalizedKey, []); if (!localFileMap.has(sizeTypeKey)) localFileMap.set(sizeTypeKey, []); localFileMap.get(normalizedKey).push({ file, type: 'exact' }); localFileMap.get(sizeTypeKey).push({ file, type: 'fuzzy' }); }); // 比对邮件附件(合并精确和模糊匹配逻辑) emailAttachments.forEach(attachment => { const normalizedName = this.normalizeFileName(attachment.name); const sizeTypeKey = `${attachment.size}_${attachment.type}`; let bestMatch = null; // 优先精确匹配 const exactCandidates = localFileMap.get(normalizedName) ?? []; for (const { file } of exactCandidates.filter(c => c.type === 'exact')) { if (!usedLocalFiles.has(file) && file.size === attachment.size && file.type === attachment.type) { bestMatch = { file, type: 'exact', similarity: 1.0 }; break; } } // 模糊匹配 if (!bestMatch) { const fuzzyCandidates = localFileMap.get(sizeTypeKey) ?? []; let bestSimilarity = 0; for (const { file } of fuzzyCandidates.filter(c => c.type === 'fuzzy')) { if (usedLocalFiles.has(file)) continue; const similarity = this.calculateNameSimilarity(attachment.name, file.name); if (similarity > 0.6 && similarity > bestSimilarity) { bestMatch = { file, type: 'renamed', similarity }; bestSimilarity = similarity; } } } if (bestMatch) { result.matched.push({ email: attachment, local: bestMatch.file, matchType: bestMatch.type, similarity: bestMatch.similarity }); usedLocalFiles.add(bestMatch.file); } else { result.missing.push(attachment); } }); // 检查重复和本地独有文件(合并处理逻辑) const localFileUsage = new Map(); result.matched.forEach(match => { const key = `${match.local.path}_${match.local.size}`; (localFileUsage.get(key) || localFileUsage.set(key, []).get(key)).push(match); }); // 批量处理重复文件和本地独有文件 for (const matches of localFileUsage.values()) { if (matches.length > 1) { result.duplicates.push({ localFile: matches[0].local, emailAttachments: matches.map(m => m.email) }); } } result.localOnly = localFiles.filter(file => !usedLocalFiles.has(file)); // 计算总大小 result.summary.emailTotalSize = emailAttachments.reduce((sum, att) => sum + att.size, 0); result.summary.localTotalSize = localFiles.reduce((sum, file) => sum + file.size, 0); result.summary.matchedTotalSize = result.matched.reduce((sum, match) => sum + match.email.size, 0); result.summary.missingTotalSize = result.missing.reduce((sum, att) => sum + att.size, 0); // 更新统计 Object.assign(result.summary, { matchedCount: result.matched.length, missingCount: result.missing.length, duplicateCount: result.duplicates.length }); return result; } // 带进度显示的文件比对 async compareFilesWithProgress(localFiles, emailAttachments, updateProgress) { try { const result = { missing: [], duplicates: [], matched: [], localOnly: [], summary: { totalEmail: emailAttachments.length, totalLocal: localFiles.length, missingCount: 0, duplicateCount: 0, matchedCount: 0, emailTotalSize: 0, localTotalSize: 0, matchedTotalSize: 0, missingTotalSize: 0 } }; // 步骤1: 预处理本地文件映射 updateProgress('分析本地文件', '建立文件索引...', 3, 4); await new Promise(resolve => setTimeout(resolve, 50)); const localFileMap = new Map(); const usedLocalFiles = new Set(); localFiles.forEach(file => { const normalizedKey = this.normalizeFileName(file.name); const sizeTypeKey = `${file.size}_${file.type}`; if (!localFileMap.has(normalizedKey)) localFileMap.set(normalizedKey, []); if (!localFileMap.has(sizeTypeKey)) localFileMap.set(sizeTypeKey, []); localFileMap.get(normalizedKey).push({ file, type: 'exact' }); localFileMap.get(sizeTypeKey).push({ file, type: 'fuzzy' }); }); console.log(`建立文件索引完成,本地文件映射条目: ${localFileMap.size}`); // 步骤2: 比对邮件附件 updateProgress('比对文件匹配', '正在匹配邮件附件...', 3, 4); await new Promise(resolve => setTimeout(resolve, 50)); for (let i = 0; i < emailAttachments.length; i++) { const attachment = emailAttachments[i]; const normalizedName = this.normalizeFileName(attachment.name); const sizeTypeKey = `${attachment.size}_${attachment.type}`; let bestMatch = null; // 优先精确匹配 const exactCandidates = localFileMap.get(normalizedName) ?? []; for (const { file } of exactCandidates.filter(c => c.type === 'exact')) { if (!usedLocalFiles.has(file) && file.size === attachment.size && file.type === attachment.type) { bestMatch = { file, type: 'exact', similarity: 1.0 }; break; } } // 模糊匹配 if (!bestMatch) { const fuzzyCandidates = localFileMap.get(sizeTypeKey) ?? []; let bestSimilarity = 0; for (const { file } of fuzzyCandidates.filter(c => c.type === 'fuzzy')) { if (usedLocalFiles.has(file)) continue; const similarity = this.calculateNameSimilarity(attachment.name, file.name); if (similarity > 0.6 && similarity > bestSimilarity) { bestMatch = { file, type: 'renamed', similarity }; bestSimilarity = similarity; } } } if (bestMatch) { result.matched.push({ email: attachment, local: bestMatch.file, matchType: bestMatch.type, similarity: bestMatch.similarity }); usedLocalFiles.add(bestMatch.file); } else { result.missing.push(attachment); } // 每处理20个文件更新一次进度 if ((i + 1) % 20 === 0 || i === emailAttachments.length - 1) { updateProgress('比对文件匹配', `已处理 ${i + 1}/${emailAttachments.length} 个附件`, 3, 4); await new Promise(resolve => setTimeout(resolve, 10)); } } console.log(`文件匹配完成,匹配: ${result.matched.length}, 缺失: ${result.missing.length}`); // 步骤3: 检查重复文件和本地独有文件 updateProgress('完成分析', '检查重复文件和整理结果...', 3, 4); await new Promise(resolve => setTimeout(resolve, 50)); // 改进的重复文件检测逻辑 result.duplicates = this.detectDuplicateFiles(result.matched, localFiles, emailAttachments); result.localOnly = localFiles.filter(file => !usedLocalFiles.has(file)); // 计算总大小 result.summary.emailTotalSize = emailAttachments.reduce((sum, att) => sum + att.size, 0); result.summary.localTotalSize = localFiles.reduce((sum, file) => sum + file.size, 0); result.summary.matchedTotalSize = result.matched.reduce((sum, match) => sum + match.email.size, 0); result.summary.missingTotalSize = result.missing.reduce((sum, att) => sum + att.size, 0); // 更新统计 Object.assign(result.summary, { matchedCount: result.matched.length, missingCount: result.missing.length, duplicateCount: result.duplicates.length, localOnlyCount: result.localOnly.length }); console.log('比对分析完成:', result.summary); return result; } catch (error) { console.error('比对过程中出错:', error); throw new Error(`文件比对失败: ${error.message}`); } } // 改进的重复文件检测方法 detectDuplicateFiles(matchedFiles, localFiles, emailAttachments) { const duplicates = []; // 1. 检测一个本地文件匹配多个邮件附件的情况 const localFileToEmails = new Map(); matchedFiles.forEach(match => { const localFileKey = this.generateFileKey(match.local); if (!localFileToEmails.has(localFileKey)) { localFileToEmails.set(localFileKey, []); } localFileToEmails.get(localFileKey).push(match); }); // 找出一对多的匹配 for (const [fileKey, matches] of localFileToEmails.entries()) { if (matches.length > 1) { duplicates.push({ type: 'oneToMany', localFile: matches[0].local, emailAttachments: matches.map(m => m.email), reason: '一个本地文件匹配多个邮件附件', severity: 'high' }); } } // 2. 检测多个本地文件匹配同一个邮件附件的情况 const emailToLocalFiles = new Map(); matchedFiles.forEach(match => { const emailKey = this.generateEmailKey(match.email); if (!emailToLocalFiles.has(emailKey)) { emailToLocalFiles.set(emailKey, []); } emailToLocalFiles.get(emailKey).push(match); }); // 找出多对一的匹配 for (const [emailKey, matches] of emailToLocalFiles.entries()) { if (matches.length > 1) { duplicates.push({ type: 'manyToOne', emailAttachment: matches[0].email, localFiles: matches.map(m => m.local), reason: '多个本地文件匹配同一个邮件附件', severity: 'medium' }); } } // 3. 检测本地文件夹中的重复文件(相同内容但不同名称) const localDuplicates = this.findLocalDuplicates(localFiles); localDuplicates.forEach(dup => { duplicates.push({ type: 'localDuplicate', localFiles: dup.files, reason: dup.reason, severity: 'low' }); }); // 4. 检测邮件附件中的重复(相同内容但来自不同邮件) const emailDuplicates = this.findEmailDuplicates(emailAttachments); emailDuplicates.forEach(dup => { duplicates.push({ type: 'emailDuplicate', emailAttachments: dup.files, reason: dup.reason, severity: 'info' }); }); return duplicates; } // 生成文件唯一标识键 generateFileKey(file) { // 使用文件大小、修改时间和标准化文件名生成唯一键 const normalizedName = this.normalizeFileName(file.name); return `${normalizedName}_${file.size}_${file.lastModified ?? 0}`; } // 生成邮件附件唯一标识键 generateEmailKey(attachment) { // 使用文件大小和标准化文件名生成唯一键 const normalizedName = this.normalizeFileName(attachment.name); return `${normalizedName}_${attachment.size}`; } // 查找本地文件中的重复 findLocalDuplicates(localFiles) { const duplicates = []; const sizeGroups = new Map(); // 按大小分组 localFiles.forEach(file => { const size = file.size; if (!sizeGroups.has(size)) { sizeGroups.set(size, []); } sizeGroups.get(size).push(file); }); // 检查每个大小组中的重复 for (const [size, files] of sizeGroups.entries()) { if (files.length > 1) { // 按标准化文件名进一步分组 const nameGroups = new Map(); files.forEach(file => { const normalizedName = this.normalizeFileName(file.name); if (!nameGroups.has(normalizedName)) { nameGroups.set(normalizedName, []); } nameGroups.get(normalizedName).push(file); }); // 找出同名同大小的文件 for (const [normalizedName, sameNameFiles] of nameGroups.entries()) { if (sameNameFiles.length > 1) { duplicates.push({ files: sameNameFiles, reason: `相同大小(${this.formatData(size, 'size')})和相似文件名的文件` }); } } // 检查不同名但相同大小的可疑重复 const nameGroupsArray = Array.from(nameGroups.values()); if (nameGroupsArray.length > 1 && size > 1024) { // 只检查大于1KB的文件 const allFiles = nameGroupsArray.flat(); if (allFiles.length > 1) { // 检查文件名相似度 const similarFiles = []; for (let i = 0; i < allFiles.length; i++) { for (let j = i + 1; j < allFiles.length; j++) { const similarity = this.calculateNameSimilarity(allFiles[i].name, allFiles[j].name); if (similarity > 0.7) { if (!similarFiles.includes(allFiles[i])) similarFiles.push(allFiles[i]); if (!similarFiles.includes(allFiles[j])) similarFiles.push(allFiles[j]); } } } if (similarFiles.length > 1) { duplicates.push({ files: similarFiles, reason: `相同大小(${this.formatData(size, 'size')})和相似文件名的可疑重复文件` }); } } } } } return duplicates; } // 查找邮件附件中的重复 findEmailDuplicates(emailAttachments) { const duplicates = []; const sizeNameGroups = new Map(); // 按大小和标准化文件名分组 emailAttachments.forEach(attachment => { const normalizedName = this.normalizeFileName(attachment.name); const key = `${normalizedName}_${attachment.size}`; if (!sizeNameGroups.has(key)) { sizeNameGroups.set(key, []); } sizeNameGroups.get(key).push(attachment); }); // 找出重复的邮件附件 for (const [key, attachments] of sizeNameGroups.entries()) { if (attachments.length > 1) { // 检查是否来自不同邮件 const uniqueEmails = new Set(attachments.map(att => att.mailSubject || att.mailId)); if (uniqueEmails.size > 1) { duplicates.push({ files: attachments, reason: `相同文件在${uniqueEmails.size}封不同邮件中出现` }); } } } return duplicates; } // 标准化文件名(移除常见的重命名后缀) normalizeFileName(fileName) { // 移除扩展名 const nameWithoutExt = this.processFileName(fileName, 'removeExtension'); // 移除常见的重命名后缀,如 (1), (2), _1, _2 等 const normalized = nameWithoutExt .replace(/\s*\(\d+\)$/, '') // 移除 (1), (2) 等 .replace(/\s*_\d+$/, '') // 移除 _1, _2 等 .replace(/\s*-\d+$/, '') // 移除 -1, -2 等 .replace(/\s*副本$/, '') // 移除"副本" .replace(/\s*copy$/, '') // 移除"copy" .replace(/\s*Copy$/, '') // 移除"Copy" .replace(/\s*复制$/, '') // 移除"复制" .trim(); return normalized.toLowerCase(); } // 计算文件名相似度 calculateNameSimilarity(name1, name2) { const norm1 = this.normalizeFileName(name1); const norm2 = this.normalizeFileName(name2); // 如果标准化后完全相同,返回高相似度 if (norm1 === norm2) { return 0.95; } // 使用编辑距离计算相似度 const distance = this.levenshteinDistance(norm1, norm2); const maxLength = Math.max(norm1.length, norm2.length); if (maxLength === 0) return 1; return 1 - (distance / maxLength); } // 优化的编辑距离算法(使用滚动数组减少内存占用) levenshteinDistance(str1, str2) { if (str1 === str2) return 0; if (!str1.length) return str2.length; if (!str2.length) return str1.length; // 使用两个数组而不是二维矩阵,节省内存 let prev = Array(str2.length + 1).fill(0).map((_, i) => i); let curr = Array(str2.length + 1); for (let i = 1; i <= str1.length; i++) { curr[0] = i; for (let j = 1; j <= str2.length; j++) { curr[j] = str1[i - 1] === str2[j - 1] ? prev[j - 1] : Math.min(prev[j - 1], prev[j], curr[j - 1]) + 1; } [prev, curr] = [curr, prev]; } return prev[str2.length]; } // 显示比对结果 displayComparisonResults(result, summaryDiv, missingDiv, duplicateDiv, matchedDiv) { // 保存当前比对结果,供"保留最新"功能使用 this.currentComparisonResult = result; // 为显示区域添加数据属性,便于后续更新 summaryDiv.setAttribute('data-comparison-summary', ''); missingDiv.setAttribute('data-comparison-missing', ''); duplicateDiv.setAttribute('data-comparison-duplicates', ''); matchedDiv.setAttribute('data-comparison-matched', ''); // 计算统计数据 const emailSize = result.summary.emailTotalSize; const localSize = result.summary.localTotalSize; const matchedSize = result.summary.matchedTotalSize; const missingSize = result.summary.missingTotalSize; const sizeMatchPercentage = emailSize > 0 ? Math.round((matchedSize / emailSize) * 100) : 100; const sizeDifference = localSize - emailSize; const sizeDifferenceAbs = Math.abs(sizeDifference); // 判断大小匹配状态 const getSizeMatchStatus = () => { if (sizeDifferenceAbs === 0) { return { color: 'linear-gradient(135deg, #d4edda, #f8f9fa)', borderColor: '#c3e6cb', textColor: '#155724', icon: '✓', text: '文件夹大小完全匹配' }; } if (sizeDifferenceAbs < emailSize * 0.01) { return { color: 'linear-gradient(135deg, #e2f3ff, #f8f9fa)', borderColor: '#b3d9ff', textColor: '#0d47a1', icon: '≈', text: '文件夹大小基本匹配(差异小于1%)' }; } if (sizeDifferenceAbs < emailSize * 0.05) { return { color: 'linear-gradient(135deg, #fff3cd, #f8f9fa)', borderColor: '#ffeaa7', textColor: '#856404', icon: '⚠', text: '文件夹大小存在轻微差异(小于5%)' }; } return { color: 'linear-gradient(135deg, #f8d7da, #f8f9fa)', borderColor: '#f5c6cb', textColor: '#721c24', icon: '✗', text: '文件夹大小存在明显差异' }; }; const sizeStatus = getSizeMatchStatus(); // 优化摘要区域 - 增加色彩层次和视觉引导 summaryDiv.innerHTML = ` <div style="margin-bottom: 24px;"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 20px;"> <div style="width: 4px; height: 24px; background: linear-gradient(135deg, #007bff, #0056b3); border-radius: 2px;"></div> <h4 style="margin: 0; font-size: 20px; font-weight: 700; color: #13181D;">比对摘要</h4> </div> <!-- 核心统计卡片 - 增加色彩层次 --> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px;"> <div style="background: linear-gradient(135deg, #f8f9fa, #ffffff); border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.04);"> <div style="font-size: 28px; font-weight: 700; margin-bottom: 4px; color: #495057;">${result.summary.totalEmail}</div> <div style="font-size: 13px; color: #6c757d; font-weight: 600;">邮件附件总数</div> <div style="font-size: 11px; color: #6c757d; margin-top: 4px;">${this.formatData(emailSize, 'size')}</div> </div> <div style="background: linear-gradient(135deg, #e8f5e8, #ffffff); border: 1px solid #c3e6cb; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.04);"> <div style="font-size: 28px; font-weight: 700; margin-bottom: 4px; color: #155724;">${result.summary.matchedCount}</div> <div style="font-size: 13px; color: #155724; font-weight: 600;">匹配文件</div> <div style="font-size: 11px; color: #6c757d; margin-top: 4px;">${sizeMatchPercentage}% 大小匹配</div> </div> <div style="background: linear-gradient(135deg, #fff3cd, #ffffff); border: 2px solid #ffeaa7; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 6px rgba(255,193,7,0.15);"> <div style="font-size: 28px; font-weight: 700; margin-bottom: 4px; color: #856404;">${result.summary.missingCount}</div> <div style="font-size: 13px; color: #856404; font-weight: 600;">缺失文件</div> <div style="font-size: 11px; color: #6c757d; margin-top: 4px;">${this.formatData(missingSize, 'size')}</div> </div> <div style="background: linear-gradient(135deg, #e3f2fd, #ffffff); border: 1px solid #b3d9ff; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.04);"> <div style="font-size: 28px; font-weight: 700; margin-bottom: 4px; color: #0d47a1;">${result.summary.totalLocal}</div> <div style="font-size: 13px; color: #0d47a1; font-weight: 600;">本地文件总数</div> <div style="font-size: 11px; color: #6c757d; margin-top: 4px;">${this.formatData(localSize, 'size')}</div> </div> </div> <!-- 大小比对状态卡片 - 增加状态色彩 --> <div style="background: linear-gradient(135deg, #f8f9fa, #ffffff); border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.04);"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;"> <div style="width: 40px; height: 40px; background: ${sizeStatus.color}; border: 1px solid ${sizeStatus.borderColor}; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: ${sizeStatus.textColor}; font-size: 16px; font-weight: bold;">${sizeStatus.icon}</div> <div> <h5 style="margin: 0; font-size: 18px; font-weight: 700; color: #13181D;">文件夹大小比对</h5> <div style="font-size: 14px; color: #6c757d; margin-top: 2px;">${sizeStatus.text}</div> </div> </div> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;"> <div style="background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 16px;"> <div style="font-size: 12px; color: #6c757d; margin-bottom: 6px;">已匹配文件大小</div> <div style="font-size: 18px; font-weight: 700; color: #13181D;">${this.formatData(matchedSize, 'size')}</div> <div style="font-size: 11px; color: #6c757d; margin-top: 2px;">${sizeMatchPercentage}% 匹配率</div> </div> ${sizeDifference !== 0 ? ` <div style="background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 16px;"> <div style="font-size: 12px; color: #6c757d; margin-bottom: 6px;">大小差异</div> <div style="font-size: 18px; font-weight: 700; color: #13181D;"> ${sizeDifference > 0 ? '+' : ''}${this.formatData(sizeDifferenceAbs, 'size')} </div> <div style="font-size: 11px; color: #6c757d; margin-top: 2px;"> ${sizeDifference > 0 ? '本地文件更多' : '本地文件更少'} </div> </div> ` : ''} </div> </div> </div> `; // 重新设计缺失文件区域 - 增加完整信息展示 if (result.missing.length > 0) { // 统计文件类型分布 const typeStats = {}; let totalSize = 0; result.missing.forEach(attachment => { const type = attachment.type || '未知'; typeStats[type] = (typeStats[type] || 0) + 1; totalSize += attachment.size || 0; }); missingDiv.innerHTML = ` <div style="background: #ffffff; border: 1px solid #dee2e6; border-radius: 6px; margin-bottom: 20px;"> <!-- 头部概览 --> <div style="padding: 16px 20px; border-bottom: 1px solid #e9ecef;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;"> <div> <h4 style="margin: 0 0 4px 0; font-size: 16px; font-weight: 600; color: #13181D;">缺失文件</h4> <div style="font-size: 13px; color: #6c757d;">需要下载 ${result.missing.length} 个文件,总大小 ${this.formatData(totalSize, 'size')}</div> </div> <div style="display: flex; gap: 8px;"> <button class="batch-download-missing-btn" style="padding: 6px 12px; background: #007bff; color: white; border: none; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#0056b3'" onmouseout="this.style.background='#007bff'"> 批量下载 </button> </div> </div> <!-- 文件类型统计 --> <div style="display: flex; gap: 12px; flex-wrap: wrap;"> ${Object.entries(typeStats).map(([type, count]) => ` <div style="background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; padding: 4px 8px; font-size: 11px;"> <span style="color: #6c757d;">${type}:</span> <span style="color: #13181D; font-weight: 600; margin-left: 4px;">${count}</span> </div> `).join('')} </div> </div> <!-- 缺失文件列表 --> <div style="max-height: 400px; overflow-y: auto;"> ${result.missing.map((attachment, index) => ` <div style="border-bottom: ${index === result.missing.length - 1 ? 'none' : '1px solid #f8f9fa'}; padding: 16px 20px; transition: background 0.2s;" onmouseover="this.style.background='#f8f9fa'" onmouseout="this.style.background='transparent'"> <!-- 文件信息 --> <div style="display: flex; align-items: flex-start; gap: 12px; margin-bottom: 8px;"> <div style="width: 4px; height: 16px; background: #ffc107; border-radius: 2px; flex-shrink: 0; margin-top: 2px;"></div> <div style="flex: 1; min-width: 0;"> <div style="font-weight: 600; color: #13181D; margin-bottom: 4px; word-break: break-all; font-size: 14px;"> ${this.processData(attachment.name, 'escapeHtml')} </div> <div style="display: flex; align-items: center; gap: 16px; font-size: 12px; color: #6c757d; margin-bottom: 6px;"> <span>${this.formatData(attachment.size, 'size')}</span> <span>${attachment.type || '未知类型'}</span> ${attachment.fileid ? `<span>ID: ${attachment.fileid}</span>` : ''} </div> <!-- 来源邮件信息 --> ${attachment.mailSubject || attachment.mailSender || attachment.mailDate ? ` <div style="background: #f8f9fa; border-radius: 4px; padding: 6px 10px; margin-top: 6px;"> <div style="font-size: 11px; color: #6c757d; margin-bottom: 2px;">来源邮件:</div> <div style="font-size: 12px; color: #13181D;"> ${attachment.mailSubject ? ` <div style="font-weight: 500; margin-bottom: 2px; word-break: break-all;"> ${this.processData(attachment.mailSubject, 'escapeHtml')} </div> ` : ''} <div style="display: flex; gap: 12px; font-size: 11px; color: #6c757d;"> ${attachment.mailSender ? `<span>发件人: ${this.processData(attachment.mailSender, 'escapeHtml')}</span>` : ''} ${attachment.mailDate ? `<span>日期: ${this.formatDate(attachment.mailDate, 'YYYY-MM-DD HH:mm')}</span>` : ''} </div> </div> </div> ` : ''} </div> <!-- 操作按钮 --> <div style="display: flex; flex-direction: column; gap: 4px; flex-shrink: 0;"> <button data-fileid="${attachment.fileid}" class="download-single-btn" style="padding: 6px 12px; background: #28a745; color: white; border: none; border-radius: 4px; font-size: 11px; font-weight: 500; cursor: pointer; transition: background 0.2s; white-space: nowrap;" onmouseover="this.style.background='#218838'" onmouseout="this.style.background='#28a745'"> 下载 </button> ${attachment.mailId ? ` <button onclick="window.attachmentManager.openMail('${attachment.mailId}')" style="padding: 4px 8px; background: transparent; color: #6c757d; border: 1px solid #dee2e6; border-radius: 4px; font-size: 10px; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.background='#f8f9fa'; this.style.color='#13181D'" onmouseout="this.style.background='transparent'; this.style.color='#6c757d'"> 查看邮件 </button> ` : ''} </div> </div> </div> `).join('')} </div> </div> `; } else { missingDiv.innerHTML = ` <div style="background: linear-gradient(135deg, #f0fff4, #ffffff); border: 1px solid #c3e6cb; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(40,167,69,0.1);"> <div style="width: 60px; height: 60px; background: linear-gradient(135deg, #c3e6cb, #d4edda); border: 1px solid #28a745; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; color: #155724; font-size: 20px;">✓</div> <h4 style="margin: 0 0 8px 0; color: #155724; font-size: 18px; font-weight: 700;">文件完整</h4> <div style="color: #155724; font-size: 14px;">所有邮件附件都已存在于本地文件夹中</div> </div> `; } // 重新设计重复文件检测面板 - 简洁清晰的信息架构 if (result.duplicates.length > 0) { const duplicatesByType = this.groupDuplicatesByType(result.duplicates); const canKeepLatestCount = result.duplicates.filter(dup => this.canKeepLatestFile(dup)).length; // 统计各类型数量 const typeStats = Object.entries(duplicatesByType).map(([type, duplicates]) => ({ type, count: duplicates.length, title: this.getDuplicateTypeTitle(type).replace(/[⚠️🔄📁📧❓]\s*/, '') // 移除表情符号 })); duplicateDiv.innerHTML = ` <div style="background: #ffffff; border: 1px solid #dee2e6; border-radius: 6px; margin-bottom: 20px;"> <!-- 头部概览 --> <div style="padding: 16px 20px; border-bottom: 1px solid #e9ecef;"> <div style="display: flex; align-items: center; justify-content: between; gap: 16px;"> <div> <h4 style="margin: 0 0 4px 0; font-size: 16px; font-weight: 600; color: #13181D;">重复文件检测</h4> <div style="font-size: 13px; color: #6c757d;">发现 ${result.duplicates.length} 个重复项,需要处理</div> </div> ${canKeepLatestCount > 0 ? ` <button class="batch-keep-latest-btn" data-count="${canKeepLatestCount}" style="padding: 6px 12px; background: #28a745; color: white; border: none; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.2s; white-space: nowrap;" onmouseover="this.style.background='#218838'" onmouseout="this.style.background='#28a745'"> 批量保留最新 (${canKeepLatestCount}) </button> ` : ''} </div> <!-- 类型统计 --> <div style="display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap;"> ${typeStats.map(stat => ` <div style="background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; padding: 6px 10px; font-size: 12px;"> <span style="color: #6c757d;">${stat.title}:</span> <span style="color: #13181D; font-weight: 600; margin-left: 4px;">${stat.count}</span> </div> `).join('')} </div> </div> <!-- 重复项列表 --> <div style="max-height: 400px; overflow-y: auto;"> ${Object.entries(duplicatesByType).map(([type, duplicates], typeIndex) => ` ${duplicates.map((dup, dupIndex) => this.renderDuplicateItem(dup, this.getDuplicateGlobalIndex(result.duplicates, dup))).join('')} `).join('')} </div> </div> `; } else { duplicateDiv.innerHTML = ` <div style="background: linear-gradient(135deg, #f0fff4, #ffffff); border: 1px solid #c3e6cb; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(40,167,69,0.1);"> <div style="width: 60px; height: 60px; background: linear-gradient(135deg, #c3e6cb, #d4edda); border: 1px solid #28a745; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; color: #155724; font-size: 20px;">✓</div> <h4 style="margin: 0 0 8px 0; color: #155724; font-size: 18px; font-weight: 700;">无重复文件</h4> <div style="color: #155724; font-size: 14px;">未检测到重复或相似的文件</div> </div> `; } // 优化匹配文件区域 - 增加成功色彩提示 if (result.matched.length > 0) { const exactMatches = result.matched.filter(match => match.type === 'exact'); const renamedMatches = result.matched.filter(match => match.type === 'renamed'); matchedDiv.innerHTML = ` <div style="background: linear-gradient(135deg, #f0fff4, #ffffff); border: 1px solid #c3e6cb; border-radius: 8px; overflow: hidden; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(40,167,69,0.1);"> <div style="background: linear-gradient(135deg, #d4edda, #ffffff); border-bottom: 1px solid #c3e6cb; padding: 16px 20px;"> <div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; gap: 12px;"> <div style="width: 36px; height: 36px; background: linear-gradient(135deg, #c3e6cb, #d4edda); border: 1px solid #28a745; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 16px; color: #155724;">✓</div> <div> <h4 style="margin: 0; font-size: 18px; font-weight: 700; color: #155724;">匹配文件</h4> <div style="font-size: 13px; color: #155724; margin-top: 2px;">已成功匹配的文件</div> </div> </div> <div style="text-align: right;"> <div style="font-size: 24px; font-weight: 700; color: #155724;">${result.matched.length}</div> <div style="font-size: 11px; color: #155724;">个文件</div> </div> </div> </div> <div style="max-height: 300px; overflow-y: auto;"> ${exactMatches.length > 0 ? ` <div style="padding: 12px 20px; background: #f8f9fa; color: #13181D; font-weight: 600; font-size: 14px; border-bottom: 1px solid #e9ecef;"> 精确匹配 (${exactMatches.length}) </div> ${exactMatches.map((match, index) => ` <div style="padding: 16px 20px; border-bottom: ${index === exactMatches.length - 1 && renamedMatches.length === 0 ? 'none' : '1px solid #f8f9fa'}; display: flex; align-items: center; gap: 16px; transition: background 0.2s;" onmouseover="this.style.background='#f8f9fa'" onmouseout="this.style.background='transparent'"> <div style="width: 6px; height: 6px; background: #13181D; border-radius: 50%; flex-shrink: 0;"></div> <div style="flex: 1; min-width: 0;"> <div style="font-weight: 600; color: #13181D; margin-bottom: 4px; word-break: break-all;">${this.processData(match.email.name, 'escapeHtml')}</div> <div style="display: flex; align-items: center; gap: 12px; font-size: 12px; color: #6c757d;"> <span>${this.formatData(match.email.size, 'size')}</span> <span>•</span> <span>${match.email.type}</span> </div> </div> </div> `).join('')} ` : ''} ${renamedMatches.length > 0 ? ` <div style="padding: 12px 20px; background: #f8f9fa; color: #13181D; font-weight: 600; font-size: 14px; border-bottom: 1px solid #e9ecef;"> 重命名匹配 (${renamedMatches.length}) </div> ${renamedMatches.map((match, index) => ` <div style="padding: 16px 20px; border-bottom: ${index === renamedMatches.length - 1 ? 'none' : '1px solid #f8f9fa'}; display: flex; align-items: center; gap: 16px; transition: background 0.2s;" onmouseover="this.style.background='#f8f9fa'" onmouseout="this.style.background='transparent'"> <div style="width: 6px; height: 6px; background: #6c757d; border-radius: 50%; flex-shrink: 0;"></div> <div style="flex: 1; min-width: 0;"> <div style="font-weight: 600; color: #13181D; margin-bottom: 4px; word-break: break-all;">${this.processData(match.email.name, 'escapeHtml')}</div> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">→ ${this.processData(match.local.name, 'escapeHtml')}</div> <div style="display: flex; align-items: center; gap: 12px; font-size: 12px; color: #6c757d;"> <span>${this.formatData(match.email.size, 'size')}</span> <span>•</span> <span>${match.email.type}</span> </div> </div> </div> `).join('')} ` : ''} </div> </div> `; } else { matchedDiv.innerHTML = ` <div style="background: linear-gradient(135deg, #f8f9fa, #ffffff); border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.04);"> <div style="width: 60px; height: 60px; background: linear-gradient(135deg, #dee2e6, #f8f9fa); border: 1px solid #adb5bd; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; color: #6c757d; font-size: 20px;">○</div> <h4 style="margin: 0 0 8px 0; color: #495057; font-size: 18px; font-weight: 700;">无匹配文件</h4> <div style="color: #6c757d; font-size: 14px;">未找到匹配的文件</div> </div> `; } // 设置事件监听器 this.setupComparisonDialogEvents(summaryDiv.parentNode); } // 按类型分组重复文件 groupDuplicatesByType(duplicates) { const grouped = {}; duplicates.forEach(dup => { if (!grouped[dup.type]) { grouped[dup.type] = []; } grouped[dup.type].push(dup); }); return grouped; } // 获取重复文件类型的颜色配置 getDuplicateTypeColor(type) { const colors = { 'oneToMany': { bg: '#fff3cd', text: '#856404' }, 'manyToOne': { bg: '#f8d7da', text: '#721c24' }, 'localDuplicate': { bg: '#d1ecf1', text: '#0c5460' }, 'emailDuplicate': { bg: '#e2e3e5', text: '#383d41' } }; return colors[type] || { bg: '#f8f9fa', text: '#495057' }; } // 获取重复文件类型的标题 getDuplicateTypeTitle(type) { const titles = { 'oneToMany': '⚠️ 一对多匹配', 'manyToOne': '🔄 多对一匹配', 'localDuplicate': '📁 本地重复文件', 'emailDuplicate': '📧 邮件重复附件' }; return titles[type] || '❓ 未知类型'; } // 渲染重复文件项 - 重新设计为简洁统一的格式 renderDuplicateItem(dup, index) { const canKeepLatest = this.canKeepLatestFile(dup); const typeInfo = this.getDuplicateTypeInfo(dup.type); return ` <div style="border-bottom: 1px solid #f8f9fa; padding: 16px 20px; transition: background 0.2s;" data-dup-index="${index}" onmouseover="this.style.background='#f8f9fa'" onmouseout="this.style.background='transparent'"> <!-- 重复类型标识 --> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;"> <div style="width: 4px; height: 16px; background: ${typeInfo.color}; border-radius: 2px;"></div> <div style="font-weight: 600; color: #13181D; font-size: 14px;">${typeInfo.title}</div> <div style="font-size: 12px; color: #6c757d; background: #f8f9fa; padding: 2px 6px; border-radius: 3px;"> ${dup.reason} </div> </div> <!-- 重复文件详情 --> ${this.renderDuplicateDetails(dup)} <!-- 操作按钮 --> ${canKeepLatest ? ` <div style="margin-top: 12px; text-align: right;"> <button class="keep-latest-btn" data-index="${index}" style="background: #28a745; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#218838'" onmouseout="this.style.background='#28a745'"> 保留最新 </button> </div> ` : ''} </div> `; } // 获取重复类型信息 getDuplicateTypeInfo(type) { const typeMap = { 'oneToMany': { title: '一对多匹配', color: '#ffc107' }, 'manyToOne': { title: '多对一匹配', color: '#dc3545' }, 'localDuplicate': { title: '本地重复文件', color: '#17a2b8' }, 'emailDuplicate': { title: '邮件重复附件', color: '#6c757d' } }; return typeMap[type] || { title: '未知类型', color: '#6c757d' }; } // 渲染重复文件详情 renderDuplicateDetails(dup) { switch (dup.type) { case 'oneToMany': return ` <!-- 本地文件 --> <div style="margin-bottom: 12px;"> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">本地文件:</div> <div style="background: #f8f9fa; border-radius: 4px; padding: 8px 12px;"> <div style="font-weight: 600; color: #13181D; margin-bottom: 2px; word-break: break-all;"> ${this.processData(dup.localFile.name, 'escapeHtml')} </div> <div style="font-size: 12px; color: #6c757d;"> ${this.formatData(dup.localFile.size, 'size')} </div> </div> </div> <!-- 匹配的邮件附件 --> <div> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">匹配的邮件附件 (${dup.emailAttachments.length}):</div> <div style="display: flex; flex-direction: column; gap: 4px;"> ${dup.emailAttachments.map(att => ` <div style="background: #f8f9fa; border-radius: 4px; padding: 6px 10px; font-size: 12px;"> <div style="color: #13181D; font-weight: 500; margin-bottom: 2px; word-break: break-all;"> ${this.processData(att.name, 'escapeHtml')} </div> <div style="color: #6c757d; display: flex; gap: 12px;"> <span>${this.formatData(att.size, 'size')}</span> ${att.date ? `<span>${this.formatDate(att.date, 'MM-DD HH:mm')}</span>` : ''} </div> </div> `).join('')} </div> </div> `; case 'manyToOne': return ` <!-- 邮件附件 --> <div style="margin-bottom: 12px;"> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">邮件附件:</div> <div style="background: #f8f9fa; border-radius: 4px; padding: 8px 12px;"> <div style="font-weight: 600; color: #13181D; margin-bottom: 2px; word-break: break-all;"> ${this.processData(dup.emailAttachment.name, 'escapeHtml')} </div> <div style="font-size: 12px; color: #6c757d;"> ${this.formatData(dup.emailAttachment.size, 'size')} </div> </div> </div> <!-- 匹配的本地文件 --> <div> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">匹配的本地文件 (${dup.localFiles.length}):</div> <div style="display: flex; flex-direction: column; gap: 4px;"> ${dup.localFiles.map(file => ` <div style="background: #f8f9fa; border-radius: 4px; padding: 6px 10px; font-size: 12px;"> <div style="color: #13181D; font-weight: 500; margin-bottom: 2px; word-break: break-all;"> ${this.processData(file.name, 'escapeHtml')} </div> <div style="color: #6c757d; display: flex; gap: 12px; flex-wrap: wrap;"> <span>${this.formatData(file.size, 'size')}</span> ${file.path ? `<span>路径: ${file.path}</span>` : ''} ${file.lastModified ? `<span>修改: ${this.formatDate(file.lastModified, 'MM-DD HH:mm')}</span>` : ''} </div> </div> `).join('')} </div> </div> `; case 'localDuplicate': return ` <div> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">重复的本地文件 (${dup.localFiles.length}):</div> <div style="display: flex; flex-direction: column; gap: 4px;"> ${dup.localFiles.map(file => ` <div style="background: #f8f9fa; border-radius: 4px; padding: 6px 10px; font-size: 12px;"> <div style="color: #13181D; font-weight: 500; margin-bottom: 2px; word-break: break-all;"> ${this.processData(file.name, 'escapeHtml')} </div> <div style="color: #6c757d; display: flex; gap: 12px; flex-wrap: wrap;"> <span>${this.formatData(file.size, 'size')}</span> ${file.path ? `<span>路径: ${file.path}</span>` : ''} ${file.lastModified ? `<span>修改: ${this.formatDate(file.lastModified, 'MM-DD HH:mm')}</span>` : ''} </div> </div> `).join('')} </div> </div> `; case 'emailDuplicate': return ` <div> <div style="font-size: 12px; color: #6c757d; margin-bottom: 4px;">重复的邮件附件 (${dup.emailAttachments.length}):</div> <div style="display: flex; flex-direction: column; gap: 4px;"> ${dup.emailAttachments.map(att => ` <div style="background: #f8f9fa; border-radius: 4px; padding: 6px 10px; font-size: 12px;"> <div style="color: #13181D; font-weight: 500; margin-bottom: 2px; word-break: break-all;"> ${this.processData(att.name, 'escapeHtml')} </div> <div style="color: #6c757d; display: flex; gap: 12px; flex-wrap: wrap;"> <span>${this.formatData(att.size, 'size')}</span> <span>来自: ${att.mailSubject || '未知邮件'}</span> ${att.date ? `<span>${this.formatDate(att.date, 'MM-DD HH:mm')}</span>` : ''} </div> </div> `).join('')} </div> </div> `; default: return `<div style="font-size: 12px; color: #6c757d;">未知重复类型</div>`; } } // 获取重复项在全局数组中的索引 getDuplicateGlobalIndex(allDuplicates, targetDup) { return allDuplicates.findIndex(dup => dup === targetDup); } // 判断是否可以执行"保留最新"操作 canKeepLatestFile(dup) { switch (dup.type) { case 'localDuplicate': return dup.localFiles && dup.localFiles.length > 1 && dup.localFiles.some(file => file.lastModified); case 'emailDuplicate': return dup.emailAttachments && dup.emailAttachments.length > 1 && dup.emailAttachments.some(att => att.date); case 'manyToOne': return dup.localFiles && dup.localFiles.length > 1 && dup.localFiles.some(file => file.lastModified); default: return false; } } // 保留最新文件,删除旧文件 async keepLatestFile(duplicateIndex) { if (!this.currentComparisonResult || !this.currentComparisonResult.duplicates) { this.showToast('比对结果不存在', 'error'); return; } const dup = this.currentComparisonResult.duplicates[duplicateIndex]; if (!dup) { this.showToast('重复文件项不存在', 'error'); return; } try { let result; switch (dup.type) { case 'localDuplicate': result = await this.keepLatestLocalFiles(dup); break; case 'emailDuplicate': result = await this.keepLatestEmailAttachments(dup); break; case 'manyToOne': result = await this.keepLatestInManyToOne(dup); break; default: this.showToast('此类型不支持保留最新操作', 'warning'); return; } if (result.success) { this.showToast(`已保留最新文件,删除了 ${result.deletedCount} 个旧文件`, 'success'); // 从重复列表中移除已处理的项 this.currentComparisonResult.duplicates.splice(duplicateIndex, 1); // 重新渲染比对结果 this.refreshComparisonDisplay(); } else { this.showToast(`操作失败: ${result.error}`, 'error'); } } catch (error) { console.error('保留最新文件时出错:', error); this.showToast(`操作失败: ${error.message}`, 'error'); } } // 保留最新的本地文件 async keepLatestLocalFiles(dup, silent = false) { const files = dup.localFiles; if (!files || files.length <= 1) { return { success: false, error: '文件数量不足' }; } // 按修改时间排序,最新的在前 const sortedFiles = files.sort((a, b) => { const timeA = a.lastModified ?? 0; const timeB = b.lastModified ?? 0; return timeB - timeA; }); const latestFile = sortedFiles[0]; const filesToDelete = sortedFiles.slice(1); // 确认操作(非静默模式) if (!silent) { const confirmMessage = `确定要保留最新文件并删除 ${filesToDelete.length} 个旧文件吗?\n\n保留: ${latestFile.name}\n删除: ${filesToDelete.map(f => f.name).join(', ')}`; if (!confirm(confirmMessage)) { return { success: false, error: '用户取消操作' }; } } let deletedCount = 0; const errors = []; // 删除旧文件 for (const file of filesToDelete) { try { if (file.handle) { await file.handle.remove(); deletedCount++; } else { errors.push(`无法删除 ${file.name}: 文件句柄不存在`); } } catch (error) { errors.push(`删除 ${file.name} 失败: ${error.message}`); } } if (errors.length > 0) { console.warn('删除文件时出现部分错误:', errors); } return { success: deletedCount > 0, deletedCount, error: errors.length > 0 ? errors.join('; ') : null }; } // 保留最新的邮件附件(标记为下载,其他忽略) async keepLatestEmailAttachments(dup, silent = false) { const attachments = dup.emailAttachments; if (!attachments || attachments.length <= 1) { return { success: false, error: '附件数量不足' }; } // 按邮件日期排序,最新的在前 const sortedAttachments = attachments.sort((a, b) => { const timeA = new Date(a.date ?? 0).getTime(); const timeB = new Date(b.date ?? 0).getTime(); return timeB - timeA; }); const latestAttachment = sortedAttachments[0]; const oldAttachments = sortedAttachments.slice(1); // 确认操作(非静默模式) if (!silent) { const confirmMessage = `确定要标记最新附件为下载,忽略 ${oldAttachments.length} 个旧附件吗?\n\n下载: ${latestAttachment.name} (${this.formatDate(latestAttachment.date, 'YYYY-MM-DD')})\n忽略: ${oldAttachments.map(att => `${att.name} (${this.formatDate(att.date, 'YYYY-MM-DD')})`).join(', ')}`; if (!confirm(confirmMessage)) { return { success: false, error: '用户取消操作' }; } } // 这里可以实现将旧附件从下载列表中移除的逻辑 // 或者添加到忽略列表中 return { success: true, deletedCount: oldAttachments.length, message: `已标记保留最新附件: ${latestAttachment.name}` }; } // 在多对一匹配中保留最新的本地文件 async keepLatestInManyToOne(dup, silent = false) { return await this.keepLatestLocalFiles(dup, silent); } // 批量保留最新文件 async batchKeepLatest() { if (!this.currentComparisonResult || !this.currentComparisonResult.duplicates) { this.showToast('比对结果不存在', 'error'); return; } const duplicates = this.currentComparisonResult.duplicates; const processableDuplicates = duplicates .map((dup, index) => ({ dup, index })) .filter(({ dup }) => this.canKeepLatestFile(dup)); if (processableDuplicates.length === 0) { this.showToast('没有可以批量处理的重复文件', 'warning'); return; } // 确认操作 const confirmMessage = `确定要批量处理 ${processableDuplicates.length} 个重复文件组吗?\n\n此操作将:\n- 保留每组中最新的文件\n- 删除其他旧版本文件\n\n此操作不可撤销!`; if (!confirm(confirmMessage)) { return; } this.showProgress('正在批量处理重复文件...'); let processedCount = 0; let totalDeleted = 0; const errors = []; // 按索引降序处理,避免索引变化问题 const sortedDuplicates = processableDuplicates.sort((a, b) => b.index - a.index); for (const { dup, index } of sortedDuplicates) { try { let result; switch (dup.type) { case 'localDuplicate': result = await this.keepLatestLocalFiles(dup, true); // 静默模式 break; case 'emailDuplicate': result = await this.keepLatestEmailAttachments(dup, true); // 静默模式 break; case 'manyToOne': result = await this.keepLatestInManyToOne(dup, true); // 静默模式 break; default: continue; } if (result.success) { totalDeleted += result.deletedCount; // 从重复列表中移除已处理的项 this.currentComparisonResult.duplicates.splice(index, 1); processedCount++; } else { errors.push(`处理失败: ${result.error}`); } } catch (error) { errors.push(`处理重复文件时出错: ${error.message}`); } } this.hideProgress(); // 显示结果 if (processedCount > 0) { this.showToast(`批量处理完成!处理了 ${processedCount} 个重复文件组,删除了 ${totalDeleted} 个旧文件`, 'success'); // 重新渲染比对结果 this.refreshComparisonDisplay(); } else { this.showToast('批量处理失败', 'error'); } if (errors.length > 0) { console.warn('批量处理过程中的错误:', errors); this.showToast(`处理过程中出现 ${errors.length} 个错误,请查看控制台`, 'warning'); } } // 设置比较对话框事件监听器 setupComparisonDialogEvents(dialogContent) { // 批量保留最新按钮 const batchKeepLatestBtns = dialogContent.querySelectorAll('.batch-keep-latest-btn'); batchKeepLatestBtns.forEach(btn => { btn.addEventListener('click', () => this.batchKeepLatest()); btn.addEventListener('mouseenter', (e) => e.target.style.background = '#138496'); btn.addEventListener('mouseleave', (e) => e.target.style.background = '#17a2b8'); }); // 单个保留最新按钮 const keepLatestBtns = dialogContent.querySelectorAll('.keep-latest-btn'); keepLatestBtns.forEach(btn => { const index = parseInt(btn.dataset.index); btn.addEventListener('click', () => this.keepLatestFile(index)); btn.addEventListener('mouseenter', (e) => e.target.style.background = '#218838'); btn.addEventListener('mouseleave', (e) => e.target.style.background = '#28a745'); }); // 验证设置按钮 const validationSettingsBtns = dialogContent.querySelectorAll('.validation-settings-btn'); validationSettingsBtns.forEach(btn => { btn.addEventListener('click', () => this.openValidationSettings()); btn.addEventListener('mouseenter', (e) => e.target.style.background = 'var(--theme_primary_hover, #0056b3)'); btn.addEventListener('mouseleave', (e) => e.target.style.background = 'var(--theme_primary, #007bff)'); }); } // 刷新比对结果显示 refreshComparisonDisplay() { if (!this.currentComparisonResult) return; const summaryDiv = document.querySelector('[data-comparison-summary]'); const missingDiv = document.querySelector('[data-comparison-missing]'); const duplicateDiv = document.querySelector('[data-comparison-duplicates]'); const matchedDiv = document.querySelector('[data-comparison-matched]'); if (summaryDiv && missingDiv && duplicateDiv && matchedDiv) { this.displayComparisonResults( this.currentComparisonResult, summaryDiv, missingDiv, duplicateDiv, matchedDiv ); // 重新设置事件监听器 this.setupComparisonDialogEvents(summaryDiv.closest('.comparison-dialog')); } } // 下载单个附件 async downloadSingleAttachment(fileid) { const attachment = this.attachments.find(att => att.fileid === fileid); if (!attachment) { this.showToast('附件不存在', 'error'); return; } try { const dirHandle = await window.showDirectoryPicker(); await this.downloadAttachment(attachment, dirHandle); this.showToast('下载完成', 'success'); } catch (error) { if (error.name !== 'AbortError') { this.showToast('下载失败: ' + error.message, 'error'); } } } // 优化的过滤器应用(减少重复过滤操作) applyFilters() { const { fileTypes, timeRange, searchKeyword } = this.filters; // 预定义文件类型映射 const fileTypeMap = { '文档': new Set(['doc', 'docx', 'pdf', 'txt', 'rtf']), '图片': new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']), '压缩包': new Set(['zip', 'rar', '7z', 'tar', 'gz']), '其他': new Set(['doc', 'docx', 'pdf', 'txt', 'rtf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'zip', 'rar', '7z', 'tar', 'gz']) }; // 预计算时间范围 const timeFilter = timeRange !== '全部' ? (() => { const now = new Date(); const ranges = { '今天': new Date(now.getFullYear(), now.getMonth(), now.getDate()), '本周': new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay()), '本月': new Date(now.getFullYear(), now.getMonth(), 1), '今年': new Date(now.getFullYear(), 0, 1) }; const threshold = ranges[timeRange]; return threshold ? (att => new Date(att.date) >= threshold) : null; })() : null; // 预处理搜索关键词 const keyword = searchKeyword?.toLowerCase(); // 单次遍历应用所有过滤器 const filteredAttachments = this.attachments.filter(attachment => { // 文件类型过滤 if (fileTypes.length > 0 && !fileTypes.includes('全部')) { const ext = this.processFileName(attachment.name, 'getExtension')?.toLowerCase(); const matchesType = fileTypes.some(type => { const typeSet = fileTypeMap[type]; return type === '其他' ? !typeSet.has(ext) : typeSet?.has(ext); }); if (!matchesType) return false; } // 时间过滤 if (timeFilter && !timeFilter(attachment)) return false; // 关键词过滤 if (keyword && !attachment.name.toLowerCase().includes(keyword) && !attachment.mailSubject?.toLowerCase().includes(keyword)) return false; return true; }); this.updateAttachmentList(filteredAttachments); } // 文件类型常量 static FILE_TYPES = { '图片': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'], '文档': ['doc', 'docx', 'pdf', 'txt', 'rtf', 'xls', 'xlsx', 'csv', 'ppt', 'pptx'], '压缩包': ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'], '视频': ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'], '音频': ['mp3', 'wav', 'flac', 'aac', 'ogg'] }; getFileType(filename) { const ext = filename?.split('.').pop()?.toLowerCase() || ''; for (const [type, extensions] of Object.entries(AttachmentManager.FILE_TYPES)) { if (extensions.includes(ext)) return type; } return '其他'; } // Toast图标常量 static TOAST_ICONS = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' }; // 选择器常量 static SELECTORS = { CONTENT_AREA: '#attachment-content-area', OVERLAY_PANEL: '#attachment-manager-overlay', MANAGER_PANEL: '.attachment-manager-panel', OVERLAY_CONTENT: '.attachment-overlay-content', OVERLAY_TOOLBAR: '.attachment-overlay-toolbar', DOWNLOADER_BTN: '#attachment-downloader-btn', DATA_ATTR: '[data-attachment-manager]', CUSTOM_STYLES: 'style[data-attachment-manager]' }; async processMailsInBatches(mails, folderId) { const batchSize = 5; const totalBatches = Math.ceil(mails.length / batchSize); let mailIndex = 1; for (let i = 0; i < totalBatches; i++) { const start = i * batchSize; const batch = mails.slice(start, start + batchSize); for (const mail of batch) { if (mail?.normal_attach?.length > 0) { const mailAttachments = this.processMailAttachments(mail); // 为每个邮件的附件设置mailIndex和attachIndex mailAttachments.forEach((attachment, attachIndex) => { attachment.mailIndex = mailIndex; attachment.attachIndex = attachIndex + 1; }); this.attachments.push(...mailAttachments); mailIndex++; } } // 每处理5封邮件让出主线程 if (i < totalBatches - 1) { await new Promise(resolve => setTimeout(resolve, 1)); } // 更新进度(减少频率) if (i % 2 === 0 || i === totalBatches - 1) { const progress = Math.round(((i + 1) / totalBatches) * 100); this.updateStatus(`正在处理邮件... ${progress}%`); } } // 处理完所有附件后,为每个附件设置fileIndex this.attachments.forEach((attachment, index) => { attachment.fileIndex = index + 1; }); } processMailAttachments(mail) { const sid = this.downloader.sid; const mailData = { mailId: mail.emailid, mailSubject: mail.subject, totime: mail.totime, sender: mail.senders?.item?.[0]?.email, senderName: mail.senders?.item?.[0]?.nick, date: mail.totime }; return mail.normal_attach .filter(attach => attach?.name && attach?.download_url) .map(attach => { const dotIndex = attach.name.lastIndexOf('.'); const nameWithoutExt = dotIndex > 0 ? attach.name.substring(0, dotIndex) : attach.name; const ext = dotIndex > 0 ? attach.name.substring(dotIndex + 1) : ''; let downloadUrl = attach.download_url; if (!downloadUrl.startsWith('http')) { downloadUrl = MAIL_CONSTANTS.BASE_URL + downloadUrl; } if (!downloadUrl.includes('sid=')) { downloadUrl += `${downloadUrl.includes('?') ? '&' : '?'}sid=${sid}`; } return { ...attach, ...mailData, nameWithoutExt, ext, type: ext?.toLowerCase() || '', // 添加type字段,与本地文件保持一致 download_url: downloadUrl }; }); } // 字符串和数据处理工具方法 // 统一数据处理工具 processData(data, operation) { switch (operation) { case 'escapeHtml': const div = document.createElement('div'); div.textContent = data; return div.innerHTML; case 'createSafeDate': if (!data) return null; let date; if (typeof data === 'string') { date = new Date(data); } else if (typeof data === 'number') { date = new Date(data < 10000000000 ? data * 1000 : data); } else { date = new Date(data); } return isNaN(date.getTime()) ? null : date; default: return data; } } // 统一UI工厂 createUI(type, config = {}) { const { className, styles, content, events, children, attributes, id } = config; let element; switch (type) { case 'button': element = document.createElement('button'); if (content) element.innerHTML = content; break; case 'div': element = document.createElement('div'); if (content) element.innerHTML = content; break; case 'span': element = document.createElement('span'); if (content) element.textContent = content; break; case 'dialog': element = document.createElement('div'); StyleManager.applyStyle(element, 'dialogs', 'compareDialog'); break; case 'menu': element = document.createElement('div'); StyleManager.applyStyle(element, 'menus', 'dropdown'); break; case 'menuItem': element = document.createElement('div'); StyleManager.applyStyle(element, 'menus', 'item'); if (content) element.textContent = content; break; case 'card': element = document.createElement('div'); StyleManager.applyStyle(element, 'cards', 'attachment'); break; case 'toolbar': element = document.createElement('div'); StyleManager.applyStyle(element, 'toolbars', 'overlay'); break; default: element = document.createElement(type); } if (id) element.id = id; if (className) element.className = className; if (attributes) { Object.entries(attributes).forEach(([key, value]) => { element.setAttribute(key, value); }); } if (styles) { if (typeof styles === 'string') { // 如果styles是字符串,直接设置cssText element.style.cssText = styles; } else if (typeof styles === 'object' && styles !== null) { // 如果styles是对象,逐个设置属性 Object.entries(styles).forEach(([key, value]) => { // 过滤掉数字索引和无效的CSS属性名 if (typeof key === 'string' && !key.match(/^\d+$/) && (typeof value === 'string' || typeof value === 'number')) { try { element.style[key] = value; } catch (error) { // 静默忽略CSS属性设置错误 } } }); } } if (events) { Object.entries(events).forEach(([event, handler]) => { if (event === 'hover') { element.onmouseover = handler.enter; element.onmouseout = handler.leave; } else { element.addEventListener(event, handler); } }); } if (children) { children.forEach(child => element.appendChild(child)); } return element; } // DOM操作工具 domHelper(operation, ...args) { switch (operation) { case 'query': return document.querySelector(args[0]); case 'queryAll': return document.querySelectorAll(args[0]); case 'remove': const elements = typeof args[0] === 'string' ? document.querySelectorAll(args[0]) : [args[0]]; elements.forEach(el => el?.remove()); break; default: return null; } } getFileIcon(filename) { const ext = filename?.split('.').pop()?.toLowerCase() || ''; const icons = { // 图片 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'bmp': '🖼️', 'webp': '🖼️', // 文档 'doc': '📄', 'docx': '📄', 'pdf': '📄', 'txt': '📄', 'rtf': '📄', // 表格 'xls': '📊', 'xlsx': '📊', 'csv': '📊', // 演示文稿 'ppt': '📑', 'pptx': '📑', // 压缩包 'zip': '🗜️', 'rar': '🗜️', '7z': '🗜️', 'tar': '🗜️', 'gz': '🗜️', // 其他 'default': '📎' }; return icons[ext] || icons.default; } // 获取文件缩略图URL getThumbnailUrl(attachment) { if (!attachment || !attachment.name || !attachment.fileid || !attachment.mailId) { return null; } const ext = attachment.name.split('.').pop()?.toLowerCase() || ''; const supportedImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; const supportedDocTypes = ['pdf', 'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx']; // 只为支持预览的文件类型生成缩略图URL if (!supportedImageTypes.includes(ext) && !supportedDocTypes.includes(ext)) { return null; } // 获取当前的sid const sid = this.downloader?.getSid?.() || ''; if (!sid) { return null; } // 构建缩略图URL,参考您提供的格式 const thumbnailUrl = `https://wx.mail.qq.com/attach/thumbnail?mailid=${attachment.mailId}&fileid=${attachment.fileid}&name=${encodeURIComponent(attachment.name)}&sid=${sid}`; return thumbnailUrl; } // 判断文件是否支持缩略图预览 supportsThumbnail(filename) { const ext = filename?.split('.').pop()?.toLowerCase() || ''; const supportedTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx']; return supportedTypes.includes(ext); } getFilteredAttachments() { if (!Array.isArray(this.attachments)) { return []; } let filtered = this.attachments.filter(attachment => { if (!attachment) { return false; } // 新的文件类型筛选 if (this.currentFilter !== 'all') { const fileType = this.getFileType(attachment.name); switch (this.currentFilter) { case 'images': if (fileType !== '图片') return false; break; case 'documents': if (fileType !== '文档') return false; break; case 'archives': if (fileType !== '压缩包') return false; break; case 'others': if (['图片', '文档', '压缩包', '视频', '音频'].includes(fileType)) return false; break; } } // 原有的日期筛选 if (this.filters.date !== 'all') { const date = this.processData(attachment.date || attachment.totime, 'createSafeDate'); if (!date) return false; // 如果日期无效,过滤掉 const now = new Date(); switch (this.filters.date) { case 'today': if (date.toDateString() !== now.toDateString()) return false; break; case 'week': const weekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000); if (date < weekAgo) return false; break; case 'month': if (date.getMonth() !== now.getMonth() || date.getFullYear() !== now.getFullYear()) return false; break; case 'custom': if (this.filters.dateRange.start && date < this.filters.dateRange.start) return false; if (this.filters.dateRange.end && date > this.filters.dateRange.end) return false; break; } } // 文件大小过滤(使用设置中的配置) const fileSize = attachment.size; if (this.filters.minSize > 0 && fileSize < this.filters.minSize) return false; if (this.filters.maxSize > 0 && fileSize > this.filters.maxSize) return false; // 文件类型过滤(使用设置中的配置) const fileName = attachment.name; const fileExt = fileName?.split('.').pop()?.toLowerCase() || ''; // 允许的文件类型 if (this.filters.allowedTypes && this.filters.allowedTypes.length > 0) { if (!this.filters.allowedTypes.includes(fileExt)) return false; } // 排除的文件类型 if (this.filters.excludedTypes && this.filters.excludedTypes.length > 0) { if (this.filters.excludedTypes.includes(fileExt)) return false; } return true; }); // 应用排序 return this.getSortedAttachments(filtered); } getSortedAttachments(attachments) { return attachments.sort((a, b) => { let comparison = 0; switch (this.currentSort) { case 'name': comparison = a.name.localeCompare(b.name); break; case 'size': comparison = a.size - b.size; break; case 'date': const dateA = a.date || a.totime; const dateB = b.date || b.totime; comparison = dateA && dateB ? dateA - dateB : 0; break; case 'type': const typeA = this.getFileType(a.name); const typeB = this.getFileType(b.name); comparison = typeA.localeCompare(typeB); break; default: // 默认按日期降序 const defaultDateA = a.date || a.totime; const defaultDateB = b.date || b.totime; comparison = defaultDateB - defaultDateA; break; } // 默认降序排列,除非是名称排序 return this.currentSort === 'name' ? comparison : -comparison; }); } showSortMenu(button) { const rect = button.getBoundingClientRect(); const menu = this.createUI('menu', { styles: { top: (rect.bottom + 4) + 'px', left: rect.left + 'px' } }); const sortOptions = [ { value: 'date', label: '按日期' }, { value: 'size', label: '按大小' }, { value: 'name', label: '按名称' } ]; sortOptions.forEach(option => { const item = this.createUI('menuItem', { content: option.label }); if (this.filters.sortBy === option.value) { item.style.background = 'var(--base_gray_005, rgba(20, 46, 77, 0.05))'; const orderIcon = this.createUI('span', { content: this.filters.sortOrder === 'asc' ? '↑' : '↓' }); item.appendChild(orderIcon); } item.addEventListener('click', () => { if (this.filters.sortBy === option.value) { this.filters.sortOrder = this.filters.sortOrder === 'asc' ? 'desc' : 'asc'; } else { this.filters.sortBy = option.value; this.filters.sortOrder = 'desc'; } this.applyFilters(); menu.remove(); }); menu.appendChild(item); }); document.body.appendChild(menu); const closeMenu = (e) => { if (!menu.contains(e.target) && e.target !== button) { menu.remove(); document.removeEventListener('click', closeMenu); } }; setTimeout(() => { document.addEventListener('click', closeMenu); }, 0); } applySearch(keyword) { if (!keyword) { this.displayAttachments(this.attachments); return; } keyword = keyword.toLowerCase(); const filteredAttachments = this.attachments.filter(attachment => attachment.name.toLowerCase().includes(keyword) || (attachment.mailSubject && attachment.mailSubject.toLowerCase().includes(keyword)) || (attachment.senderName && attachment.senderName.toLowerCase().includes(keyword)) ); this.displayAttachments(filteredAttachments); } // 统一选择管理 manageSelection(action = 'toggle') { const checkboxes = this.domHelper('queryAll', `${AttachmentManager.SELECTORS.CONTENT_AREA} input[type="checkbox"]`); const allSelected = this.selectedAttachments.size === checkboxes.length; switch (action) { case 'toggle': if (allSelected) { this.selectedAttachments.clear(); checkboxes.forEach(cb => cb.checked = false); } else { checkboxes.forEach(cb => { cb.checked = true; this.selectedAttachments.add(cb.dataset.attachmentId); }); } break; case 'clear': this.selectedAttachments.clear(); checkboxes.forEach(cb => cb.checked = false); break; case 'selectAll': checkboxes.forEach(cb => { cb.checked = true; this.selectedAttachments.add(cb.dataset.attachmentId); }); break; } this.updateSmartDownloadButton(); } // 创建标准按钮 createButton(type, config = {}) { const { text, onClick, className = '', styles = {} } = config; const buttonStyles = { primary: { padding: '0 24px', height: '32px', background: '#0F7AF5', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s', boxShadow: '0 2px 8px 0 rgba(15,122,245,0.08)' }, secondary: { padding: '0 24px', height: '32px', background: '#fff', color: '#0F7AF5', border: '1.5px solid #0F7AF5', borderRadius: '4px', fontSize: '12px', fontWeight: '600', cursor: 'pointer', transition: 'all 0.2s' }, outline: { padding: '0 16px', height: '32px', background: '#fff', color: '#666', border: '1px solid #d9d9d9', borderRadius: '4px', fontSize: '12px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.2s' } }; return this.createUI('button', { content: text, className, styles: { ...buttonStyles[type], ...styles }, events: { click: onClick, hover: { enter: () => { if (type === 'primary') { event.target.style.background = '#0e66cb'; event.target.style.boxShadow = '0 4px 16px 0 rgba(15,122,245,0.13)'; } else if (type === 'outline') { event.target.style.backgroundColor = '#f5f5f5'; event.target.style.borderColor = '#40a9ff'; } }, leave: () => { if (type === 'primary') { event.target.style.background = '#0F7AF5'; event.target.style.boxShadow = '0 2px 8px 0 rgba(15,122,245,0.08)'; } else if (type === 'outline') { event.target.style.backgroundColor = '#fff'; event.target.style.borderColor = '#d9d9d9'; } } } } }); } // 统一下载方法 async downloadAll() { const filteredAttachments = this.getFilteredAttachments(); await this.performDownload(filteredAttachments, '全部'); } async downloadSelected() { if (this.selectedAttachments.size === 0) { this.showToast('请先选择要下载的附件', 'warning'); return; } const selectedAttachments = this.attachments.filter(att => this.selectedAttachments.has(att.name_md5)); await this.performDownload(selectedAttachments, '选中', true); } async performDownload(attachments, type, clearSelection = false) { if (this.downloading) { this.showToast('已有下载任务正在进行中', 'warning'); return; } if (attachments.length === 0) { this.showToast(`当前没有可下载的${type}附件`, 'warning'); return; } try { const dirHandle = await this.selectDownloadFolder(); this.downloading = true; this.showToast(`准备下载${type} ${attachments.length} 个附件...`, 'info'); this.totalTasksForProgress = attachments.length; this.completedTasksForProgress = 0; this.showProgress(); this.updateDownloadProgress(); const results = await this.downloadWithConcurrency(attachments, dirHandle); const successCount = results.filter(r => !r.error).length; const failCount = results.filter(r => r.error).length; this.showToast( failCount > 0 ? `${type}下载完成。成功: ${successCount},失败: ${failCount}` : `${type} ${successCount} 个附件下载成功完成。`, failCount > 0 ? 'warning' : 'success' ); this.updateStatus('下载处理完毕'); if (clearSelection) { this.manageSelection('clear'); } // 自动启动对比功能(如果下载成功且支持文件系统API) if (successCount > 0 && window.showDirectoryPicker && this.downloadSettings.downloadBehavior?.autoCompareAfterDownload) { this.autoCompareAfterDownload(dirHandle, successCount, failCount, type); } } catch (error) { if (error.name === 'AbortError') { this.showToast('已取消下载', 'info'); } else { this.showToast(`下载过程中发生错误: ${error.message}`, 'error'); } } finally { this.downloading = false; this.hideProgress(); } } // 下载完成后自动对比 async autoCompareAfterDownload(dirHandle, successCount, failCount, type) { try { // 延迟一点时间,确保文件系统写入完成 await new Promise(resolve => setTimeout(resolve, 1000)); this.showToast(`${type}下载完成,正在自动对比本地文件...`, 'info', 2000); // 直接使用下载时的目录句柄进行对比 await this.showComparisonResults(dirHandle); } catch (error) { console.error('自动对比失败:', error); // 根据错误类型给出不同的提示 if (error.name === 'NotAllowedError') { this.showToast('自动对比需要文件系统权限,请手动点击"对比本地"按钮', 'warning'); } else if (error.name === 'AbortError') { // 用户取消了对比,不需要显示错误 return; } else { this.showToast('自动对比失败,可手动点击"对比本地"按钮进行对比', 'warning'); } } } async downloadAttachment(attachment, dirHandle, namingStrategy = null) { const attachmentName = attachment.name || 'unknown_attachment'; try { // 获取附件内容 const response = await this.downloader.fetchAttachment(attachment); // 确定目标文件夹 const targetFolderHandle = await this.getTargetFolder(dirHandle, attachment); // 检查是否支持文件系统访问API if (window.showDirectoryPicker) { try { const fileNamingConfig = this.downloadSettings.fileNaming; const baseFileName = this.generateFileName(attachment, fileNamingConfig, this.attachments, namingStrategy); const finalFileName = await this.handleFileConflict(targetFolderHandle, baseFileName); const fileHandle = await targetFolderHandle.getFileHandle(finalFileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(response.response); await writable.close(); // 验证下载完整性 if (this.downloadSettings.downloadBehavior.verifyDownloads) { const isValid = await this.verifyDownload(fileHandle, response.response.size); if (!isValid) { console.warn(`文件 ${finalFileName} 下载验证失败`); } } return true; } catch (error) { throw error; } } else { try { // 使用GM_download const fileNamingConfig = this.downloadSettings.fileNaming; const blob = response.response; const url = URL.createObjectURL(blob); const fileName = this.generateFileName(attachment, fileNamingConfig, this.attachments, namingStrategy); await new Promise((resolve, reject) => { GM_download({ url: url, name: fileName, saveAs: true, onload: function () { URL.revokeObjectURL(url); resolve(); }, onerror: function (error) { URL.revokeObjectURL(url); reject(error); } }); }); return true; } catch (error) { throw error; } } } catch (error) { throw error; } } // 验证下载完整性 async verifyDownload(fileHandle, expectedSize) { try { const file = await fileHandle.getFile(); const actualSize = file.size; return actualSize === expectedSize; } catch (error) { return false; } } updateDownloadProgress() { if (this.totalTasksForProgress === 0) { const progressElement = document.querySelector('#download-progress-bar'); if (progressElement) { progressElement.style.width = '0%'; } return; } const progress = (this.completedTasksForProgress / this.totalTasksForProgress) * 100; const [progressElement, statusElement] = [ this.domHelper('query', '#download-progress-bar'), this.domHelper('query', '#download-status') ]; if (progressElement) { progressElement.style.width = `${progress}%`; if (progress < 100) { progressElement.style.transition = 'width 0.3s ease-in-out'; } else { progressElement.style.transition = 'none'; } } if (statusElement) { // 确保统计数据有效 const completedSize = this.downloadStats.completedSize || 0; const totalSize = this.downloadStats.totalSize || 0; const speed = this.downloadStats.speed || 0; // 计算剩余时间 const remainingSize = Math.max(0, totalSize - completedSize); const remainingTime = speed > 0 ? remainingSize / speed : 0; // 格式化时间 const formatTime = (seconds) => { if (!seconds || seconds <= 0) return '计算中...'; if (seconds < 60) return `${Math.round(seconds)}秒`; if (seconds < 3600) return `${Math.round(seconds / 60)}分钟`; return `${Math.round(seconds / 3600)}小时`; }; // 更新状态文本 statusElement.innerHTML = ` ${Math.round(progress)}% - ${this.formatData(completedSize, 'size')} / ${this.formatData(totalSize, 'size')} - ${this.formatData(speed, 'size')}/s - 剩余时间: ${formatTime(remainingTime)} `; } } // 统一状态管理 updateStatus(message, showProgress = false) { // 更新工具栏副标题状态 const countInfo = document.getElementById('attachment-count-info'); if (countInfo) { countInfo.textContent = message; } // 更新进度条区域状态(用于下载过程) const status = document.getElementById('download-status'); if (status) { status.textContent = message; status.style.opacity = '0'; setTimeout(() => { status.style.transition = 'opacity 0.3s ease'; status.style.opacity = '1'; }, 10); } if (showProgress) this.showProgress(message); } showProgress(message = '正在下载附件...') { const progressArea = document.getElementById('attachment-progress-area'); if (progressArea) { StyleManager.applyStyle(progressArea, 'progress', 'area'); progressArea.innerHTML = ` <div style="margin-bottom: 8px; font-weight: 500; color: var(--base_gray_100, #13181D);">${message}</div> <div style="background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); border-radius: 4px; height: 8px; overflow: hidden; margin-bottom: 8px;"> <div id="download-progress-bar" style="background: var(--theme_primary, #0F7AF5); height: 100%; width: 0%; transition: width 0.3s ease;"></div> </div> <div id="download-status" style="font-size: 12px; color: var(--base_gray_050, #888);">准备开始...</div> `; } } hideProgress() { const progressArea = document.getElementById('attachment-progress-area'); if (progressArea) { progressArea.style.display = 'none'; progressArea.innerHTML = ''; } } showToast(message, type = 'info', duration = 3000) { const icon = AttachmentManager.TOAST_ICONS[type]; const toast = this.createUI('div', { content: `${icon} ${message}`, styles: { ...StyleManager.getStyles().toasts.base, ...StyleManager.getStyles().toastExtensions.custom, borderLeft: `3px solid ${this.getToastColor(type)}` } }); // 确保动画样式已添加 this.ensureToastAnimations(); document.body.appendChild(toast); setTimeout(() => { if (toast.parentElement) { toast.style.animation = 'slideOut 0.3s ease-out'; setTimeout(() => toast.remove(), 300); } }, duration); } ensureToastAnimations() { // 检查是否已经添加了动画样式 if (document.getElementById('toast-animations')) return; const style = document.createElement('style'); style.id = 'toast-animations'; style.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(style); } ensureVariableSelectorStyles() { // 检查是否已经添加了变量选择器样式 if (document.getElementById('variable-selector-styles')) return; const style = document.createElement('style'); style.id = 'variable-selector-styles'; style.textContent = ` @keyframes slideIn { from { opacity: 0; transform: scale(0.9) translateY(-20px); } to { opacity: 1; transform: scale(1) translateY(0); } } .popup-header { flex-shrink: 0; padding: 24px 28px 16px 28px; border-bottom: 1px solid #f0f0f0; } .template-section { flex-shrink: 0; padding: 20px 28px; background: #fafbfc; border-bottom: 1px solid #e8eaed; } .variables-section { flex: 1; overflow-y: auto; padding: 20px 28px; min-height: 0; } .variables-section::-webkit-scrollbar { width: 6px; } .variables-section::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 3px; } .variables-section::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 3px; } .variables-section::-webkit-scrollbar-thumb:hover { background: #a8a8a8; } .popup-footer { flex-shrink: 0; padding: 16px 28px 24px 28px; border-top: 1px solid #f0f0f0; background: #fafbfc; } .variable-group { margin-bottom: 18px; } .variable-group:last-child { margin-bottom: 0; } .variable-group-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid #e0e0e0; } .variables-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px; } .variable-item { padding: 10px 12px; border: 1px solid #e0e0e0; border-radius: 6px; cursor: pointer; transition: all 0.15s ease; background: white; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .variable-item:hover { background: #f0f8ff; border-color: #007bff; box-shadow: 0 2px 4px rgba(0,123,255,0.1); transform: translateY(-1px); } .variable-key { font-weight: 600; color: #007bff; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; } .variable-name { font-size: 10px; color: #666; background: #f5f5f5; padding: 1px 4px; border-radius: 2px; display: inline-block; } .variable-desc { font-size: 11px; color: #666; margin-top: 2px; line-height: 1.2; } `; document.head.appendChild(style); } getToastColor(type) { const colors = { success: 'var(--chrome_green, #16F761)', error: 'var(--chrome_red, #F73116)', warning: 'var(--chrome_orange, #F7A316)', info: 'var(--theme_primary, #0F7AF5)' }; return colors[type] || colors.info; } // 统一格式化工具 formatData(data, type) { switch (type) { case 'size': if (!data || data === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let size = data; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(2)} ${units[unitIndex]}`; case 'totalSize': const totalSize = data.reduce((sum, att) => sum + att.size, 0); return this.formatData(totalSize, 'size'); default: return data; } } groupAttachmentsByMail(attachments) { const groups = new Map(); for (const attachment of attachments) { const key = attachment.mailId; if (!groups.has(key)) { groups.set(key, { mailId: attachment.mailId, subject: attachment.mailSubject, date: attachment.totime, sender: attachment.sender, senderName: attachment.senderName, attachments: [] }); } groups.get(key).attachments.push(attachment); } return Array.from(groups.values()); } // 下载分组附件 async downloadGroupAttachments(group, groupContainer) { if (this.downloading) { this.showToast('已有下载任务正在进行中', 'warning'); return; } const checkboxes = groupContainer.querySelectorAll('input[type="checkbox"]'); const selectedIds = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.dataset.attachmentId); const attachmentsToDownload = selectedIds.length > 0 ? group.attachments.filter(a => selectedIds.includes(a.name_md5)) : group.attachments; if (!attachmentsToDownload?.length) { this.showToast('此邮件组没有可下载的附件', 'info'); return; } try { const dirHandle = await this.selectDownloadFolder(); this.downloading = true; this.showToast(`准备并发下载邮件组中的 ${attachmentsToDownload.length} 个附件...`, 'info'); this.totalTasksForProgress = attachmentsToDownload.length; this.completedTasksForProgress = 0; this.showProgress(); this.updateDownloadProgress(); const results = await this.downloadWithConcurrency(attachmentsToDownload, dirHandle); const successCount = results.filter(r => !r.error).length; const failCount = results.filter(r => r.error).length; this.showToast( failCount > 0 ? `邮件组下载完成。成功: ${successCount},失败: ${failCount}` : `邮件组中 ${successCount} 个附件下载成功。`, failCount > 0 ? 'warning' : 'success' ); this.updateStatus('下载处理完毕'); // 自动启动对比功能(如果下载成功且支持文件系统API) if (successCount > 0 && window.showDirectoryPicker && this.downloadSettings.downloadBehavior?.autoCompareAfterDownload) { this.autoCompareAfterDownload(dirHandle, successCount, failCount, '邮件组'); } } catch (error) { this.showToast( error.name === 'AbortError' ? '已取消选择文件夹进行分组下载' : `邮件组下载过程中发生错误: ${error.message}`, error.name === 'AbortError' ? 'info' : 'error' ); } finally { this.downloading = false; this.hideProgress(); } } // 统一状态显示方法 showState(type, message = '', container = null) { // 移除已存在的状态 const existingState = document.getElementById('attachment-state'); if (existingState) { existingState.remove(); } const stateDiv = this.createUI('div', { styles: { id: 'attachment-state' } }); stateDiv.id = 'attachment-state'; if (type === 'loading') { const loadingStyles = StyleManager.getStyles().states.loading; stateDiv.style.cssText = loadingStyles; const spinner = this.createUI('div', { styles: StyleManager.getStyles().states.spinner }); const text = this.createUI('div', { content: message || '正在加载附件...', styles: StyleManager.getStyles().progress.text }); stateDiv.appendChild(spinner); stateDiv.appendChild(text); StyleManager.addSpinnerAnimation(); } else if (type === 'error') { const errorStyles = { ...StyleManager.getStyles().toasts.base, ...StyleManager.getStyles().errors.centered, borderLeft: '3px solid var(--chrome_red, #F73116)' }; Object.entries(errorStyles).forEach(([key, value]) => { if (typeof value === 'string' || typeof value === 'number') { stateDiv.style[key] = value; } }); const icon = this.createUI('div', { content: '!', styles: StyleManager.getStyles().texts.errorIcon }); const text = this.createUI('div', { content: message, styles: StyleManager.getStyles().texts.errorText }); stateDiv.appendChild(icon); stateDiv.appendChild(text); setTimeout(() => stateDiv.remove(), 5000); } if (container) { container.style.position = 'relative'; container.appendChild(stateDiv); } } // 获取目标文件夹 async getTargetFolder(baseDirHandle, attachment) { let currentDirHandle = baseDirHandle; switch (this.downloadSettings.folderStructure) { case 'subject': // 按主题组织 const subjectFolderName = this.sanitizeFileName(attachment.mailSubject || attachment.subject || '未知主题', attachment); if (subjectFolderName) { currentDirHandle = await this.getOrCreateFolder(currentDirHandle, subjectFolderName); } break; case 'sender': // 按发件人组织 const senderFolderName = this.sanitizeFileName(attachment.senderEmail || attachment.sender || '未知发件人', attachment); if (senderFolderName) { currentDirHandle = await this.getOrCreateFolder(currentDirHandle, senderFolderName); } break; case 'date': // 按日期组织 (MM-DD格式) const mailDate = attachment.date || attachment.totime ? new Date(typeof (attachment.date || attachment.totime) === 'number' && (attachment.date || attachment.totime) < 10000000000 ? (attachment.date || attachment.totime) * 1000 : (attachment.date || attachment.totime)) : new Date(); const dateFolderName = this.formatDate(mailDate, 'MM-DD'); if (dateFolderName) { currentDirHandle = await this.getOrCreateFolder(currentDirHandle, dateFolderName); } break; case 'custom': // 自定义文件夹结构 - 使用自定义模板 const customTemplate = this.downloadSettings.folderNaming?.customTemplate || '{date:YYYY-MM}/{senderName}'; const folderPaths = this.generateCustomFolderPath(customTemplate, attachment); for (const folderPath of folderPaths) { if (folderPath) { currentDirHandle = await this.getOrCreateFolder(currentDirHandle, folderPath); } } break; case 'flat': // 不创建子文件夹 return currentDirHandle; } return currentDirHandle; } // 生成文件夹名称 generateFolderName(template, attachment) { const variableData = this.buildVariableData(attachment); let folderName = this.replaceVariablesInTemplate(template, variableData); // 清理文件夹名中的无效字符 folderName = this.sanitizeFileName(folderName, attachment); // 如果文件夹名为空或只包含无效字符,使用默认名称 if (!folderName || folderName.trim() === '') { folderName = '未知文件夹'; } return folderName; } // 生成自定义文件夹路径 generateCustomFolderPath(template, attachment) { const variableData = this.buildVariableData(attachment); let fullPath = this.replaceVariablesInTemplate(template, variableData); // 按 / 分割路径 const folderPaths = fullPath.split('/').map(path => { const cleanPath = this.sanitizeFileName(path.trim(), attachment); return cleanPath || '未知文件夹'; }).filter(path => path && path !== ''); return folderPaths; } // 根据总数计算补零位数(最少2位) calculatePaddingDigits(total) { return Math.max(2, total.toString().length); } // 格式化索引号,根据总数动态补零 formatIndex(index, total) { const digits = this.calculatePaddingDigits(total); return String(index || 1).padStart(digits, '0'); } // 构建变量数据 buildVariableData(attachment) { const now = new Date(); const mailDate = attachment.date || attachment.totime ? new Date(typeof (attachment.date || attachment.totime) === 'number' && (attachment.date || attachment.totime) < 10000000000 ? (attachment.date || attachment.totime) * 1000 : (attachment.date || attachment.totime)) : now; // 获取总邮件数和总附件数用于动态补零 const totalMails = this.totalMailCount || (this.attachments ? new Set(this.attachments.map(att => att.mailId)).size : 1); const totalAttachments = this.attachments ? this.attachments.length : 1; return { // 邮件信息(对可能包含非法字符的字段进行预清理) subject: this.sanitizeFileName(attachment.mailSubject || attachment.subject || '未知主题', attachment), sender: this.sanitizeFileName(attachment.sender || '未知发件人', attachment), senderEmail: attachment.senderEmail || '', senderName: this.sanitizeFileName(attachment.senderName || attachment.sender || '未知发件人', attachment), mailIndex: this.formatIndex(attachment.mailIndex, totalMails), folderID: attachment.folderId || '', folderName: this.sanitizeFileName(this.getFolderDisplayName() || '未知文件夹', attachment), mailId: attachment.mailId || '', // 附件信息 fileName: attachment.name || '未知文件', fileNameNoExt: this.processFileName(attachment.name || '未知文件', 'removeExtension'), fileType: this.processFileName(attachment.name || '', 'getExtension'), fileId: attachment.fileid || '', fileIndex: this.formatIndex(attachment.fileIndex, totalAttachments), attachIndex: this.formatIndex(attachment.attachIndex, totalAttachments), size: attachment.size || 0, // 日期时间(保留原始Date对象和格式化字符串) date: mailDate, // 保留原始Date对象用于自定义格式化 time: this.formatDate(mailDate, 'HH-mm-ss'), datetime: this.formatDate(mailDate, 'YYYY-MM-DD_HH-mm-ss'), timestamp: Math.floor(mailDate.getTime() / 1000), year: this.formatDate(mailDate, 'YYYY'), month: this.formatDate(mailDate, 'MM'), monthName: this.formatDate(mailDate, 'MMMM'), day: this.formatDate(mailDate, 'DD'), weekday: this.formatDate(mailDate, 'dddd'), weekdayName: this.formatDate(mailDate, 'ddd'), hour: this.formatDate(mailDate, 'HH'), hour12: this.formatDate(mailDate, 'hh'), minute: this.formatDate(mailDate, 'mm'), second: this.formatDate(mailDate, 'ss'), ampm: this.formatDate(mailDate, 'A'), AMPM: this.formatDate(mailDate, 'A') }; } // 处理文件冲突 async handleFileConflict(folderHandle, fileName) { try { // 检查文件是否存在 try { await folderHandle.getFileHandle(fileName); // 文件已存在,需要处理冲突 const conflictResolution = this.downloadSettings.conflictResolution; switch (conflictResolution) { case 'rename': return this.generateUniqueFileName(folderHandle, fileName); case 'overwrite': return fileName; case 'skip': throw new Error(`文件 ${fileName}已存在,跳过下载`); default: // 默认使用重命名 return this.generateUniqueFileName(folderHandle, fileName); } } catch (error) { // 文件不存在,直接返回原文件名 return fileName; } } catch (error) { throw error; } } async generateUniqueFileName(folderHandle, fileName) { const ext = fileName.split('.').pop(); const baseName = fileName.slice(0, -(ext.length + 1)); let counter = 1; let newFileName = fileName; while (true) { try { await folderHandle.getFileHandle(newFileName); // 文件存在,尝试下一个名称 newFileName = `${baseName}_${counter}.${ext}`; counter++; } catch (error) { // 文件不存在,可以使用这个名称 return newFileName; } } } // 并发下载控制 async downloadWithConcurrency(attachments, dirHandle) { // 预先分析命名策略(如果启用了auto模式)0 let namingStrategy = null; const validation = this.downloadSettings.fileNaming.validation; if (validation?.enabled && validation.fallbackPattern === 'auto') { namingStrategy = this.analyzeAttachmentNaming(attachments, validation.pattern); } const results = []; const tasks = attachments.map(attachment => ({ attachment, status: 'pending', retries: 0, namingStrategy })); this.totalTasksForProgress = tasks.length; this.completedTasksForProgress = 0; this.downloadStats = { startTime: Date.now(), completedSize: 0, totalSize: attachments.reduce((sum, att) => sum + (att.size || 0), 0), speed: 0, lastUpdate: Date.now() }; // 根据用户设置决定使用动态并发还是固定并发 const concurrentSetting = this.downloadSettings.downloadBehavior.concurrentDownloads; const useAutoMode = concurrentSetting === 'auto'; const maxConcurrent = useAutoMode ? this.concurrentControl.currentConcurrent : parseInt(concurrentSetting) || 3; const activeDownloads = new Set(); const processNext = async () => { // 检查是否超过并发限制 const currentLimit = useAutoMode ? this.concurrentControl.currentConcurrent : maxConcurrent; if (activeDownloads.size >= currentLimit) { return; } const pendingTask = tasks.find(t => t.status === 'pending'); if (!pendingTask) return; pendingTask.status = 'processing'; const downloadPromise = this.downloadAttachment(pendingTask.attachment, dirHandle, pendingTask.namingStrategy) .then(() => { pendingTask.status = 'completed'; results.push({ attachment: pendingTask.attachment, error: null }); this.completedTasksForProgress++; // 更新下载统计数据 const attachmentSize = pendingTask.attachment.size || 0; this.updateDownloadStats(attachmentSize); this.updateDownloadProgress(); // 更新成功统计并调整并发数(仅在自动模式下) if (useAutoMode) { this.concurrentControl.successCount++; this.adjustConcurrency(); } activeDownloads.delete(downloadPromise); // 尝试启动更多任务 const currentLimit = useAutoMode ? this.concurrentControl.currentConcurrent : maxConcurrent; while (activeDownloads.size < currentLimit && tasks.some(t => t.status === 'pending')) { processNext(); } }) .catch(error => { if (pendingTask.retries < this.retryCount) { pendingTask.retries++; pendingTask.status = 'pending'; activeDownloads.delete(downloadPromise); // 重试时也要考虑并发限制 const currentLimit = useAutoMode ? this.concurrentControl.currentConcurrent : maxConcurrent; while (activeDownloads.size < currentLimit && tasks.some(t => t.status === 'pending')) { processNext(); } } else { pendingTask.status = 'failed'; results.push({ attachment: pendingTask.attachment, error }); this.completedTasksForProgress++; this.updateDownloadProgress(); // 更新失败统计并调整并发数(仅在自动模式下) if (useAutoMode) { this.concurrentControl.failCount++; this.adjustConcurrency(); } activeDownloads.delete(downloadPromise); // 尝试启动更多任务 const currentLimit = useAutoMode ? this.concurrentControl.currentConcurrent : maxConcurrent; while (activeDownloads.size < currentLimit && tasks.some(t => t.status === 'pending')) { processNext(); } } }); activeDownloads.add(downloadPromise); }; // 启动初始下载任务 const initialConcurrent = Math.min(maxConcurrent, tasks.length); for (let i = 0; i < initialConcurrent; i++) { processNext(); } // 等待所有下载完成 while (activeDownloads.size > 0) { await Promise.race(activeDownloads); } return results; } // 动态调整并发数 adjustConcurrency() { const now = Date.now(); if (!this.concurrentControl.lastAdjustTime || now - this.concurrentControl.lastAdjustTime >= this.concurrentControl.adjustInterval) { const totalCount = this.concurrentControl.successCount + this.concurrentControl.failCount; if (totalCount === 0) return; // 没有足够的数据进行调整 const successRate = this.concurrentControl.successCount / totalCount; const oldConcurrent = this.concurrentControl.currentConcurrent; if (successRate > 0.9) { // 成功率很高,可以增加并发 this.concurrentControl.currentConcurrent = Math.min( this.concurrentControl.currentConcurrent + 1, this.concurrentControl.maxConcurrent ); } else if (successRate < 0.7) { // 成功率较低,减少并发 this.concurrentControl.currentConcurrent = Math.max( this.concurrentControl.currentConcurrent - 1, this.concurrentControl.minConcurrent ); } // 如果并发数发生变化,显示调整信息 if (this.concurrentControl.currentConcurrent !== oldConcurrent) { console.log(`[动态并发] 成功率: ${(successRate * 100).toFixed(1)}%, 并发数: ${oldConcurrent} → ${this.concurrentControl.currentConcurrent}`); } // 重置计数器 this.concurrentControl.successCount = 0; this.concurrentControl.failCount = 0; this.concurrentControl.lastAdjustTime = now; } } // 更新下载统计 updateDownloadStats(completedSize) { const now = Date.now(); // 初始化时间戳 if (!this.downloadStats.lastUpdate) { this.downloadStats.lastUpdate = this.downloadStats.startTime || now; } const timeDiff = (now - this.downloadStats.lastUpdate) / 1000; // 转换为秒 const totalElapsed = (now - this.downloadStats.startTime) / 1000; // 总耗时 this.downloadStats.completedSize += completedSize; this.downloadStats.lastUpdate = now; // 计算平均速度(基于总体进度,更稳定) if (totalElapsed > 0) { this.downloadStats.speed = this.downloadStats.completedSize / totalElapsed; } // 更新状态显示 this.updateStatus(`下载中: ${this.formatData(this.downloadStats.completedSize, 'size')} / ${this.formatData(this.downloadStats.totalSize, 'size')} ` + `(${this.formatData(this.downloadStats.speed, 'size')}/s)` ); } // 智能分组附件 // 优化的智能分组算法(减少重复计算) groupAttachmentsSmartly(attachments) { const smartGroupingConfig = this.downloadSettings.smartGrouping; if (!smartGroupingConfig.enabled) { return this.groupAttachmentsByMail(attachments); } const groups = new Map(); const { minGroupSize, maxGroupSize, groupBy } = smartGroupingConfig; // 预计算分组键生成器 const keyGenerators = groupBy.map(criteria => { switch (criteria) { case 'type': return att => this.getFileType(att.name); case 'date': return att => { const timestamp = att.date || att.totime; if (timestamp) { const date = new Date(typeof timestamp === 'number' && timestamp < 10000000000 ? timestamp * 1000 : timestamp); return !isNaN(date.getTime()) ? this.formatDate(date, 'YYYYMMDD') : ''; } return ''; }; case 'sender': return att => att.sender; default: return () => ''; } }); // 单次遍历生成分组 attachments.forEach(attachment => { const groupKey = keyGenerators.map(gen => gen(attachment)).filter(Boolean).join('_'); if (!groups.has(groupKey)) { groups.set(groupKey, { key: groupKey, attachments: [], type: this.getFileType(attachment.name), date: attachment.date, sender: attachment.sender }); } groups.get(groupKey).attachments.push(attachment); }); // 批量处理分组大小 const result = []; for (const group of groups.values()) { if (group.attachments.length >= minGroupSize && group.attachments.length <= maxGroupSize) { result.push(group); } else { result.push(...this.groupAttachmentsByMail(group.attachments)); } } return result; } // 优化的命名模式解析 parseNamingPattern(pattern, attachment) { if (!pattern || !attachment) return ''; const replacements = { name: attachment.name, fileName: attachment.name, mailSubject: this.sanitizeFileName(attachment.mailSubject || '', attachment), subject: this.sanitizeFileName(attachment.mailSubject || '', attachment), sender: this.sanitizeFileName(attachment.senderName || attachment.sender || '', attachment), senderEmail: attachment.sender || '', mailId: attachment.mailId, attachmentId: attachment.fid, date: attachment.totime ? this.formatDate(new Date(typeof attachment.totime === 'number' && attachment.totime < 10000000000 ? attachment.totime * 1000 : attachment.totime), 'YYYYMMDD') : '', toTime: attachment.totime ? this.formatDate(new Date(typeof attachment.totime === 'number' && attachment.totime < 10000000000 ? attachment.totime * 1000 : attachment.totime), 'YYYYMMDDHHmmss') : '', fileType: this.processFileName(attachment.name, 'getExtension'), size: attachment.size ? this.formatData(attachment.size, 'size') : '' }; return pattern.replace(/\{(\w+)\}/g, (match, key) => replacements[key] || match); } // 生成备用命名前缀 generateFallbackPrefix(fallbackPattern, attachment) { if (!fallbackPattern || !attachment) return ''; switch (fallbackPattern) { case 'mailSubject': return this.sanitizeFileName(attachment.mailSubject || '', attachment); case 'senderEmail': return this.sanitizeFileName(attachment.sender || '', attachment); case 'toTime': return attachment.totime ? this.formatDate(new Date(typeof attachment.totime === 'number' && attachment.totime < 10000000000 ? attachment.totime * 1000 : attachment.totime), 'YYYYMMDDHHmmss') : ''; case 'customTemplate': // 这个会在后面的逻辑中处理 return ''; case 'auto': default: return ''; } } // 优化的智能命名分析(减少重复过滤和计算) analyzeAttachmentNaming(attachments, validationPattern) { if (!attachments?.length) return { strategy: 'default', prefix: '' }; // 创建正则表达式 let regex; try { regex = new RegExp(validationPattern); } catch (error) { return { strategy: 'default', prefix: '' }; } // 单次遍历分析附件 const validAttachments = []; for (const att of attachments) { if (regex.test(att.name)) validAttachments.push(att); } const validCount = validAttachments.length; // 情况1:只有1个附件,或者全部不满足正则 if (attachments.length === 1 || validCount === 0) { return { strategy: 'mailSubject', prefix: '' }; } // 情况2:数量大于2张,且大于1张满足匹配 if (attachments.length >= 2 && validCount > 1) { const commonPrefix = this.findCommonPrefix(validAttachments.map(att => att.name)); if (commonPrefix?.length > 0) { return { strategy: 'commonPrefix', prefix: commonPrefix }; } } // 情况3:只有1个满足匹配 if (validCount === 1) { const extractedPrefix = this.extractNamingPattern(validAttachments[0].name); if (extractedPrefix) { return { strategy: 'extractedPattern', prefix: extractedPrefix }; } } // 默认策略 return { strategy: 'mailSubject', prefix: '' }; } // 优化的公共前缀查找算法 findCommonPrefix(fileNames) { if (!fileNames?.length || fileNames.length < 2) return ''; const separators = new Set(['+', '-', '_', ' ', '.', '(', ')', '[', ']']); let prefix = fileNames[0]; // 逐个比较,提前退出优化 for (let i = 1; i < fileNames.length && prefix.length > 0; i++) { const current = fileNames[i]; const minLen = Math.min(prefix.length, current.length); let j = 0; while (j < minLen && prefix[j] === current[j]) j++; prefix = prefix.substring(0, j); } // 找到最后一个分隔符位置 const lastSepIndex = [...prefix].findLastIndex(char => separators.has(char)); return lastSepIndex > 0 ? prefix.substring(0, lastSepIndex + 1) : prefix; } // 从单个文件名中提取命名模式 extractNamingPattern(fileName) { // 匹配模式:{任意?}{分隔符?}{数字}{分隔符?}{数字?}{分隔符?}{任意?} // 例如:作者+123456+123456789+作品1.jpg -> 作者+123456+123456789+ const patterns = [ // 模式1: 前缀+数字+数字+后缀 (如: 作者+123456+123456789+作品1.jpg) /^(.+?[+\-_\s])(\d+)([+\-_\s])(\d+)([+\-_\s]).*/, // 模式2: 前缀+数字+后缀 (如: 作者+123456+作品1.jpg) /^(.+?[+\-_\s])(\d+)([+\-_\s]).*/, // 模式3: 前缀+数字 (如: 作者123456作品1.jpg) /^(.+?)(\d{6,}).*/ ]; for (const pattern of patterns) { const match = fileName.match(pattern); if (match) { if (pattern === patterns[0]) { // 包含两个数字的情况:返回到第二个数字后的分隔符 return match[1] + match[2] + match[3] + match[4] + match[5]; } else if (pattern === patterns[1]) { // 包含一个数字的情况:返回到数字后的分隔符 return match[1] + match[2] + match[3]; } else { // 简单数字匹配:尝试找到数字前的合理分割点 const beforeNumber = match[1]; const number = match[2]; // 寻找最后一个可能的分隔符 const separators = ['+', '-', '_', ' ']; for (let i = beforeNumber.length - 1; i >= 0; i--) { if (separators.includes(beforeNumber[i])) { return beforeNumber.substring(0, i + 1) + number; } } return beforeNumber + number; } } } return ''; } // 生成文件名(支持智能auto模式) generateFileName(attachment, fileNamingConfig, allAttachments = null, namingStrategy = null) { // 如果没有配置,直接返回清理后的原文件名 if (!fileNamingConfig) { return this.processFileName(attachment.name, 'sanitize', attachment); } let fileName = attachment.name; let needsFallback = false; // 检查是否启用了验证功能 if (fileNamingConfig.validation && fileNamingConfig.validation.enabled && fileNamingConfig.validation.pattern) { try { const regex = new RegExp(fileNamingConfig.validation.pattern); const isValid = regex.test(attachment.name); needsFallback = !isValid; } catch (error) { // 静默忽略正则表达式错误 needsFallback = false; } } // 按照指定顺序构建文件名:1. 命名策略 2. 备用命名前缀 3. 前缀、后缀 const parts = []; const ext = this.processFileName(attachment.name, 'getExtension'); let baseFileName = this.processFileName(attachment.name, 'removeExtension'); // 1. 命名策略(自定义模式或原始文件名) if (fileNamingConfig.useCustomPattern && fileNamingConfig.customPattern) { // 使用自定义命名模板 baseFileName = this.parseNamingPattern(fileNamingConfig.customPattern, attachment); } else if (needsFallback && fileNamingConfig.validation.fallbackPattern) { // 需要使用备用命名策略 if (fileNamingConfig.validation.fallbackPattern === 'auto') { // 使用智能auto模式 return this.generateAutoFileName(attachment, fileNamingConfig, allAttachments, namingStrategy); } else if (fileNamingConfig.validation.fallbackPattern === 'customTemplate' && fileNamingConfig.validation.fallbackTemplate) { // 使用自定义备用模板 baseFileName = this.parseNamingPattern(fileNamingConfig.validation.fallbackTemplate, attachment); } else { // 使用其他备用策略 const fallbackPrefix = this.generateFallbackPrefix(fileNamingConfig.validation.fallbackPattern, attachment); if (fallbackPrefix) { baseFileName = fallbackPrefix + '_' + baseFileName; } } } // 如果不使用自定义模式且不需要备用策略,则使用原始文件名(已在上面设置) // 2. 备用命名前缀(如果启用了验证且需要备用策略,但不是auto或customTemplate) if (needsFallback && fileNamingConfig.validation.fallbackPattern && fileNamingConfig.validation.fallbackPattern !== 'auto' && fileNamingConfig.validation.fallbackPattern !== 'customTemplate') { const fallbackPrefix = this.generateFallbackPrefix(fileNamingConfig.validation.fallbackPattern, attachment); if (fallbackPrefix && !baseFileName.startsWith(fallbackPrefix)) { parts.push(fallbackPrefix); } } // 3. 前缀、后缀 if (fileNamingConfig.prefix) { parts.push(fileNamingConfig.prefix); } // 添加基础文件名 if (baseFileName) { parts.push(baseFileName); } // 后缀 if (fileNamingConfig.suffix) { parts.push(fileNamingConfig.suffix); } // 合并文件名 if (parts.length > 0) { const separator = fileNamingConfig.separator || '_'; const finalBaseName = parts.join(separator); fileName = ext ? `${finalBaseName}.${ext}` : finalBaseName; } else { // 如果没有任何部分,使用原始文件名 fileName = attachment.name; } return this.processFileName(fileName, 'sanitize', attachment); } // 生成智能auto模式文件名 generateAutoFileName(attachment, fileNamingConfig, allAttachments, namingStrategy) { if (!namingStrategy) { // 如果没有提供策略,需要分析所有附件 if (!allAttachments) { // 按照新的命名顺序构建默认文件名 const parts = []; const ext = this.processFileName(attachment.name, 'getExtension'); // 备用命名前缀(邮件主题) if (attachment.mailSubject) { parts.push(attachment.mailSubject); } // 前缀 if (fileNamingConfig.prefix) { parts.push(fileNamingConfig.prefix); } // 基础文件名 const baseFileName = this.processFileName(attachment.name, 'removeExtension'); if (baseFileName) { parts.push(baseFileName); } // 后缀 if (fileNamingConfig.suffix) { parts.push(fileNamingConfig.suffix); } const separator = fileNamingConfig.separator || '_'; const finalBaseName = parts.join(separator); const finalName = ext ? `${finalBaseName}.${ext}` : finalBaseName; return this.processFileName(finalName, 'sanitize', attachment); } namingStrategy = this.analyzeAttachmentNaming(allAttachments, fileNamingConfig.validation.pattern); } // 确保附件对象有预计算的字段(兼容性检查) if (!attachment.nameWithoutExt) { attachment.nameWithoutExt = this.processFileName(attachment.name, 'removeExtension'); } if (!attachment.ext) { attachment.ext = this.processFileName(attachment.name, 'getExtension'); } // 按照新的命名顺序构建文件名 const parts = []; let baseFileName = ''; // 1. 命名策略(智能分析结果) switch (namingStrategy.strategy) { case 'mailSubject': // 使用邮件主题+文件名 baseFileName = `${attachment.mailSubject}_${attachment.nameWithoutExt}`; break; case 'commonPrefix': // 使用公共前缀+原文件名去掉公共部分 const remainingName = attachment.name.startsWith(namingStrategy.prefix) ? attachment.name.substring(namingStrategy.prefix.length) : attachment.name; const remainingWithoutExt = this.processFileName(remainingName, 'removeExtension'); baseFileName = `${namingStrategy.prefix}${remainingWithoutExt}`; break; case 'extractedPattern': // 使用提取的模式+原文件名的后缀部分 if (attachment.nameWithoutExt.startsWith(namingStrategy.prefix)) { const suffix = attachment.nameWithoutExt.substring(namingStrategy.prefix.length); baseFileName = `${namingStrategy.prefix}${suffix}`; } else { baseFileName = `${namingStrategy.prefix}${attachment.nameWithoutExt}`; } break; default: // 默认使用邮件主题+文件名 baseFileName = `${attachment.mailSubject}_${attachment.nameWithoutExt}`; break; } // 2. 备用命名前缀(已在策略中处理) // 3. 前缀、后缀 if (fileNamingConfig.prefix) { parts.push(fileNamingConfig.prefix); } // 添加基础文件名 if (baseFileName) { parts.push(baseFileName); } // 后缀 if (fileNamingConfig.suffix) { parts.push(fileNamingConfig.suffix); } // 合并文件名 const separator = fileNamingConfig.separator || '_'; const finalBaseName = parts.length > 0 ? parts.join(separator) : baseFileName; const finalName = attachment.ext ? `${finalBaseName}.${attachment.ext}` : finalBaseName; const sanitizedName = this.processFileName(finalName, 'sanitize', attachment); // 验证文件名是否符合正则表达式 if (!this.validateFileName(sanitizedName)) { console.log(`文件名 "${sanitizedName}" 不符合验证规则,使用备用策略`); return this.generateFallbackFileName(sanitizedName, attachment); } return sanitizedName; } // 文件工具方法集合 // 统一文件名处理工具 processFileName(fileName, operation, attachment = null) { switch (operation) { case 'removeExtension': const lastDotIndex = fileName.lastIndexOf('.'); return lastDotIndex > 0 ? fileName.substring(0, lastDotIndex) : fileName; case 'getExtension': const match = fileName.match(/\.([^.]+)$/); return match ? match[1].toLowerCase() : null; case 'sanitize': return this.sanitizeFileName(fileName, attachment); default: return fileName; } } // 文件名规范检查和清理方法 sanitizeFileName(fileName, attachment = null) { const validation = this.downloadSettings.fileNaming.validation; // 如果未启用验证,使用基本清理 if (!validation?.enabled) { return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim(); } let cleanName = fileName; // 应用内容替换规则 cleanName = this.applyContentReplacement(cleanName, attachment); const replacementChar = validation.replacementChar || '_'; const removeInvalidChars = validation.removeInvalidChars !== false; // 移除系统禁用字符 if (removeInvalidChars) { cleanName = cleanName.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacementChar); } // 清理多余的空格和替换字符 cleanName = cleanName.replace(/\s+/g, ' ').trim(); // 移除连续的替换字符 if (replacementChar && replacementChar !== ' ') { const escapedChar = replacementChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const duplicatePattern = new RegExp(`${escapedChar}{2,}`, 'g'); cleanName = cleanName.replace(duplicatePattern, replacementChar); } // 清理首尾的特殊字符 cleanName = cleanName.replace(/^[._\-\s]+|[._\-\s]+$/g, ''); // 确保文件名不为空 if (!cleanName || cleanName.trim() === '') { cleanName = 'unnamed_file'; } return cleanName; } // 应用内容替换规则 applyContentReplacement(fileName, attachment = null) { const validation = this.downloadSettings.fileNaming.validation; const contentReplacement = validation?.contentReplacement; // 如果未启用内容替换或没有搜索内容,直接返回原文件名 if (!contentReplacement?.enabled || !contentReplacement.search) { return fileName; } try { let result = fileName; let searchPattern = contentReplacement.search; let replaceContent = contentReplacement.replace ?? ''; // 如果有附件信息,替换变量 if (attachment && replaceContent.includes('{')) { replaceContent = this.parseNamingPattern(replaceContent, attachment); } if (contentReplacement.mode === 'regex') { // 正则表达式模式 const flags = contentReplacement.global ? 'g' : ''; const caseFlags = contentReplacement.caseSensitive ? '' : 'i'; const regex = new RegExp(searchPattern, flags + caseFlags); result = result.replace(regex, replaceContent); } else { // 字符串模式 if (contentReplacement.global) { // 全局替换 if (contentReplacement.caseSensitive) { // 区分大小写 while (result.includes(searchPattern)) { result = result.replace(searchPattern, replaceContent); } } else { // 不区分大小写 const regex = new RegExp(searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); result = result.replace(regex, replaceContent); } } else { // 只替换第一个匹配项 if (contentReplacement.caseSensitive) { const index = result.indexOf(searchPattern); if (index !== -1) { result = result.substring(0, index) + replaceContent + result.substring(index + searchPattern.length); } } else { const lowerResult = result.toLowerCase(); const lowerSearch = searchPattern.toLowerCase(); const index = lowerResult.indexOf(lowerSearch); if (index !== -1) { result = result.substring(0, index) + replaceContent + result.substring(index + searchPattern.length); } } } } return result; } catch (error) { console.warn('内容替换失败:', error); return fileName; // 如果替换失败,返回原文件名 } } // 验证文件名是否符合正则表达式 validateFileName(fileName) { const validation = this.downloadSettings.fileNaming.validation; if (!validation?.enabled || !validation.pattern) { return true; } try { const regex = new RegExp(validation.pattern); const nameWithoutExt = this.processFileName(fileName, 'removeExtension'); return regex.test(nameWithoutExt); } catch (error) { console.warn('正则表达式验证失败:', error); return true; // 如果正则表达式有问题,默认通过验证 } } // 根据备用策略生成文件名 generateFallbackFileName(originalFileName, attachment) { const validation = this.downloadSettings.fileNaming.validation; const fallbackPattern = validation?.fallbackPattern || 'auto'; const ext = this.processFileName(originalFileName, 'getExtension'); let newName; switch (fallbackPattern) { case 'mailSubject': newName = attachment.mailSubject || attachment.subject || 'untitled'; break; case 'senderEmail': newName = attachment.sender || 'unknown_sender'; break; case 'toTime': newName = attachment.totime ? this.formatDate(new Date(typeof attachment.totime === 'number' && attachment.totime < 10000000000 ? attachment.totime * 1000 : attachment.totime), 'YYYYMMDDHHmmss') : Date.now().toString(); break; case 'customTemplate': // 使用自定义模板 const template = validation?.fallbackTemplate || '{subject}_{fileName}'; newName = this.parseNamingPattern(template, attachment); break; case 'auto': default: // 智能分析:尝试提取有意义的部分 const nameWithoutExt = this.processFileName(originalFileName, 'removeExtension'); const numbers = nameWithoutExt.match(/\d+/g); const letters = nameWithoutExt.match(/[a-zA-Z\u4e00-\u9fff]+/g); if (numbers && numbers.length > 0) { newName = numbers.join('_'); } else if (letters && letters.length > 0) { newName = letters.slice(0, 3).join('_'); } else { newName = attachment.mailSubject || attachment.subject || `file_${Date.now()}`; } break; } // 清理生成的文件名 newName = this.sanitizeFileName(newName, attachment); return ext ? `${newName}.${ext}` : newName; } // 统一文件夹选择方法 async selectDownloadFolder() { const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite', startIn: 'downloads' }); const permissionStatus = await dirHandle.requestPermission({ mode: 'readwrite' }); if (permissionStatus !== 'granted') { throw new Error('需要文件夹写入权限才能下载文件'); } return dirHandle; } // 通用的变量替换函数,用于预览和实际文件名生成 replaceVariablesInTemplate(template, variableData) { if (!template || !variableData) return template; let result = template; // 处理带格式的变量,如 {date:YYYY-MM} result = result.replace(/\{(\w+):([^}]+)\}/g, (match, varName, format) => { if (varName === 'date' && variableData.date) { // 处理日期格式化 const mailDate = variableData.date instanceof Date ? variableData.date : new Date(typeof variableData.date === 'number' && variableData.date < 10000000000 ? variableData.date * 1000 : variableData.date); if (mailDate && !isNaN(mailDate.getTime())) { return this.formatDate(mailDate, format); } } return match; // 如果无法处理,保持原样 }); // 处理普通变量,如 {senderName} result = result.replace(/\{(\w+)\}/g, (match, varName) => { const value = variableData[varName]; return (value !== undefined && value !== null) ? String(value) : match; }); return result; } // 优化的自定义变量处理(减少正则表达式创建) processCustomVariables(variables, attachment) { if (!variables?.length) return {}; const result = {}; const attachmentEntries = Object.entries(attachment); variables.forEach(variable => { if (variable.name && variable.value) { let value = variable.value; // 批量替换,避免重复创建正则表达式 attachmentEntries.forEach(([key, val]) => { if (value.includes(`{${key}}`)) { value = value.replaceAll(`{${key}}`, val ?? ''); } }); result[variable.name] = value; } }); return result; } async getOrCreateFolder(parentHandle, folderName) { try { return await parentHandle.getDirectoryHandle(folderName, { create: true }); } catch (error) { throw error; } } formatDate(dateOrTimestamp, format) { if (arguments.length === 1) { const date = this.processData(dateOrTimestamp, 'createSafeDate'); if (!date) return ''; return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } const pad = (num) => String(num).padStart(2, '0'); let date = dateOrTimestamp instanceof Date ? dateOrTimestamp : this.processData(dateOrTimestamp, 'createSafeDate'); if (!date || isNaN(date.getTime())) { return ''; } if (!format || typeof format !== 'string') { return ''; } const year = date.getFullYear(); const month = pad(date.getMonth() + 1); const day = pad(date.getDate()); const hour24 = pad(date.getHours()); const hour12 = pad(date.getHours() % 12 || 12); const minute = pad(date.getMinutes()); const second = pad(date.getSeconds()); return format .replace('YYYY', year) .replace('MM', month) .replace('DD', day) .replace('HH', hour24) .replace('hh', hour12) .replace('mm', minute) .replace('ss', second); } getSmartGroupName(attachment) { const parts = []; const smartGroupingConfig = this.downloadSettings.smartGrouping; if (smartGroupingConfig.groupByType) { const fileType = this.getFileType(attachment.name); if (fileType) { parts.push(fileType.toUpperCase()); } } if (smartGroupingConfig.groupByDate) { const timestamp = attachment.date || attachment.totime || attachment.mailDate; if (timestamp) { const date = this.normalizeTimestamp(timestamp); if (date && !isNaN(date.getTime())) { parts.push(this.formatDate(date, 'YYYY-MM')); } } } return parts.length > 0 ? parts.join('_') : null; } static async _asyncRetry(asyncFn, argsArray, maxRetries, delayMs, retryIdentifier = 'Unnamed Task') { let attempts = 0; while (attempts <= maxRetries) { // Note: attempts <= maxRetries for initial + maxRetries try { if (attempts > 0) { // Delay only for actual retries console.log(`[AsyncRetry] Retrying ${retryIdentifier}, attempt ${attempts} of ${maxRetries} after ${delayMs}ms delay...`); await new Promise(resolve => setTimeout(resolve, delayMs)); } return await asyncFn(...argsArray); } catch (error) { console.warn(`[AsyncRetry] Attempt ${attempts + 1} for ${retryIdentifier} failed:`, error.message); attempts++; // Increment after logging the current attempt that failed if (attempts > maxRetries) { console.error(`[AsyncRetry] All ${maxRetries + 1} attempts for ${retryIdentifier} (initial + ${maxRetries} retries) failed.`); throw error; // Re-throw the last error } } } } updateMailCount(filteredAttachments) { const attachmentList = filteredAttachments || this.attachments; const { mailIds, totalSize } = attachmentList.reduce((acc, att) => { if (att.mailId) acc.mailIds.add(att.mailId); if (att.size) acc.totalSize += att.size; return acc; }, { mailIds: new Set(), totalSize: 0 }); // 优先使用API返回的总邮件数,如果没有则使用计算的邮件数 const mailCount = this.totalMailCount || mailIds.size; const totalAttachmentCount = this.attachments ? this.attachments.length : 0; const statsText = `${mailCount} 封邮件 · ${totalAttachmentCount} 个附件${totalSize > 0 ? ` · ${this.formatData(totalSize, 'size')}` : ''}`; // 更新标题栏中的统计信息 const headerInfo = document.getElementById('attachment-count-info'); if (headerInfo) { headerInfo.textContent = statsText; } // 兼容旧的元素ID ['folder-stats'].forEach(id => { const elem = document.getElementById(id); if (elem) elem.textContent = statsText; }); const mailCountElem = document.getElementById('mail-count'); if (mailCountElem) mailCountElem.textContent = `${mailCount} 封邮件`; } updateAttachmentList(attachments) { if (!this.attachmentList) return; this.attachmentList.innerHTML = ''; // 更新统计信息 this.updateMailCount(attachments); if (attachments.length === 0) { const emptyState = this.createUI('div', { content: ` <svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-bottom: 16px;"> <path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M14 2V8H20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> <div>暂无附件</div> `, styles: StyleManager.getStyles().states.empty }); this.attachmentList.appendChild(emptyState); return; } attachments.forEach(attachment => { const card = this.createAttachmentCard(attachment); this.attachmentList.appendChild(card); }); } toggleAttachmentManager() { // 防抖机制,避免快速重复点击 if (this.toggleInProgress) { console.log('[切换面板] 操作正在进行中,忽略重复点击'); return; } this.toggleInProgress = true; console.log('[切换面板] 开始切换附件管理面板,当前状态:', this.isViewActive ? '激活' : '未激活'); try { if (this.isViewActive) { this.hideAttachmentView(); } else { this.initializeAttachmentView(); } } finally { // 延迟重置防抖标志,避免操作过快 setTimeout(() => { this.toggleInProgress = false; console.log('[切换面板] 防抖标志已重置'); }, 1000); } } async initializeAttachmentView() { if (this.isViewActive) { console.log('[初始化] 面板已激活,跳过重复初始化'); return; } console.log('[初始化] 开始初始化附件视图'); try { await this.prepareForInitialization(); await this.setupUserInterface(); await this.loadAttachmentData(); await this.completeInitialization(); console.log('[初始化] 附件视图初始化完成'); } catch (error) { console.error('[初始化] 初始化失败:', error); this.handleInitializationError(error); } } // 准备初始化 - 检查前置条件和清理状态 async prepareForInitialization() { console.log('[准备初始化] 开始准备初始化'); // 检查前置条件 const sid = this.downloader.sid; if (!sid) { throw new Error('SID为空,请确保已正确登录(不可用)'); } const folderId = this.downloader.getCurrentFolderId(); if (!folderId) { throw new Error('无法获取当前文件夹ID'); } // 清理之前的状态 this.cleanupPreviousState(); // 设置基础状态 this.isViewActive = true; this.currentFolderId = folderId; this.isLoading = true; this.attachments = []; this.selectedAttachments.clear(); console.log('[准备初始化] 初始化准备完成,文件夹ID:', folderId); } // 界面初始化 - 创建UI和显示加载状态 async setupUserInterface() { console.log('[界面初始化] 开始创建用户界面'); // 创建覆盖面板 this.createOverlayPanel(); // 显示初始加载状态 const container = document.querySelector('#attachment-content-area'); if (container) { this.showState('loading', '正在初始化附件管理器...', container); } // 设置全局键盘监听 this.addGlobalKeyListener(); // 更新工具栏状态 this.updateStatus('正在准备加载附件...'); console.log('[界面初始化] 用户界面创建完成'); } async loadAttachmentData() { const container = document.querySelector('#attachment-content-area'); this.showState('loading', '正在获取邮件列表...', container); // 首先获取第一页,同时获取总邮件数 const firstPageResult = await this.downloader.fetchMailList(this.currentFolderId, 0, 50); const totalMailCount = firstPageResult.total; const firstPageMails = firstPageResult.mails; if (!firstPageMails.length) { this.attachments = []; this.totalMailCount = 0; return; } // 保存总邮件数 this.totalMailCount = totalMailCount; // 如果总邮件数大于50,需要获取剩余的邮件 let allMails = [...firstPageMails]; if (totalMailCount > 50) { this.showState('loading', `正在获取 ${totalMailCount} 封邮件...`, container); // 计算需要获取的页数 const totalPages = Math.ceil(totalMailCount / 50); // 并行获取剩余页面 const pagePromises = []; for (let page = 1; page < totalPages; page++) { pagePromises.push(this.downloader.fetchMailList(this.currentFolderId, page, 50)); } try { const results = await Promise.all(pagePromises); results.forEach(result => { if (result.mails && result.mails.length > 0) { allMails.push(...result.mails); } }); } catch (error) { console.error('[获取邮件] 获取剩余页面失败:', error); this.showToast('部分邮件获取失败,将处理已获取的邮件', 'warning'); } } this.showState('loading', `正在处理 ${allMails.length} 封邮件...`, container); this.attachments = []; await this.processMailsInBatches(allMails, this.currentFolderId); } async completeInitialization() { this.displayAttachments(this.attachments); this.updateMailCount(this.attachments); this.updateStatus(`共 ${this.totalMailCount} 封邮件,包含 ${this.attachments.length} 个附件。`); this.updateSmartDownloadButton(); this.isLoading = false; } // 清理之前的状态 cleanupPreviousState() { // 清理选中状态 this.selectedAttachments.clear(); // 清理引用 this.smartDownloadButton = null; this.overlayPanel = null; // 清理定时器 if (this.downloadProgressTimer) { clearInterval(this.downloadProgressTimer); this.downloadProgressTimer = null; } // 移除可能遗留的UI元素 const existingOverlays = document.querySelectorAll('#attachment-manager-overlay'); existingOverlays.forEach(overlay => overlay.remove()); // 移除Toast和菜单 const toasts = document.querySelectorAll('.attachment-toast, .attachment-menu, .attachment-dialog'); toasts.forEach(toast => toast.remove()); } // 处理初始化错误 handleInitializationError(error) { console.error('[错误处理] 处理初始化错误:', error); // 重置状态 this.isViewActive = false; this.isLoading = false; // 清理资源 this.cleanupAttachmentManager(true); // 显示错误信息 const errorMessage = '附件管理器初始化失败: ' + error.message; this.showToast(errorMessage, 'error', 5000); console.log('[错误处理] 错误处理完成'); } hideAttachmentView() { console.log('[隐藏视图] 开始隐藏附件视图'); try { // 设置状态 this.isViewActive = false; this.isLoading = false; // 按顺序清理 this.cleanupAttachmentManager(); this.removeOverlayPanel(); this.restorePageState(); // 确保按钮仍然存在,如果不存在则重新创建 setTimeout(() => { const existingButton = document.querySelector('#attachment-downloader-btn, [data-attachment-manager-btn="true"], .attachment-floating-btn'); if (!existingButton) { console.log('[隐藏视图] 按钮丢失,重新创建'); this.createAndInjectButton(); } }, 500); console.log('[隐藏视图] 附件视图隐藏完成'); } catch (error) { console.error('[隐藏视图] 隐藏过程中出现错误:', error); this.cleanupAttachmentManager(true); try { this.showToast('关闭附件视图时出现问题,已强制清理', 'warning', 3000); } catch (toastError) { console.error('[隐藏视图] 无法显示错误提示:', toastError); } } } // 文件夹变化时的重新初始化 async reinitializeForFolderChange() { console.log('[重新初始化] 开始文件夹变化重新初始化'); try { // 更新当前文件夹ID this.currentFolderId = this.downloader.getCurrentFolderId(); this.isLoading = true; // 清理当前数据 this.attachments = []; this.selectedAttachments.clear(); this.totalMailCount = 0; // 更新工具栏标题 const titleElement = document.querySelector('#attachment-manager-overlay h1'); if (titleElement) { titleElement.textContent = this.getFolderDisplayName(); } // 重新加载数据 await this.loadAttachmentData(); await this.completeInitialization(); console.log('[重新初始化] 文件夹变化重新初始化完成'); } catch (error) { console.error('[重新初始化] 重新初始化失败:', error); this.showToast('切换文件夹失败: ' + error.message, 'error'); // 显示错误状态 const container = document.querySelector('#attachment-content-area'); if (container) { this.showState('error', '切换文件夹失败: ' + error.message, container); } } finally { this.isLoading = false; } } // 统一清理方法 cleanupAttachmentManager(force = false) { try { // 清理选中状态和引用 this.selectedAttachments.clear(); this.smartDownloadButton = null; this.overlayPanel = null; // 清理定时器 if (this.downloadProgressTimer) { clearInterval(this.downloadProgressTimer); this.downloadProgressTimer = null; } // 移除全局键盘事件监听器 this.removeGlobalKeyListener(); // 断开按钮重复检查观察器 if (this.buttonObserver) { this.buttonObserver.disconnect(); this.buttonObserver = null; } // 移除响应式监听器 if (this.resizeListener) { window.removeEventListener('resize', this.resizeListener); this.resizeListener = null; } // 清理浮动按钮样式 const floatingBtnStyles = document.getElementById('attachment-floating-btn-styles'); if (floatingBtnStyles) { floatingBtnStyles.remove(); } // 清理DOM元素 const elementsToRemove = force ? [ '#attachment-manager-overlay', '.attachment-manager-panel', '.attachment-toast', '.attachment-menu', '.attachment-dialog', '[data-attachment-manager]' ] : [ '.attachment-toast', '.attachment-menu', '.attachment-dialog', '[data-attachment-manager]' ]; elementsToRemove.forEach(selector => { const elements = document.querySelectorAll(selector); elements.forEach(element => { // 保护按钮不被删除 if (element.hasAttribute('data-attachment-manager-btn')) { return; } if (selector === '[data-attachment-manager]') { element.removeAttribute('data-attachment-manager'); } else { element.remove(); } }); }); // 清理样式和进度 const customStyles = document.querySelectorAll('style[data-attachment-manager], #attachment-layout-style'); customStyles.forEach(style => style.remove()); this.hideProgress(); // 重置内部状态 this.isLoading = false; this.isViewActive = false; // 恢复页面状态 if (force) { this.restorePageState(); } } catch (error) { // 静默忽略清理错误 } } createWindowHeader() { const header = this.createUI('div', { className: 'attachment-window-header' }); // 窗口标题 const title = this.createUI('div', { styles: 'display: flex; align-items: center; gap: 12px;', content: ` <div style="font-size: 16px; font-weight: 600; color: var(--base_gray_100, #13181D);"> ${this.getFolderDisplayName()} </div> ` }); // 窗口控制按钮 const controls = this.createUI('div', { className: 'attachment-window-controls' }); // 最小化按钮 const minimizeBtn = this.createUI('div', { className: 'attachment-window-button minimize', title: '最小化', events: { click: (e) => { e.stopPropagation(); this.toggleMinimize(); } } }); // 最大化按钮 const maximizeBtn = this.createUI('div', { className: 'attachment-window-button maximize', title: '最大化/还原', events: { click: (e) => { e.stopPropagation(); this.toggleMaximize(); } } }); // 关闭按钮 const closeBtn = this.createUI('div', { className: 'attachment-window-button close', title: '关闭', events: { click: (e) => { e.stopPropagation(); this.hideAttachmentView(); } } }); controls.appendChild(minimizeBtn); controls.appendChild(maximizeBtn); controls.appendChild(closeBtn); header.appendChild(title); header.appendChild(controls); return header; } createOverlayPanel() { this.removeOverlayPanel(); // 添加布局样式 StyleManager.addLayoutStyles(); // 创建背景遮罩 this.overlayBackground = this.createUI('div', { className: 'attachment-floating-overlay', events: { click: (e) => { if (e.target === this.overlayBackground) { this.hideAttachmentView(); } } } }); // 创建浮动窗口 this.overlayPanel = this.createUI('div', { className: 'attachment-floating-window', events: { keydown: (e) => e.key === 'Escape' && (e.preventDefault(), this.hideAttachmentView()) } }); this.overlayPanel.id = AttachmentManager.SELECTORS.OVERLAY_PANEL.slice(1); this.overlayPanel.tabIndex = -1; // 创建窗口标题栏 const header = this.createWindowHeader(); // 创建窗口内容 const content = this.createUI('div', { className: 'attachment-window-content', content: this.createNativeAttachmentView() }); this.overlayPanel.appendChild(header); this.overlayPanel.appendChild(content); this.overlayBackground.appendChild(this.overlayPanel); document.body.appendChild(this.overlayBackground); // 添加拖拽功能 this.addWindowDragFunctionality(header); // 显示动画 requestAnimationFrame(() => { this.overlayBackground.classList.add('show'); this.overlayPanel.classList.add('show'); this.overlayPanel.focus(); }); } addWindowDragFunctionality(header) { let isDragging = false; let startX, startY, startLeft, startTop; header.addEventListener('mousedown', (e) => { if (e.target.classList.contains('attachment-window-button')) return; if (this.overlayPanel.classList.contains('maximized')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = this.overlayPanel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); header.style.cursor = 'grabbing'; e.preventDefault(); }); const handleMouseMove = (e) => { if (!isDragging) return; const deltaX = e.clientX - startX; const deltaY = e.clientY - startY; const newLeft = startLeft + deltaX; const newTop = startTop + deltaY; // 限制窗口不能拖出屏幕 const maxLeft = window.innerWidth - this.overlayPanel.offsetWidth; const maxTop = window.innerHeight - this.overlayPanel.offsetHeight; const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft)); const constrainedTop = Math.max(0, Math.min(newTop, maxTop)); this.overlayPanel.style.left = constrainedLeft + 'px'; this.overlayPanel.style.top = constrainedTop + 'px'; this.overlayPanel.style.transform = 'none'; }; const handleMouseUp = () => { isDragging = false; header.style.cursor = 'move'; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; // 双击标题栏最大化/还原 header.addEventListener('dblclick', (e) => { if (e.target.classList.contains('attachment-window-button')) return; this.toggleMaximize(); }); } toggleMinimize() { this.overlayPanel.classList.toggle('minimized'); this.isMinimized = this.overlayPanel.classList.contains('minimized'); } toggleMaximize() { this.overlayPanel.classList.toggle('maximized'); this.isMaximized = this.overlayPanel.classList.contains('maximized'); if (this.isMaximized) { // 保存当前位置和大小 this.windowState = { left: this.overlayPanel.style.left, top: this.overlayPanel.style.top, width: this.overlayPanel.style.width, height: this.overlayPanel.style.height, transform: this.overlayPanel.style.transform }; } else { // 恢复之前的位置和大小 if (this.windowState) { this.overlayPanel.style.left = this.windowState.left; this.overlayPanel.style.top = this.windowState.top; this.overlayPanel.style.width = this.windowState.width; this.overlayPanel.style.height = this.windowState.height; this.overlayPanel.style.transform = this.windowState.transform; } else { // 如果没有保存的状态,回到中心位置 this.overlayPanel.style.left = ''; this.overlayPanel.style.top = ''; this.overlayPanel.style.width = ''; this.overlayPanel.style.height = ''; this.overlayPanel.style.transform = 'translate(-50%, -50%)'; } } } removeOverlayPanel() { // 移除浮动窗口 if (this.overlayBackground) { // 添加关闭动画 this.overlayBackground.classList.remove('show'); if (this.overlayPanel) { this.overlayPanel.classList.remove('show'); } // 延迟移除元素,等待动画完成 setTimeout(() => { if (this.overlayBackground && this.overlayBackground.parentNode) { this.overlayBackground.remove(); } this.overlayBackground = null; this.overlayPanel = null; }, 300); } // 移除可能遗留的浮动窗口 const existingOverlays = document.querySelectorAll('.attachment-floating-overlay'); existingOverlays.forEach(overlay => overlay.remove()); // 清理引用 this.smartDownloadButton = null; this.windowState = null; this.isMinimized = false; this.isMaximized = false; // 移除可能的高z-index元素 const highZIndexElements = document.querySelectorAll('[style*="z-index: 10000"], [style*="z-index: 99999"]'); highZIndexElements.forEach(element => { // 保护按钮不被删除 if (element.hasAttribute('data-attachment-manager-btn') || element.classList.contains('attachment-floating-btn')) { return; } if (element.classList.contains('attachment-floating-overlay') || element.classList.contains('attachment-floating-window') || element.classList.contains('attachment-toast') || element.classList.contains('attachment-menu') || element.classList.contains('attachment-dialog')) { element.remove(); } }); } restorePageState() { try { // 恢复页面滚动 document.documentElement.style.overflow = ''; document.body.style.overflow = ''; document.documentElement.style.position = ''; document.body.style.position = ''; // 移除可能的遮罩层 const overlays = document.querySelectorAll('[style*="position: fixed"][style*="z-index"]'); overlays.forEach(overlay => { if (overlay.id === 'attachment-manager-overlay' || overlay.classList.contains('attachment-manager-panel') || overlay.classList.contains('attachment-overlay')) { overlay.remove(); } }); // 确保原生页面元素可见和可交互 const mainApp = document.querySelector('#mailMainApp, .mail-main-app, .main-content'); if (mainApp) { mainApp.style.display = ''; mainApp.style.visibility = ''; mainApp.style.pointerEvents = ''; } // 恢复工具栏状态 const toolbar = document.querySelector('.xmail-ui-ellipsis-toolbar'); if (toolbar) { toolbar.style.display = ''; toolbar.style.visibility = ''; toolbar.style.pointerEvents = ''; } // 重新启用页面交互 document.body.style.pointerEvents = ''; // 触发窗口resize事件,确保页面布局正确 setTimeout(() => { window.dispatchEvent(new Event('resize')); }, 100); } catch (error) { // 静默忽略页面状态恢复错误 } } addGlobalKeyListener() { // 移除可能存在的旧监听器 this.removeGlobalKeyListener(); // 创建新的键盘事件处理器 this.globalKeyHandler = (e) => { // 只有在面板激活且不在加载状态时才响应ESC键 if (this.isViewActive && !this.isLoading && e.key === 'Escape') { // 检查是否有其他弹窗或输入框处于焦点状态 const activeElement = document.activeElement; const isInputFocused = activeElement && ( activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.contentEditable === 'true' ); // 检查是否有其他模态框打开 const hasOtherModals = document.querySelector('.attachment-dialog, .variable-selector-overlay'); if (!isInputFocused && !hasOtherModals) { console.log('[键盘监听] ESC键被按下,关闭附件面板'); e.preventDefault(); e.stopPropagation(); this.hideAttachmentView(); } } }; // 添加全局键盘事件监听器 document.addEventListener('keydown', this.globalKeyHandler, true); console.log('[键盘监听] 全局键盘监听器已添加'); } removeGlobalKeyListener() { if (this.globalKeyHandler) { document.removeEventListener('keydown', this.globalKeyHandler, true); this.globalKeyHandler = null; } } updateSmartDownloadButton() { if (!this.smartDownloadButton) return; const selectedCount = this.selectedAttachments.size; const text = selectedCount > 0 ? `下载选中 (${selectedCount})` : '下载全部'; if (this.smartDownloadButton.querySelector('svg')) { this.smartDownloadButton.lastChild.textContent = text; } else { this.smartDownloadButton.textContent = text; } } // 简化的原生附件视图,适配浮动窗口 createNativeAttachmentView() { return ` <div class="mail-list-page-items" style="border: none; box-shadow: none; height: 100%; overflow: hidden;"> <div class="mail-list-page-items-inner" style="border: none; border-radius: 0; box-shadow: none; margin: 0; height: 100%; display: flex; flex-direction: column;"> <div class="xmail-ui-float-scroll" style="border: none; flex: 1; overflow: hidden;"> <div class="ui-float-scroll-body" tabindex="0" style="padding: 0; height: 100%; overflow-y: auto;"> <div id="attachment-content-area" style="padding: 20px; min-height: 100%;"> <!-- Bento Grid统计信息将在这里显示 --> </div> </div> </div> <div id="attachment-progress-area" style="display: none; border: none; margin: 0; position: absolute; bottom: 0; left: 0; right: 0; background: #fff; border-top: 1px solid var(--base_gray_010, #e9e9e9); z-index: 1001;"></div> </div> </div> `; } displayAttachments(attachments) { const container = document.querySelector('#attachment-manager-overlay #attachment-content-area') || document.querySelector('#attachment-content-area'); if (!container) return; container.innerHTML = ''; document.getElementById('attachment-state')?.remove(); if (!attachments?.length) { container.innerHTML = `<div style="text-align: center; padding: 40px; color: #888;">当前文件夹暂无附件</div>`; this.updateMailCount([]); return; } this.displayBentoGrid(attachments, container); this.updateMailCount(attachments); this.updateSmartDownloadButton(); } displayBentoGrid(attachments, container) { const bentoGrid = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoGrid }); // 立即显示的基础统计 const basicStats = this.calculateBasicStats(attachments); // 1. 标题栏 const headerCard = this.createHeaderCard(); bentoGrid.appendChild(headerCard); // 2. 主要功能行(下载卡片 + 快速操作) const mainFunctionRow = this.createMainFunctionRow(); bentoGrid.appendChild(mainFunctionRow); // 添加分隔线 const divider1 = this.createUI('div', { styles: 'height: 1px; background: linear-gradient(90deg, transparent, var(--base_gray_010, rgba(22, 46, 74, 0.1)), transparent); margin: 8px 0;' }); bentoGrid.appendChild(divider1); // 3. 邮件统计卡片 const mailStatsCard = this.createMailStatsCard(basicStats.totalMails); bentoGrid.appendChild(mailStatsCard); // 4. 无附件邮件详细列表 const noAttachmentListCard = this.createNoAttachmentListCard(); bentoGrid.appendChild(noAttachmentListCard); // 5. 文件类型和问题文件统计行 const fileTypeRow = this.createFileTypeRow(); bentoGrid.appendChild(fileTypeRow); // 6. 命名异常附件详细列表 const invalidNamingListCard = this.createInvalidNamingListCard(); bentoGrid.appendChild(invalidNamingListCard); // 7. 重复附件详细列表 const duplicateAttachmentsListCard = this.createDuplicateAttachmentsListCard(); bentoGrid.appendChild(duplicateAttachmentsListCard); container.appendChild(bentoGrid); // 更新文件夹结构预览 this.updateFolderStructurePreview(attachments); // 延迟计算详细统计 const cards = [ headerCard, // 索引0 - 标题栏 mainFunctionRow, // 索引1 - 主要功能行 mailStatsCard, // 索引2 - 邮件统计 noAttachmentListCard, // 索引3 - 无附件邮件列表 fileTypeRow, // 索引4 - 文件类型和问题文件 invalidNamingListCard, // 索引5 - 命名异常列表 duplicateAttachmentsListCard // 索引6 - 重复附件列表 ]; this.calculateDetailedStatsAsync(attachments, cards); } // 基础统计 - 立即计算 calculateBasicStats(attachments) { return { totalMails: new Set(attachments.map(a => a.mailId)).size, totalMailsInFolder: this.totalMailCount }; } // 详细统计 - 异步计算 async calculateDetailedStatsAsync(attachments, cards) { // 初始化详细数据 this.detailData = { noAttachmentMails: [], invalidNamingAttachments: [], duplicateAttachments: [] }; // 分批计算,避免阻塞UI const batchSize = 50; const stats = { totalAttachments: attachments.length, senderCount: 0, noAttachmentMails: 0, duplicateMails: 0, duplicateAttachments: 0, totalSize: 0, largeAttachments: 0, imageCount: 0, archiveCount: 0, documentCount: 0, otherCount: 0, invalidNaming: 0 }; // 更新附件数量 this.updateElementById('attachment-count', this.attachments ? this.attachments.length : attachments.length); // 分批处理附件 for (let i = 0; i < attachments.length; i += batchSize) { const batch = attachments.slice(i, i + batchSize); await this.processBatch(batch, stats); // 让出主线程 await new Promise(resolve => setTimeout(resolve, 1)); } // 计算发件人数量 const senders = new Set(); const mailSubjects = new Map(); const mailStats = await this.getAllMailsForStats(); const allMails = mailStats.mails; for (const mail of allMails) { if (mail.senders?.item?.[0]?.email) { senders.add(mail.senders.item[0].email); } // 统计重复邮件 const subject = mail.subject?.trim().toLowerCase(); if (subject) { mailSubjects.set(subject, (mailSubjects.get(subject) ?? 0) + 1); } } stats.senderCount = senders.size; stats.totalMailsInFolder = mailStats.totalMails; // 保存无附件邮件详细数据 this.detailData.noAttachmentMails = allMails.filter(mail => !mail.attach || mail.attach != 1); stats.noAttachmentMails = this.detailData.noAttachmentMails.length; stats.duplicateMails = Array.from(mailSubjects.values()).filter(count => count > 1).length; // 计算重复附件数量(基于文件名和大小) const attachmentMap = new Map(); const attachmentGroups = new Map(); attachments.forEach(attachment => { const key = `${attachment.name}_${attachment.size}`; attachmentMap.set(key, (attachmentMap.get(key) ?? 0) + 1); if (!attachmentGroups.has(key)) { attachmentGroups.set(key, []); } attachmentGroups.get(key).push(attachment); }); // 收集重复文件详细信息 this.detailData.duplicateAttachments = []; for (const [key, attachments] of attachmentGroups.entries()) { if (attachments.length > 1) { this.detailData.duplicateAttachments.push(...attachments.map(attachment => ({ ...attachment, duplicateKey: key, duplicateCount: attachments.length }))); } } stats.duplicateAttachments = Array.from(attachmentMap.values()).filter(count => count > 1).length; // 更新主下载卡片 this.updateElementById('attachment-count', this.attachments ? this.attachments.length : attachments.length); this.updateElementById('total-size', `总大小 ${this.formatFileSize(stats.totalSize)}`); // 更新统计概览卡片 this.updateElementById('quick-mail-count', stats.totalMailsInFolder || this.totalMailCount || '计算中...'); this.updateElementById('quick-sender-count', stats.senderCount || '计算中...'); this.updateElementById('quick-no-attachment-mails', stats.noAttachmentMails || '计算中...'); // 更新警告卡片 this.updateElementById('no-attachment-mails', stats.noAttachmentMails); // 更新邮件统计卡片 this.updateElementById('sender-count', stats.senderCount); // 更新文件类型统计 this.updateElementById('image-files', stats.imageCount); this.updateElementById('archive-files', stats.archiveCount); this.updateElementById('document-files', stats.documentCount); this.updateElementById('other-files', stats.otherCount); // 更新问题文件统计 this.updateElementById('invalid-naming', stats.invalidNaming); this.updateElementById('large-files', stats.largeAttachments); this.updateElementById('duplicate-attachments', stats.duplicateAttachments); this.updateElementById('duplicate-mails', stats.duplicateMails); // 更新详细列表 this.updateNoAttachmentList(); this.updateInvalidNamingList(); this.updateDuplicateAttachmentsList(); } async processBatch(batch, stats) { const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2']; const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico']; const documentExts = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf']; // 确保详细数据数组存在 if (!this.detailData.invalidNamingAttachments) { this.detailData.invalidNamingAttachments = []; } batch.forEach(attachment => { // 计算文件大小 const size = parseInt(attachment.size) || parseInt(attachment.filesize); stats.totalSize += size; // 超大附件 (>10MB) if (size > 10 * 1024 * 1024) { stats.largeAttachments++; } // 文件类型统计 const ext = attachment.ext || this.processFileName(attachment.name, 'getExtension'); const extLower = ext?.toLowerCase() || ''; if (imageExts.includes(extLower)) { stats.imageCount++; } else if (archiveExts.includes(extLower)) { stats.archiveCount++; } else if (documentExts.includes(extLower)) { stats.documentCount++; } else { stats.otherCount++; } // 命名异常检查 - 使用设置中的验证规则 const fileName = attachment.name; const validationResult = this.checkFileNameValidation(fileName); if (!validationResult.isValid) { stats.invalidNaming++; this.detailData.invalidNamingAttachments.push({ ...attachment, invalidReason: validationResult.reason }); } }); } // 检查文件名是否符合验证规则 checkFileNameValidation(fileName) { const validation = this.downloadSettings.fileNaming.validation; // 如果验证未开启,则认为所有文件名都是有效的 if (!validation?.enabled) { return { isValid: true, reason: '' }; } // 如果没有设置验证模式,使用默认的基本检查 if (!validation.pattern) { // 基本的文件名检查 if (!fileName || fileName.trim() === '') { return { isValid: false, reason: '文件名为空' }; } if (!fileName.includes('.')) { return { isValid: false, reason: '没有文件扩展名' }; } return { isValid: true, reason: '' }; } try { const regex = new RegExp(validation.pattern); const nameWithoutExt = this.processFileName(fileName, 'removeExtension'); if (!regex.test(nameWithoutExt)) { return { isValid: false, reason: `不符合验证规则: ${validation.pattern}` }; } return { isValid: true, reason: '' }; } catch (error) { console.warn('正则表达式验证失败:', error); // 如果正则表达式有问题,使用基本检查 if (!fileName.includes('.')) { return { isValid: false, reason: '没有文件扩展名' }; } return { isValid: true, reason: '' }; } } async getAllMailsForStats() { try { const folderId = this.downloader.getCurrentFolderId(); // 获取更多邮件数据以提高统计准确性,最多获取前5页(250封邮件) const allMails = await this.downloader.getMailsWithPagination(folderId, 5); // 同时获取总数信息 const firstPageResult = await this.downloader.fetchMailList(folderId, 0, 50); return { mails: allMails, totalMails: firstPageResult.total, unreadMails: firstPageResult.unread }; } catch (error) { console.warn('获取邮件统计失败:', error); return { mails: [], totalMails: 0, unreadMails: 0 }; } } // 更新无附件邮件列表 updateNoAttachmentList() { const container = document.getElementById('no-attachment-list-container'); const listCard = container?.closest('.bento-card'); if (!container) return; const data = this.detailData.noAttachmentMails; if (data.length === 0) { container.innerHTML = ` <div style="text-align: center; padding: 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 暂无无附件邮件 </div> `; if (listCard) listCard.style.display = 'none'; return; } // 显示列表卡片 if (listCard) listCard.style.display = 'block'; // 只显示前5条,超过5条显示"查看更多" const displayData = data.slice(0, 5); const hasMore = data.length > 5; // 清空容器 container.innerHTML = ''; // 创建主容器 const mainContainer = document.createElement('div'); mainContainer.style.cssText = 'display: flex; flex-direction: column; gap: 8px;'; // 添加邮件项目 displayData.forEach((mail, index) => { const mailElement = this.renderCompactMailItem(mail, index); mainContainer.appendChild(mailElement); }); // 添加"更多"提示 if (hasMore) { const moreDiv = document.createElement('div'); moreDiv.style.cssText = 'padding: 12px; text-align: center; border-top: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); margin-top: 8px;'; moreDiv.innerHTML = ` <span style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 还有 ${data.length - 5} 封邮件未显示 </span> `; mainContainer.appendChild(moreDiv); } container.appendChild(mainContainer); } // 更新命名异常附件列表 updateInvalidNamingList() { const container = document.getElementById('invalid-naming-list-container'); const listCard = container?.closest('.bento-card'); if (!container) return; const data = this.detailData.invalidNamingAttachments; const validation = this.downloadSettings.fileNaming.validation; // 检查验证规则状态 const validationStatus = this.getValidationStatus(); if (data.length === 0) { container.innerHTML = ` <div style="text-align: center; padding: 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> <div style="margin-bottom: 8px;">暂无命名异常附件</div> <div style="font-size: 11px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5));"> ${validationStatus} </div> </div> `; if (listCard) listCard.style.display = 'none'; return; } // 显示列表卡片 if (listCard) listCard.style.display = 'block'; // 只显示前5条,超过5条显示"查看更多" const displayData = data.slice(0, 5); const hasMore = data.length > 5; // 清空容器 container.innerHTML = ''; // 创建验证规则提示 const validationInfo = document.createElement('div'); validationInfo.style.cssText = 'margin-bottom: 12px; padding: 8px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); border-radius: 6px; border-left: 3px solid var(--theme_primary, #007bff);'; validationInfo.innerHTML = ` <div style="font-size: 12px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); margin-bottom: 2px;">当前验证规则</div> <div style="font-size: 11px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));">${validationStatus}</div> `; // 创建主容器 const mainContainer = document.createElement('div'); mainContainer.style.cssText = 'display: flex; flex-direction: column; gap: 8px;'; // 添加附件项目 displayData.forEach((attachment, index) => { const attachmentElement = this.renderCompactAttachmentItem(attachment, index); mainContainer.appendChild(attachmentElement); }); // 添加"更多"提示 if (hasMore) { const moreDiv = document.createElement('div'); moreDiv.style.cssText = 'padding: 12px; text-align: center; border-top: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); margin-top: 8px;'; moreDiv.innerHTML = ` <span style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 还有 ${data.length - 5} 个异常附件未显示 </span> `; mainContainer.appendChild(moreDiv); } container.appendChild(validationInfo); container.appendChild(mainContainer); // 验证设置按钮事件处理已在卡片创建时添加 } // 获取验证规则状态描述 getValidationStatus() { const validation = this.downloadSettings.fileNaming.validation; if (!validation?.enabled) { return '验证规则已关闭 - 仅检查基本文件名格式'; } if (!validation.pattern) { return '验证规则已开启 - 使用默认基本检查'; } return `验证规则: ${validation.pattern}`; } // 更新重复附件列表 updateDuplicateAttachmentsList() { const container = document.getElementById('duplicate-attachments-list-container'); const listCard = container?.closest('.bento-card'); if (!container) return; const data = this.detailData.duplicateAttachments; if (data.length === 0) { container.innerHTML = ` <div style="text-align: center; padding: 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 暂无重复附件 </div> `; if (listCard) listCard.style.display = 'none'; return; } // 显示列表卡片 if (listCard) listCard.style.display = 'block'; // 按重复组分组显示 const groupedData = this.groupDuplicateAttachments(data); const displayGroups = groupedData.slice(0, 3); // 只显示前3组 const hasMore = groupedData.length > 3; // 清空容器 container.innerHTML = ''; // 创建主容器 const mainContainer = document.createElement('div'); mainContainer.style.cssText = 'display: flex; flex-direction: column; gap: 12px;'; // 添加组元素 displayGroups.forEach((group, index) => { const groupElement = this.renderDuplicateGroup(group, index); mainContainer.appendChild(groupElement); }); // 添加"更多"提示 if (hasMore) { const moreDiv = document.createElement('div'); moreDiv.style.cssText = 'padding: 12px; text-align: center; border-top: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); margin-top: 8px;'; moreDiv.innerHTML = ` <span style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 还有 ${groupedData.length - 3} 组重复文件未显示 </span> `; mainContainer.appendChild(moreDiv); } container.appendChild(mainContainer); } // 将重复附件按组分组 groupDuplicateAttachments(duplicateAttachments) { const groups = new Map(); duplicateAttachments.forEach(attachment => { const key = attachment.duplicateKey; if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(attachment); }); return Array.from(groups.values()); } // 渲染重复文件组 renderDuplicateGroup(group, groupIndex) { if (group.length === 0) return document.createElement('div'); const fileName = group[0].name; const fileSize = this.formatFileSize(parseInt(group[0].size) || parseInt(group[0].filesize)); const duplicateCount = group.length; // 找出最新的附件(按时间排序) const sortedGroup = [...group].sort((a, b) => { const dateA = this.processData(a.date || a.totime, 'createSafeDate'); const dateB = this.processData(b.date || b.totime, 'createSafeDate'); if (!dateA && !dateB) return 0; if (!dateA) return 1; if (!dateB) return -1; return dateB.getTime() - dateA.getTime(); }); // 标记最新的附件 const latestAttachment = sortedGroup[0]; const groupWithLatestFlag = group.map(attachment => ({ ...attachment, isLatest: attachment === latestAttachment })); const groupDiv = document.createElement('div'); groupDiv.style.cssText = 'padding: 12px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); border-radius: 6px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1));'; // 创建头部信息 const headerDiv = document.createElement('div'); headerDiv.style.cssText = 'display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;'; headerDiv.innerHTML = ` <div style="flex: 1; min-width: 0;"> <div style="font-weight: 500; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px;">${fileName}</div> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); display: flex; align-items: center; gap: 8px;"> <span style="background: var(--theme_warning_light, rgba(255, 193, 7, 0.1)); color: var(--theme_warning, #ffc107); padding: 1px 4px; border-radius: 3px; font-size: 11px;">重复 ${duplicateCount} 次</span> <span>${fileSize}</span> </div> </div> `; // 创建附件列表容器 const itemsContainer = document.createElement('div'); itemsContainer.style.cssText = 'display: flex; flex-direction: column; gap: 4px; max-height: 120px; overflow-y: auto;'; // 添加附件项目 groupWithLatestFlag.forEach((attachment, index) => { const itemElement = this.renderCompactDuplicateAttachmentItem(attachment, index); itemsContainer.appendChild(itemElement); }); groupDiv.appendChild(headerDiv); groupDiv.appendChild(itemsContainer); return groupDiv; } // 渲染紧凑的重复附件项目 renderCompactDuplicateAttachmentItem(attachment, index) { const mailSubject = attachment.mailSubject; const mailId = attachment.mailId; const attachmentDate = this.processData(attachment.date || attachment.totime, 'createSafeDate'); const dateStr = attachmentDate ? this.formatDate(attachmentDate, 'MM-DD HH:mm') : '未知时间'; const isLatest = attachment.isLatest; const senderName = attachment.senderName || '未知发件人'; const senderEmail = attachment.senderEmail || attachment.sender || ''; const div = document.createElement('div'); div.style.cssText = `padding: 8px; background: white; border-radius: 4px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: pointer; transition: all 0.2s;`; div.innerHTML = ` <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;"> <div style="flex: 1; min-width: 0;"> <div style="font-size: 12px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; margin-bottom: 2px;"> <span>来自邮件: ${mailSubject}</span> ${isLatest ? '<span style="background: var(--theme_success, #28a745); color: white; padding: 1px 4px; border-radius: 3px; font-size: 10px; font-weight: 500;">最新</span>' : ''} </div> <div style="font-size: 10px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); margin-bottom: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> 发件人: ${senderName}${senderEmail ? ` (${senderEmail})` : ''} </div> <div style="font-size: 10px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> 时间: ${dateStr} </div> </div> <div style="color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); font-size: 10px;"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </div> </div> `; // 添加事件监听器 div.addEventListener('click', () => this.openMail(mailId)); div.addEventListener('mouseenter', (e) => e.target.style.background = 'var(--base_gray_005, rgba(22, 46, 74, 0.05))'); div.addEventListener('mouseleave', (e) => e.target.style.background = 'white'); return div; } // 渲染紧凑的邮件项目 renderCompactMailItem(mail, index) { const subject = mail.subject; const senderData = mail.senders?.item?.[0]; const senderName = senderData?.name || senderData?.nick || '未知发件人'; const senderEmail = senderData?.email || ''; // 修复时间字段:使用totime而不是date const timestamp = mail.totime || mail.date; const date = timestamp ? this.formatDate(this.processData(timestamp, 'createSafeDate'), 'MM-DD HH:mm') : '未知时间'; // 修复邮件ID字段:使用emailid而不是id const mailId = mail.emailid || mail.id; const div = document.createElement('div'); div.style.cssText = `padding: 12px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); border-radius: 6px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: pointer; transition: all 0.2s;`; div.innerHTML = ` <div style="display: flex; align-items: center; justify-content: space-between; gap: 12px;"> <div style="flex: 1; min-width: 0;"> <div style="font-weight: 500; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px;">${subject}</div> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">发件人: ${senderName}${senderEmail ? ` (${senderEmail})` : ''}</div> <div style="font-size: 11px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">时间: ${date}</div> </div> <div style="color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); font-size: 12px;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </div> </div> `; // 添加事件监听器 div.addEventListener('click', () => this.openMail(mailId)); div.addEventListener('mouseenter', (e) => e.target.style.background = 'var(--base_gray_010, rgba(22, 46, 74, 0.1))'); div.addEventListener('mouseleave', (e) => e.target.style.background = 'var(--base_gray_005, rgba(22, 46, 74, 0.05))'); return div; } // 渲染紧凑的附件项目 renderCompactAttachmentItem(attachment, index) { const fileName = attachment.name; const fileSize = this.formatFileSize(parseInt(attachment.size) || parseInt(attachment.filesize)); const invalidReason = attachment.invalidReason; const mailSubject = attachment.mailSubject; const mailId = attachment.mailId; const senderName = attachment.senderName || '未知发件人'; const senderEmail = attachment.senderEmail || attachment.sender || ''; const attachmentDate = this.processData(attachment.date || attachment.totime, 'createSafeDate'); const dateStr = attachmentDate ? this.formatDate(attachmentDate, 'MM-DD HH:mm') : '未知时间'; // 获取文件图标和缩略图 const fileIcon = this.getFileIcon(fileName); const thumbnailUrl = this.getThumbnailUrl(attachment); const supportsThumbnail = this.supportsThumbnail(fileName); const div = document.createElement('div'); div.style.cssText = `padding: 12px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); border-radius: 6px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: pointer; transition: all 0.2s;`; div.innerHTML = ` <div style="display: flex; align-items: center; justify-content: space-between; gap: 12px;"> <!-- 文件图标/缩略图 --> <div class="file-preview-container" style="width: 36px; height: 36px; border-radius: 6px; background: var(--base_gray_010, rgba(22, 46, 74, 0.1)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;"> ${supportsThumbnail && thumbnailUrl ? `<img class="file-thumbnail" src="${thumbnailUrl}" alt="${fileName}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 5px; opacity: 0; transition: opacity 0.3s;" data-fallback-icon="${fileIcon}"> <span class="file-icon-fallback" style="font-size: 14px; display: none; position: absolute; width: 100%; height: 100%; align-items: center; justify-content: center;">${fileIcon}</span>` : `<span style="font-size: 14px;">${fileIcon}</span>` } </div> <div style="flex: 1; min-width: 0;"> <div style="font-weight: 500; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; display: flex; align-items: center; gap: 6px;"> <span>${fileName}</span> ${invalidReason ? `<span style="background: var(--theme_danger_light, rgba(220, 53, 69, 0.1)); color: var(--theme_danger, #dc3545); padding: 1px 4px; border-radius: 3px; font-size: 10px;">${invalidReason}</span>` : ''} </div> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">大小: ${fileSize} · 时间: ${dateStr}</div> <div style="font-size: 11px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); margin-bottom: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">来自邮件: ${mailSubject}</div> <div style="font-size: 11px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">发件人: ${senderName}${senderEmail ? ` (${senderEmail})` : ''}</div> </div> <div style="color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); font-size: 12px;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </div> </div> `; // 添加事件监听器 div.addEventListener('click', () => this.openMail(mailId)); div.addEventListener('mouseenter', (e) => e.target.style.background = 'var(--base_gray_010, rgba(22, 46, 74, 0.1))'); div.addEventListener('mouseleave', (e) => e.target.style.background = 'var(--base_gray_005, rgba(22, 46, 74, 0.05))'); // 为缩略图添加事件监听器 const thumbnailImg = div.querySelector('.file-thumbnail'); const fallbackIcon = div.querySelector('.file-icon-fallback'); if (thumbnailImg && fallbackIcon) { // 图片加载成功 thumbnailImg.addEventListener('load', () => { thumbnailImg.style.opacity = '1'; }); // 图片加载失败,显示fallback图标 thumbnailImg.addEventListener('error', () => { thumbnailImg.style.display = 'none'; fallbackIcon.style.display = 'flex'; }); } return div; } // 显示详细列表 showDetailList(type) { let title = ''; let data = []; let itemRenderer = null; switch (type) { case 'no-attachment': case 'no-attachment-mails': title = '无附件邮件完整列表'; data = this.detailData?.noAttachmentMails || []; itemRenderer = this.renderMailItem.bind(this); break; case 'invalid-naming': title = '命名异常附件完整列表'; data = this.detailData?.invalidNamingAttachments || []; itemRenderer = this.renderAttachmentItem.bind(this); break; case 'duplicate-attachments': title = '重复附件完整列表'; // 将重复附件按组分组显示 const duplicateAttachments = this.detailData?.duplicateAttachments || []; data = this.groupDuplicateAttachments(duplicateAttachments); itemRenderer = this.renderDuplicateGroupItem.bind(this); break; default: console.warn('未知的列表类型:', type); return; } if (data.length === 0) { this.showToast(`暂无${title.replace('完整列表', '')}数据`, 'info'); return; } this.createDetailListDialog(title, data, itemRenderer); } // 为重复附件添加最新标记 addLatestFlagToDuplicateAttachments(duplicateAttachments) { if (!duplicateAttachments || duplicateAttachments.length === 0) { return []; } // 按重复组分组 const groups = new Map(); duplicateAttachments.forEach(attachment => { const key = attachment.duplicateKey; if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(attachment); }); const result = []; // 为每个组标记最新的附件 for (const [key, group] of groups.entries()) { // 按时间排序找出最新的 const sortedGroup = [...group].sort((a, b) => { const dateA = this.processData(a.date || a.totime, 'createSafeDate'); const dateB = this.processData(b.date || b.totime, 'createSafeDate'); if (!dateA && !dateB) return 0; if (!dateA) return 1; if (!dateB) return -1; return dateB.getTime() - dateA.getTime(); }); const latestAttachment = sortedGroup[0]; // 为每个附件添加isLatest标记 const groupWithLatestFlag = group.map(attachment => ({ ...attachment, isLatest: attachment === latestAttachment })); result.push(...groupWithLatestFlag); } return result; } // 创建详细列表对话框 createDetailListDialog(title, data, itemRenderer) { const dialog = this.createUI('div', { styles: 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px);', content: ` <div style="background: white; border-radius: 12px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); width: 95%; max-width: 1000px; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden;"> <!-- 头部区域 --> <div style="padding: 24px; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); display: flex; align-items: center; justify-content: space-between;"> <div style="flex: 1;"> <h3 style="margin: 0; font-size: 20px; font-weight: 600; color: var(--base_gray_090, rgba(22, 30, 38, 0.9));">${title}</h3> <p id="list-stats" style="margin: 4px 0 0 0; font-size: 14px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> ${title.includes('重复附件') ? `共 <span id="total-count">${data.length}</span> 组重复文件 · 显示 <span id="showing-count">${Math.min(data.length, 20)}</span> 组` : `共 <span id="total-count">${data.length}</span> 项 · 显示 <span id="showing-count">${Math.min(data.length, 20)}</span> 项` } </p> </div> <button id="close-detail-dialog" style="background: none; border: none; font-size: 24px; cursor: pointer; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); padding: 8px; border-radius: 6px; transition: all 0.2s; margin-left: 16px;">×</button> </div> <!-- 工具栏区域 --> <div style="padding: 16px 24px; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); background: var(--base_gray_005, rgba(22, 46, 74, 0.05));"> <div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;"> <!-- 搜索框 --> <div style="flex: 1; min-width: 200px; position: relative;"> <input id="detail-search" type="text" placeholder="搜索..." style="width: 70%; padding: 8px 12px 8px 36px; border: 1px solid var(--base_gray_020, rgba(22, 46, 74, 0.2)); border-radius: 6px; font-size: 14px; outline: none; transition: all 0.2s;"> <svg style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5));" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="11" cy="11" r="8"></circle> <path d="m21 21-4.35-4.35"></path> </svg> </div> <!-- 排序选择 --> <select id="detail-sort" style="padding: 8px 12px; border: 1px solid var(--base_gray_020, rgba(22, 46, 74, 0.2)); border-radius: 6px; font-size: 14px; background: white; cursor: pointer;"> <option value="default">默认排序</option> <option value="name">按名称排序</option> <option value="time">按时间排序</option> <option value="size">按大小排序</option> </select> <!-- 显示数量选择 --> <select id="detail-page-size" style="padding: 8px 12px; border: 1px solid var(--base_gray_020, rgba(22, 46, 74, 0.2)); border-radius: 6px; font-size: 14px; background: white; cursor: pointer;"> <option value="20">显示 20 项</option> <option value="50">显示 50 项</option> <option value="100">显示 100 项</option> <option value="all">显示全部</option> </select> <!-- 刷新按钮 --> <button id="detail-refresh" style="padding: 8px 12px; background: var(--theme_primary, #0F7AF5); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 6px; transition: all 0.2s;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <polyline points="23 4 23 10 17 10"></polyline> <polyline points="1 20 1 14 7 14"></polyline> <path d="m3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path> </svg> 刷新 </button> </div> </div> <!-- 列表内容区域 --> <div style="flex: 1; overflow-y: auto; padding: 16px 24px;"> <div id="detail-list-container" style="display: flex; flex-direction: column; gap: 8px;"> <!-- 内容将通过JavaScript动态加载 --> </div> <!-- 加载更多按钮 --> <div id="load-more-section" style="padding: 16px; text-align: center; margin-top: 16px; display: none;"> <button id="load-more-items" style="background: var(--theme_primary, #0F7AF5); color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.2s;"> 加载更多 </button> </div> <!-- 空状态提示 --> <div id="empty-state" style="text-align: center; padding: 40px 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); display: none;"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom: 16px; color: var(--base_gray_040, rgba(22, 30, 38, 0.4));"> <circle cx="11" cy="11" r="8"></circle> <path d="m21 21-4.35-4.35"></path> </svg> <div style="font-size: 16px; margin-bottom: 8px;">未找到匹配项</div> <div style="font-size: 14px;">尝试调整搜索条件或清除筛选</div> </div> </div> </div> ` }); document.body.appendChild(dialog); // 初始化对话框功能 this.initializeDetailDialog(dialog, title, data, itemRenderer); // 设置焦点以便键盘事件生效 dialog.tabIndex = -1; dialog.focus(); } // 初始化详细列表对话框功能 initializeDetailDialog(dialog, title, originalData, itemRenderer) { // 存储对话框状态 const state = { originalData: originalData, filteredData: [...originalData], displayedData: [], itemRenderer: itemRenderer, currentPage: 0, pageSize: 20, searchKeyword: '', sortBy: 'default' }; // 获取DOM元素 const elements = { container: dialog.querySelector('#detail-list-container'), searchInput: dialog.querySelector('#detail-search'), sortSelect: dialog.querySelector('#detail-sort'), pageSizeSelect: dialog.querySelector('#detail-page-size'), refreshBtn: dialog.querySelector('#detail-refresh'), loadMoreBtn: dialog.querySelector('#load-more-items'), loadMoreSection: dialog.querySelector('#load-more-section'), emptyState: dialog.querySelector('#empty-state'), totalCount: dialog.querySelector('#total-count'), showingCount: dialog.querySelector('#showing-count'), closeBtn: dialog.querySelector('#close-detail-dialog') }; // 更新显示统计 const updateStats = () => { elements.totalCount.textContent = state.filteredData.length; elements.showingCount.textContent = state.displayedData.length; }; // 渲染列表项 const renderItems = (items, append = false) => { if (!append) { elements.container.innerHTML = ''; state.displayedData = []; } const startIndex = state.displayedData.length; const itemsToRender = items.slice(startIndex, startIndex + (state.pageSize === 'all' ? items.length : parseInt(state.pageSize))); if (itemsToRender.length === 0) { if (state.displayedData.length === 0) { elements.emptyState.style.display = 'block'; } elements.loadMoreSection.style.display = 'none'; return; } elements.emptyState.style.display = 'none'; itemsToRender.forEach((item, index) => { const itemHtml = state.itemRenderer(item, startIndex + index); elements.container.insertAdjacentHTML('beforeend', itemHtml); // 获取刚刚插入的最后一个元素 const lastElement = elements.container.lastElementChild; // 如果是重复附件组,需要添加事件监听器 if (Array.isArray(item)) { const duplicateItems = lastElement.querySelectorAll('.duplicate-item'); duplicateItems.forEach(duplicateItem => { const mailId = duplicateItem.getAttribute('data-mail-id'); // 点击事件 duplicateItem.addEventListener('click', ((mailId) => { return (e) => { e.preventDefault(); e.stopPropagation(); this.openMail(mailId); }; })(mailId)); // 悬停效果 duplicateItem.addEventListener('mouseenter', () => { duplicateItem.style.background = 'var(--base_gray_010, rgba(22, 46, 74, 0.1))'; }); duplicateItem.addEventListener('mouseleave', () => { duplicateItem.style.background = 'var(--base_gray_005, rgba(22, 46, 74, 0.05))'; }); }); } else { // 为单个附件项目添加缩略图事件监听器 const thumbnailImg = lastElement.querySelector('.file-thumbnail'); const fallbackIcon = lastElement.querySelector('.file-icon-fallback'); if (thumbnailImg && fallbackIcon) { // 图片加载成功 thumbnailImg.addEventListener('load', () => { thumbnailImg.style.opacity = '1'; }); // 图片加载失败,显示fallback图标 thumbnailImg.addEventListener('error', () => { thumbnailImg.style.display = 'none'; fallbackIcon.style.display = 'flex'; }); } } // 为所有项目的缩略图添加事件监听器(包括重复附件组的主图标) const groupThumbnailImg = lastElement.querySelector('.file-preview-container .file-thumbnail'); const groupFallbackIcon = lastElement.querySelector('.file-preview-container .file-icon-fallback'); if (groupThumbnailImg && groupFallbackIcon) { // 图片加载成功 groupThumbnailImg.addEventListener('load', () => { groupThumbnailImg.style.opacity = '1'; }); // 图片加载失败,显示fallback图标 groupThumbnailImg.addEventListener('error', () => { groupThumbnailImg.style.display = 'none'; groupFallbackIcon.style.display = 'flex'; }); } }); state.displayedData.push(...itemsToRender); // 显示/隐藏加载更多按钮 const hasMore = state.displayedData.length < state.filteredData.length && state.pageSize !== 'all'; elements.loadMoreSection.style.display = hasMore ? 'block' : 'none'; updateStats(); }; // 应用搜索过滤 const applySearch = () => { const keyword = state.searchKeyword.toLowerCase(); if (!keyword) { state.filteredData = [...state.originalData]; } else { state.filteredData = state.originalData.filter(item => { // 判断是重复附件组还是单个项目 if (Array.isArray(item)) { // 重复附件组:搜索组内所有附件的信息 return item.some(attachment => { const searchText = [ attachment.name, attachment.mailSubject, attachment.senderName, attachment.senderEmail, attachment.sender ].filter(Boolean).join(' ').toLowerCase(); return searchText.includes(keyword); }); } else { // 单个项目:搜索邮件主题、发件人、附件名等 const searchText = [ item.subject, item.name, item.mailSubject, item.senderName, item.senderEmail, item.sender, item.invalidReason ].filter(Boolean).join(' ').toLowerCase(); return searchText.includes(keyword); } }); } }; // 应用排序 const applySort = () => { switch (state.sortBy) { case 'name': state.filteredData.sort((a, b) => { // 处理重复附件组和单个项目 const nameA = Array.isArray(a) ? (a[0]?.name || '') : (a.name || a.subject || ''); const nameB = Array.isArray(b) ? (b[0]?.name || '') : (b.name || b.subject || ''); return nameA.toLowerCase().localeCompare(nameB.toLowerCase()); }); break; case 'time': state.filteredData.sort((a, b) => { // 处理重复附件组和单个项目 let timeA, timeB; if (Array.isArray(a)) { // 重复附件组:取最新时间 timeA = Math.max(...a.map(item => item.totime || item.date || 0)); } else { timeA = a.totime || a.date || 0; } if (Array.isArray(b)) { // 重复附件组:取最新时间 timeB = Math.max(...b.map(item => item.totime || item.date || 0)); } else { timeB = b.totime || b.date || 0; } return timeB - timeA; // 新的在前 }); break; case 'size': state.filteredData.sort((a, b) => { // 处理重复附件组和单个项目 const sizeA = Array.isArray(a) ? parseInt(a[0]?.size || a[0]?.filesize || 0) : parseInt(a.size || a.filesize || 0); const sizeB = Array.isArray(b) ? parseInt(b[0]?.size || b[0]?.filesize || 0) : parseInt(b.size || b.filesize || 0); return sizeB - sizeA; // 大的在前 }); break; default: // 保持原始顺序 break; } }; // 刷新列表 const refreshList = () => { applySearch(); applySort(); state.currentPage = 0; renderItems(state.filteredData, false); }; // 事件监听器 elements.searchInput.addEventListener('input', (e) => { state.searchKeyword = e.target.value; refreshList(); }); elements.sortSelect.addEventListener('change', (e) => { state.sortBy = e.target.value; refreshList(); }); elements.pageSizeSelect.addEventListener('change', (e) => { state.pageSize = e.target.value; refreshList(); }); elements.refreshBtn.addEventListener('click', () => { // 重新获取数据(如果需要的话) refreshList(); this.showToast('列表已刷新', 'success', 1500); }); elements.loadMoreBtn.addEventListener('click', () => { renderItems(state.filteredData, true); }); elements.closeBtn.addEventListener('click', () => { document.body.removeChild(dialog); }); // 点击背景关闭 dialog.addEventListener('click', (e) => { if (e.target === dialog) { document.body.removeChild(dialog); } }); // ESC键关闭 dialog.addEventListener('keydown', (e) => { if (e.key === 'Escape') { document.body.removeChild(dialog); } }); // 搜索框焦点样式 elements.searchInput.addEventListener('focus', () => { elements.searchInput.style.borderColor = 'var(--theme_primary, #0F7AF5)'; elements.searchInput.style.boxShadow = '0 0 0 2px rgba(15, 122, 245, 0.2)'; }); elements.searchInput.addEventListener('blur', () => { elements.searchInput.style.borderColor = 'var(--base_gray_020, rgba(22, 46, 74, 0.2))'; elements.searchInput.style.boxShadow = 'none'; }); // 初始化列表 refreshList(); } // 加载更多项目(保留向后兼容) loadMoreItems(data, itemRenderer) { const container = document.getElementById('detail-list-container'); if (!container) return; // 清空容器,显示所有项目 container.innerHTML = data.map((item, index) => itemRenderer(item, index)).join(''); } // 渲染邮件项目 renderMailItem(mail, index) { const subject = mail.subject; const senderData = mail.senders?.item?.[0]; const senderName = senderData?.name || senderData?.nick || '未知发件人'; const senderEmail = senderData?.email || ''; const timestamp = mail.totime || mail.date; const date = timestamp ? this.formatDate(this.processData(timestamp, 'createSafeDate'), 'MM-DD HH:mm') : '未知时间'; const mailId = mail.emailid || mail.id; const element = this.createUI('div', { styles: `padding: 12px; background: white; border-radius: 8px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: pointer; transition: all 0.2s; margin-bottom: 8px;`, content: ` <div style="display: flex; align-items: center; justify-content: space-between;"> <div style="flex: 1; min-width: 0;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;"> <div style="font-weight: 600; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; flex: 1; margin-right: 12px;"> ${subject} </div> <div style="font-size: 12px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); white-space: nowrap;"> ${date} </div> </div> <div style="display: flex; align-items: center; justify-content: space-between;"> <div style="font-size: 13px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;"> ${senderName} </div> </div> </div> <div style="color: var(--base_gray_040, rgba(22, 30, 38, 0.4)); margin-left: 12px;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </div> </div> `, events: { click: () => this.openMail(mailId), mouseenter: (e) => e.target.style.background = 'var(--base_gray_005, rgba(22, 46, 74, 0.05))', mouseleave: (e) => e.target.style.background = 'white' } }); return element.outerHTML; } // 渲染附件项目 renderAttachmentItem(attachment, index) { const fileName = attachment.name; const fileSize = this.formatFileSize(parseInt(attachment.size) || parseInt(attachment.filesize)); const invalidReason = attachment.invalidReason; const mailSubject = attachment.mailSubject; const mailId = attachment.mailId; const isLatest = attachment.isLatest; const attachmentDate = this.processData(attachment.date || attachment.totime, 'createSafeDate'); const dateStr = attachmentDate ? this.formatDate(attachmentDate, 'MM-DD HH:mm') : '未知时间'; const senderName = attachment.senderName || '未知发件人'; // 获取文件图标和缩略图 const fileIcon = this.getFileIcon(fileName); const thumbnailUrl = this.getThumbnailUrl(attachment); const supportsThumbnail = this.supportsThumbnail(fileName); const element = this.createUI('div', { styles: `padding: 12px; background: white; border-radius: 8px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: pointer; transition: all 0.2s; margin-bottom: 8px;`, content: ` <div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0;"> <!-- 文件图标/缩略图 --> <div class="file-preview-container" style="width: 48px; height: 48px; border-radius: 6px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); overflow: hidden; position: relative;"> ${supportsThumbnail && thumbnailUrl ? `<img class="file-thumbnail" src="${thumbnailUrl}" alt="${fileName}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 5px; opacity: 0; transition: opacity 0.3s;" data-fallback-icon="${fileIcon}"> <span class="file-icon-fallback" style="font-size: 16px; display: none; position: absolute; width: 100%; height: 100%; align-items: center; justify-content: center;">${fileIcon}</span>` : `<span style="font-size: 16px;">${fileIcon}</span>` } </div> <!-- 文件信息 --> <div style="flex: 1; min-width: 0;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;"> <div style="font-weight: 600; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; flex: 1;"> ${fileName} </div> ${isLatest ? '<span style="background: var(--theme_success, #28a745); color: white; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; white-space: nowrap;">最新</span>' : ''} ${invalidReason ? `<span style="background: var(--theme_danger_light, rgba(220, 53, 69, 0.1)); color: var(--theme_danger, #dc3545); padding: 2px 6px; border-radius: 4px; font-size: 10px; white-space: nowrap;">${invalidReason}</span>` : ''} </div> <div style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> <span>${fileSize}</span> <span>•</span> <span>${dateStr}</span> <span>•</span> <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${senderName}</span> </div> <div style="font-size: 11px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> 来自: ${mailSubject} </div> </div> </div> <div style="color: var(--base_gray_040, rgba(22, 30, 38, 0.4)); margin-left: 12px;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </div> </div> `, events: { click: () => this.openMail(mailId), mouseenter: (e) => e.target.style.background = 'var(--base_gray_005, rgba(22, 46, 74, 0.05))', mouseleave: (e) => e.target.style.background = 'white' } }); return element.outerHTML; } // 渲染重复附件组项目(用于详细列表对话框) renderDuplicateGroupItem(group, index) { if (!group || group.length === 0) return ''; const fileName = group[0].name; const fileSize = this.formatFileSize(parseInt(group[0].size) || parseInt(group[0].filesize)); const duplicateCount = group.length; const fileIcon = this.getFileIcon(fileName); // 获取缩略图信息(使用组中第一个附件) const firstAttachment = group[0]; const thumbnailUrl = this.getThumbnailUrl(firstAttachment); const supportsThumbnail = this.supportsThumbnail(fileName); // 找出最新的附件(按时间排序) const sortedGroup = [...group].sort((a, b) => { const dateA = this.processData(a.date || a.totime, 'createSafeDate'); const dateB = this.processData(b.date || b.totime, 'createSafeDate'); if (!dateA && !dateB) return 0; if (!dateA) return 1; if (!dateB) return -1; return dateB.getTime() - dateA.getTime(); }); const latestAttachment = sortedGroup[0]; const groupWithLatestFlag = group.map(attachment => ({ ...attachment, isLatest: attachment === latestAttachment })); const element = this.createUI('div', { styles: `padding: 16px; background: white; border-radius: 8px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); margin-bottom: 8px;`, content: ` <div style="display: flex; align-items: flex-start; gap: 12px;"> <!-- 文件图标/缩略图 --> <div class="file-preview-container" style="width: 40px; height: 40px; border-radius: 8px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); overflow: hidden; position: relative;"> ${supportsThumbnail && thumbnailUrl ? `<img class="file-thumbnail" src="${thumbnailUrl}" alt="${fileName}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 7px; opacity: 0; transition: opacity 0.3s;" data-fallback-icon="${fileIcon}"> <span class="file-icon-fallback" style="font-size: 16px; display: none; position: absolute; width: 100%; height: 100%; align-items: center; justify-content: center;">${fileIcon}</span>` : `<span style="font-size: 16px;">${fileIcon}</span>` } </div> <!-- 文件信息 --> <div style="flex: 1; min-width: 0;"> <!-- 文件头部信息 --> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <div style="font-weight: 600; color: var(--base_gray_090, rgba(22, 30, 38, 0.9)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16px; flex: 1;"> ${fileName} </div> <span style="background: var(--theme_warning_light, rgba(255, 193, 7, 0.1)); color: var(--theme_warning, #ffc107); padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; white-space: nowrap;"> 重复 ${duplicateCount} 次 </span> </div> <!-- 文件基本信息 --> <div style="font-size: 13px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); margin-bottom: 12px;"> 文件大小: ${fileSize} </div> <!-- 重复附件详情列表 --> <div style="border-top: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); padding-top: 12px;"> <div style="font-size: 12px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); margin-bottom: 8px; font-weight: 500;"> 重复附件详情: </div> <div id="duplicate-items-container-${index}" style="display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto;"> ${groupWithLatestFlag.map((attachment, idx) => { const attachmentDate = this.processData(attachment.date || attachment.totime, 'createSafeDate'); const dateStr = attachmentDate ? this.formatDate(attachmentDate, 'MM-DD HH:mm') : '未知时间'; const senderName = attachment.senderName || '未知发件人'; const mailSubject = attachment.mailSubject || '未知邮件'; const isLatest = attachment.isLatest; return ` <div class="duplicate-item" data-mail-id="${attachment.mailId}" style="padding: 8px 12px; background: var(--base_gray_005, rgba(22, 46, 74, 0.05)); border-radius: 6px; border: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1)); cursor: pointer; transition: all 0.2s;"> <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;"> <div style="flex: 1; min-width: 0;"> <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 2px;"> <span style="font-size: 11px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;"> ${mailSubject} </span> ${isLatest ? '<span style="background: var(--theme_success, #28a745); color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; font-weight: 500;">最新</span>' : ''} </div> <div style="font-size: 10px; color: var(--base_gray_050, rgba(22, 30, 38, 0.5)); display: flex; align-items: center; gap: 8px;"> <span>${senderName}</span> <span>•</span> <span>${dateStr}</span> </div> </div> <div style="color: var(--base_gray_040, rgba(22, 30, 38, 0.4)); font-size: 10px;"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15 3 21 3 21 9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </div> </div> </div> `; }).join('')} </div> </div> </div> </div> ` }); return element.outerHTML; } // 打开邮件 openMail(mailId) { if (!mailId) { this.showToast('无法打开邮件:邮件ID无效', 'error'); return; } try { // 构建邮件URL - 使用正确的QQ邮箱URL格式 const currentUrl = window.location.href; const baseUrl = currentUrl.split('#')[0]; const mailUrl = `${baseUrl}#/read/${mailId}`; // 在新标签页中打开 window.open(mailUrl, '_blank'); this.showToast('正在新标签页中打开邮件...', 'info'); } catch (error) { console.error('打开邮件失败:', error); this.showToast('打开邮件失败', 'error'); } } // 打开验证设置 openValidationSettings() { try { // 直接调用设置对话框,并切换到文件命名标签页 this.showSettingsDialog().then(() => { // 等待对话框创建完成后切换到文件命名标签页 setTimeout(() => { this.switchSettingsTab('file-naming'); // 滚动到验证规则部分 setTimeout(() => { const validationSection = document.querySelector('#validation-enabled'); if (validationSection) { validationSection.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 高亮显示验证规则区域 const validationContainer = validationSection.closest('.setting-group'); if (validationContainer) { validationContainer.style.transition = 'all 0.3s ease'; validationContainer.style.background = 'rgba(0, 123, 255, 0.1)'; validationContainer.style.borderRadius = '8px'; validationContainer.style.padding = '16px'; // 3秒后移除高亮 setTimeout(() => { validationContainer.style.background = ''; validationContainer.style.padding = ''; }, 3000); } } }, 200); }, 100); }); this.showToast('正在打开验证规则设置...', 'info'); } catch (error) { console.error('打开设置失败:', error); this.showToast('打开设置失败', 'error'); } } // 生成文件夹结构预览 generateFolderStructurePreview(attachments) { const folderStructure = this.downloadSettings.folderStructure; if (folderStructure === 'flat') { return null; // 平铺模式不显示文件夹结构 } // 取前几个附件作为示例 const sampleAttachments = attachments.slice(0, 3); const folders = new Set(); sampleAttachments.forEach(attachment => { let folderPath = ''; switch (folderStructure) { case 'subject': folderPath = this.sanitizeFileName(attachment.mailSubject || attachment.subject || '未知主题', attachment); break; case 'sender': folderPath = attachment.senderEmail || attachment.sender || '未知发件人'; break; case 'date': const mailDate = attachment.date || attachment.totime ? new Date(typeof (attachment.date || attachment.totime) === 'number' && (attachment.date || attachment.totime) < 10000000000 ? (attachment.date || attachment.totime) * 1000 : (attachment.date || attachment.totime)) : new Date(); folderPath = this.formatDate(mailDate, 'MM-DD'); break; case 'custom': const customTemplate = this.downloadSettings.folderNaming?.customTemplate || '{date:YYYY-MM-DD}/{senderName}'; const variableData = this.buildVariableData(attachment); folderPath = this.replaceVariablesInTemplate(customTemplate, variableData); // 确保预览显示正确,如果变量替换失败则使用示例数据 if (folderPath === customTemplate || folderPath.includes('{') || folderPath.includes('}')) { const now = new Date(); const exampleData = { date: now, senderName: attachment.senderName || attachment.sender || '示例发件人', subject: attachment.mailSubject || attachment.subject || '示例主题', senderEmail: attachment.senderEmail || '[email protected]' }; folderPath = this.replaceVariablesInTemplate(customTemplate, exampleData); } break; } if (folderPath) { folders.add(folderPath); } }); const folderArray = Array.from(folders); const maxDisplay = 3; const hasMore = folderArray.length > maxDisplay; const displayFolders = folderArray.slice(0, maxDisplay); return { folders: displayFolders, hasMore, totalCount: folderArray.length, structure: folderStructure }; } // 更新文件夹结构预览显示 updateFolderStructurePreview(attachments) { const previewCard = document.getElementById('folder-structure-preview-card'); const previewContent = document.getElementById('folder-structure-preview-content'); if (!previewCard || !previewContent) return; const preview = this.generateFolderStructurePreview(attachments); if (preview) { previewCard.style.display = 'block'; previewContent.innerHTML = preview.folders.slice(0, 3).map(folder => `📁 ${folder}`).join('<br>') + (preview.folders.length > 3 ? `<br><span style="color: var(--base_gray_060, rgba(22, 30, 38, 0.6));">... 共 ${preview.folders.length} 个文件夹</span>` : ''); } else { previewCard.style.display = 'none'; } } createHeaderCard() { const folderName = this.getFolderDisplayName(); const headerCard = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoCardHeader, content: ` <div style="display: flex; align-items: center; gap: 16px;"> <button style="background: none; border: none; padding: 8px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); cursor: pointer; border-radius: 6px; transition: all 0.2s ease;" id="back-btn" title="返回邮件列表"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> </button> <div style="flex: 1;"> <div style="${StyleManager.getStyles().texts.headerTitle}">${folderName}</div> <div style="${StyleManager.getStyles().texts.headerSubtitle}" id="attachment-count-info">实时分析邮箱附件分布情况</div> </div> </div> <div style="display: flex; gap: 12px; align-items: center;"> <button style="${StyleManager.getStyles().texts.actionButtonSecondary}" id="settings-btn"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="3"/> <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"/> </svg> 设置 </button> <button style="${StyleManager.getStyles().texts.actionButtonSecondary}${!window.showDirectoryPicker ? '; opacity: 0.5; cursor: not-allowed;' : ''}" id="compare-btn" ${!window.showDirectoryPicker ? 'disabled title="需要 Chrome 86+ 或 Edge 86+ 浏览器支持"' : ''}> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2z"/> <path d="M19 11h-4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2z"/> <path d="M12 2v9"/> <path d="M8 6l4-4 4 4"/> </svg> 对比本地 </button> </div> `, events: { click: (e) => { if (e.target.id === 'back-btn' || e.target.closest('#back-btn')) { e.preventDefault(); e.stopPropagation(); this.hideAttachmentView(); } else if (e.target.id === 'settings-btn' || e.target.closest('#settings-btn')) { e.preventDefault(); e.stopPropagation(); this.showSettingsDialog(); } else if (e.target.id === 'compare-btn' || e.target.closest('#compare-btn')) { e.preventDefault(); e.stopPropagation(); this.showCompareDialog(); } } } }); // 添加按钮悬停效果 const backBtn = headerCard.querySelector('#back-btn'); const settingsBtn = headerCard.querySelector('#settings-btn'); const compareBtn = headerCard.querySelector('#compare-btn'); if (backBtn) { backBtn.addEventListener('mouseenter', () => { backBtn.style.background = 'var(--base_gray_005, rgba(20, 46, 77, 0.05))'; backBtn.style.color = 'var(--base_gray_080, rgba(22, 30, 38, 0.8))'; }); backBtn.addEventListener('mouseleave', () => { backBtn.style.background = 'none'; backBtn.style.color = 'var(--base_gray_060, rgba(22, 30, 38, 0.6))'; }); } if (settingsBtn) { settingsBtn.addEventListener('mouseenter', () => { StyleManager.applyInteractionStyle(settingsBtn, 'actionButtonSecondaryHover'); }); settingsBtn.addEventListener('mouseleave', () => { StyleManager.applyInteractionStyle(settingsBtn, 'actionButtonSecondaryHover', true); }); } if (compareBtn) { compareBtn.addEventListener('mouseenter', () => { StyleManager.applyInteractionStyle(compareBtn, 'actionButtonSecondaryHover'); }); compareBtn.addEventListener('mouseleave', () => { StyleManager.applyInteractionStyle(compareBtn, 'actionButtonSecondaryHover', true); }); } return headerCard; } createMainFunctionRow() { const row = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoRowResponsive, className: 'bento-row-responsive', content: ` <div style="${StyleManager.getStyles().layouts.bentoCardPrimary}"> <div style="display: flex; flex-direction: column; height: 100%;"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;"> <div style="${StyleManager.getStyles().texts.bentoLabelLarge}">📎 附件下载</div> <div style="opacity: 0.6;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7,10 12,15 17,10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> </div> </div> <div style="flex: 1; display: flex; flex-direction: column; justify-content: center; text-align: center;"> <div style="${StyleManager.getStyles().texts.bentoNumberLarge}" id="attachment-count">计算中...</div> <div style="${StyleManager.getStyles().texts.bentoDescLarge}; margin-top: 6px;" id="total-size">计算中...</div> <div id="folder-structure-preview-card" style="margin-top: 12px; display: none;"> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6)); margin-bottom: 6px;">文件夹结构预览</div> <div id="folder-structure-preview-content" style="background: var(--base_gray_005, rgba(20, 46, 77, 0.05)); padding: 8px 12px; border-radius: 6px; font-size: 11px; font-family: monospace; color: var(--base_gray_080, rgba(22, 30, 38, 0.8)); text-align: left; max-height: 80px; overflow-y: auto;"> </div> </div> </div> <div style="text-align: center; margin-top: 12px;"> <div style="${StyleManager.getStyles().texts.bentoDescLarge}; font-weight: 600;">点击下载全部附件</div> </div> </div> </div> <div style="${StyleManager.getStyles().layouts.bentoCard}"> <div style="display: flex; flex-direction: column; height: 100%;"> <div style="margin-bottom: 16px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">📊 统计概览</div> </div> <div style="flex: 1; display: flex; flex-direction: column; justify-content: space-between;"> <div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1));"> <span style="font-size: 14px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7));">邮件总数</span> <span style="font-size: 16px; font-weight: 600; color: var(--base_gray_100, #13181D);" id="quick-mail-count">计算中...</span> </div> <div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--base_gray_010, rgba(22, 46, 74, 0.1));"> <span style="font-size: 14px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7));">发件人数量</span> <span style="font-size: 16px; font-weight: 600; color: var(--base_gray_100, #13181D);" id="quick-sender-count">计算中...</span> </div> <div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0;"> <span style="font-size: 14px; color: var(--base_gray_070, rgba(22, 30, 38, 0.7));">无附件邮件</span> <span style="font-size: 16px; font-weight: 600; color: var(--base_gray_100, #13181D);" id="quick-no-attachment-mails">计算中...</span> </div> </div> </div> </div> ` }); // 添加下载点击事件和悬停效果到第一个卡片 const downloadCard = row.children[0]; const operationCard = row.children[1]; downloadCard.addEventListener('click', () => { this.downloadAll(); }); // 添加悬停效果 downloadCard.addEventListener('mouseenter', () => { StyleManager.applyInteractionStyle(downloadCard, 'cardPrimaryHover'); }); downloadCard.addEventListener('mouseleave', () => { StyleManager.applyInteractionStyle(downloadCard, 'cardPrimaryHover', true); }); // 移除统计卡片的悬停效果,因为它不可点击 return row; } createMailStatsCard(totalMails) { const card = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoCard, content: ` <div style="margin-bottom: 16px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">邮件统计概览</div> </div> <div style="${StyleManager.getStyles().layouts.bentoStatsResponsive}" class="bento-stats-responsive"> <div style="text-align: center; padding: 16px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}">${totalMails}</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">有附件邮件</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">包含附件的邮件总数</div> </div> <div style="text-align: center; padding: 16px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="sender-count">计算中...</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">发件人数量</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">不同发件人总数</div> </div> <div style="text-align: center; padding: 16px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="no-attachment-mails">计算中...</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">无附件邮件</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">需要检查的邮件</div> </div> </div> ` }); return card; } createNoAttachmentListCard() { const card = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoCard + '; display: none;', className: 'bento-card no-attachment-list-card', content: ` <div style="margin-bottom: 16px;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">无附件邮件列表</div> <button id="view-all-no-attachment-mails" style="padding: 6px 12px; font-size: 12px; background: var(--theme_primary, #007bff); color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.2s;"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <circle cx="12" cy="12" r="3"/> </svg> 查看完整列表 </button> </div> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));">点击邮件可在新标签页中打开</div> </div> <div id="no-attachment-list-container" style="max-height: 300px; overflow-y: auto;"> <div style="text-align: center; padding: 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 加载中... </div> </div> `, events: { click: (e) => { if (e.target.id === 'view-all-no-attachment-mails' || e.target.closest('#view-all-no-attachment-mails')) { e.preventDefault(); e.stopPropagation(); this.showDetailList('no-attachment'); } } } }); return card; } createInvalidNamingListCard() { const card = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoCard + '; display: none;', className: 'bento-card invalid-naming-list-card', content: ` <div style="margin-bottom: 16px;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">命名异常附件列表</div> <div style="display: flex; gap: 8px;"> <button id="view-all-invalid-naming" style="padding: 6px 12px; font-size: 12px; background: var(--theme_success, #28a745); color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.2s;"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <circle cx="12" cy="12" r="3"/> </svg> 查看完整列表 </button> <button id="open-validation-settings" class="validation-settings-btn" style="padding: 6px 12px; font-size: 12px; background: var(--theme_primary, #007bff); color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.2s;"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="3"/> <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"/> </svg> 修改验证规则 </button> </div> </div> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));">点击附件可在新标签页中打开对应邮件</div> </div> <div id="invalid-naming-list-container" style="max-height: 300px; overflow-y: auto;"> <div style="text-align: center; padding: 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 加载中... </div> </div> `, events: { click: (e) => { if (e.target.id === 'view-all-invalid-naming' || e.target.closest('#view-all-invalid-naming')) { e.preventDefault(); e.stopPropagation(); this.showDetailList('invalid-naming'); } else if (e.target.id === 'open-validation-settings' || e.target.closest('#open-validation-settings')) { e.preventDefault(); e.stopPropagation(); this.openValidationSettings(); } } } }); return card; } // 创建重复附件列表卡片 createDuplicateAttachmentsListCard() { const card = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoCard + '; display: none;', className: 'bento-card duplicate-attachments-list-card', content: ` <div style="margin-bottom: 16px;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">重复附件列表</div> <button id="view-all-duplicate-attachments" style="padding: 6px 12px; font-size: 12px; background: var(--theme_warning, #ffc107); color: #212529; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.2s; font-weight: 500;"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <circle cx="12" cy="12" r="3"/> </svg> 查看完整列表 </button> </div> <div style="font-size: 12px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));">点击附件可在新标签页中打开对应邮件</div> </div> <div id="duplicate-attachments-list-container" style="max-height: 300px; overflow-y: auto;"> <div style="text-align: center; padding: 20px; color: var(--base_gray_060, rgba(22, 30, 38, 0.6));"> 加载中... </div> </div> `, events: { click: (e) => { if (e.target.id === 'view-all-duplicate-attachments' || e.target.closest('#view-all-duplicate-attachments')) { e.preventDefault(); e.stopPropagation(); this.showDetailList('duplicate-attachments'); } } } }); return card; } createFileTypeRow() { const row = this.createUI('div', { styles: StyleManager.getStyles().layouts.bentoRowResponsive, className: 'bento-row-responsive', content: ` <div style="${StyleManager.getStyles().layouts.bentoCard}"> <div style="margin-bottom: 16px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">文件类型分布</div> </div> <div style="${StyleManager.getStyles().layouts.bentoStatsResponsive}" class="bento-stats-responsive"> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="image-files">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">图片</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">JPG, PNG, GIF等</div> </div> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="archive-files">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">压缩包</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">ZIP, RAR, 7Z等</div> </div> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="document-files">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">文档</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">PDF, DOC, XLS等</div> </div> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="other-files">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">其他</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">其他格式文件</div> </div> </div> </div> <div style="${StyleManager.getStyles().layouts.bentoCard}"> <div style="margin-bottom: 16px;"> <div style="${StyleManager.getStyles().texts.bentoTitle}">问题文件检测</div> </div> <div style="${StyleManager.getStyles().layouts.bentoStatsResponsive}" class="bento-stats-responsive"> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="invalid-naming">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">命名异常</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">无扩展名、空文件、特殊字符等</div> </div> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="large-files">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">超大文件</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">>10MB</div> </div> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="duplicate-attachments">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">重复文件</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">相同名称且相同大小的文件</div> </div> <div style="text-align: center; padding: 12px;"> <div style="${StyleManager.getStyles().texts.bentoNumber}" id="duplicate-mails">0</div> <div style="${StyleManager.getStyles().texts.bentoLabel}">重复邮件</div> <div style="${StyleManager.getStyles().texts.bentoDesc}">相同发件人的相同主题且相同附件的邮件</div> </div> </div> </div> ` }); return row; } updateCardValue(card, value) { const numberElement = card.children[1]; if (numberElement) { numberElement.textContent = value; } } updateElementById(id, value) { const element = document.getElementById(id); if (element) { element.textContent = value; } } createBentoCard(label, value, description, size = 'normal', icon = '', action = '') { const isPrimary = size === 'primary'; const sizeClass = isPrimary ? 'bentoCardPrimary' : size === 'wide' ? 'bentoCardWide' : size === 'tall' ? 'bentoCardTall' : size === 'small' ? 'bentoCardSmall' : ''; const isClickable = action === 'download'; let cardStyle = StyleManager.getStyles().layouts.bentoCard; if (sizeClass) { cardStyle += '; ' + StyleManager.getStyles().layouts[sizeClass]; } // 只有可点击的卡片才添加cursor: pointer if (isClickable) { cardStyle += '; cursor: pointer;'; } const numberStyle = isPrimary ? StyleManager.getStyles().texts.bentoNumberPrimary : StyleManager.getStyles().texts.bentoNumber; const labelStyle = isPrimary ? StyleManager.getStyles().texts.bentoLabelPrimary : StyleManager.getStyles().texts.bentoLabel; const descStyle = isPrimary ? StyleManager.getStyles().texts.bentoDescPrimary : StyleManager.getStyles().texts.bentoDesc; const card = this.createUI('div', { styles: cardStyle, content: ` <div> <div style="${numberStyle}">${value}</div> <div style="${labelStyle}">${label}</div> </div> <div style="${descStyle}">${description}</div> ${isClickable ? `<div style="position: absolute; top: 16px; right: 16px;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="opacity: 0.7;"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7,10 12,15 17,10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> </div>` : ''} `, events: isClickable ? { mouseenter: (e) => { if (isPrimary) { StyleManager.applyInteractionStyle(e.target, 'cardPrimaryHover'); } else { StyleManager.applyInteractionStyle(e.target, 'cardHover'); } }, mouseleave: (e) => { StyleManager.applyInteractionStyle(e.target, isPrimary ? 'cardPrimaryHover' : 'cardHover', true); }, click: () => { if (action === 'download') { this.downloadAll(); } } } : {} }); return card; } formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // 保留这些方法以防其他地方需要使用,但简化实现 async renderGroupsInBatches(groups, container) { // 简化版本,不再使用 return; } createGroupContainer(group, index) { // 简化版本,不再使用 return this.createUI('div'); } createAndInjectButton() { console.log('[按钮创建] 开始创建附件管理按钮'); // 先清理已存在的按钮 - 使用更全面的清理方式 const existingButtons = document.querySelectorAll('#attachment-downloader-btn, [data-attachment-manager-btn="true"], .attachment-floating-btn'); if (existingButtons.length > 0) { console.log('[按钮创建] 清理已存在的按钮,数量:', existingButtons.length); existingButtons.forEach(btn => btn.remove()); } // 检查屏幕宽度决定显示方式 const screenWidth = window.innerWidth; console.log('[按钮创建] 屏幕宽度:', screenWidth); try { if (screenWidth < 1340) { this.createFloatingButton(); } else { this.createToolbarButton(); } // 设置按钮重复检查机制 this.setupButtonDuplicationCheck(); // 设置窗口大小变化监听 this.setupResponsiveListener(); console.log('[按钮创建] 附件管理按钮创建完成'); } catch (error) { console.error('[按钮创建] 创建按钮时出现错误:', error); // 延迟重试 setTimeout(() => { console.log('[按钮创建] 重试创建按钮'); this.createAndInjectButton(); }, 2000); } } createToolbarButton() { // 尝试多个可能的工具栏选择器 const toolbarSelectors = [ '.xmail-ui-ellipsis-toolbar .ui-ellipsis-toolbar-btns', '.ui-ellipsis-toolbar-btns', '.toolbar-btns', '.mail-toolbar .toolbar-btns', '.xmail-ui-toolbar .ui-toolbar-btns', '.toolbar-right', '.toolbar-actions' ]; let toolbar = null; for (const selector of toolbarSelectors) { toolbar = document.querySelector(selector); if (toolbar) break; } if (!toolbar) { // 如果找不到工具栏,延迟重试 setTimeout(() => this.createAndInjectButton(), 1000); return; } const button = this.createUI('button', { id: 'attachment-downloader-btn', className: 'xmail-ui-btn ui-btn-size32 ui-btn-border ui-btn-them-clear-gray', attributes: { 'data-attachment-manager-btn': 'true' }, content: ` <span class="xmail-ui-icon ui-btn-icon" style="width: 16px; height: 16px; margin-right: 4px;"> <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> <path d="M768 810.7H256c-44.2 0-80-35.8-80-80V547.2c0-17.7 14.3-32 32-32s32 14.3 32 32v183.5c0 8.8 7.2 16 16 16h512c8.8 0 16-7.2 16-16V547.2c0-17.7 14.3-32 32-32s32 14.3 32 32v183.5c0 44.2-35.8 80-80 80zM480 614.4c-8.2 0-16.4-3.1-22.6-9.4L234.8 382.3c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L480 536.7l199.9-199.7c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L502.6 605c-6.3 6.3-14.4 9.4-22.6 9.4z" fill="#2c2c2c"></path> <path d="M512 646.4c-17.7 0-32-14.3-32-32V172.8c0-17.7 14.3-32 32-32s32 14.3 32 32v441.6c0 17.7-14.3 32-32 32z" fill="#2c2c2c"></path> </svg> </span> <span>附件管理</span> `, styles: { marginLeft: '8px' }, events: { click: (e) => { e.preventDefault(); e.stopPropagation(); this.toggleAttachmentManager(); } } }); toolbar.appendChild(button); } createFloatingButton() { // 添加浮动按钮样式 this.addFloatingButtonStyles(); const floatingButton = this.createUI('div', { className: 'attachment-floating-btn', attributes: { 'data-attachment-manager-btn': 'true' }, content: ` <div class="floating-btn-icon"> <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> <path d="M768 810.7H256c-44.2 0-80-35.8-80-80V547.2c0-17.7 14.3-32 32-32s32 14.3 32 32v183.5c0 8.8 7.2 16 16 16h512c8.8 0 16-7.2 16-16V547.2c0-17.7 14.3-32 32-32s32 14.3 32 32v183.5c0 44.2-35.8 80-80 80zM480 614.4c-8.2 0-16.4-3.1-22.6-9.4L234.8 382.3c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L480 536.7l199.9-199.7c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L502.6 605c-6.3 6.3-14.4 9.4-22.6 9.4z" fill="currentColor"></path> <path d="M512 646.4c-17.7 0-32-14.3-32-32V172.8c0-17.7 14.3-32 32-32s32 14.3 32 32v441.6c0 17.7-14.3 32-32 32z" fill="currentColor"></path> </svg> </div> <div class="floating-btn-tooltip">附件管理</div> `, events: { click: (e) => { e.preventDefault(); e.stopPropagation(); this.toggleAttachmentManager(); } } }); document.body.appendChild(floatingButton); } addFloatingButtonStyles() { // 检查是否已经添加了样式 if (document.getElementById('attachment-floating-btn-styles')) { return; } const styles = document.createElement('style'); styles.id = 'attachment-floating-btn-styles'; styles.textContent = ` .attachment-floating-btn { position: fixed; right: 20px; bottom: 60px; width: 56px; height: 56px; background: var(--theme_primary, #0F7AF5); border-radius: 50%; box-shadow: 0 4px 12px rgba(15, 122, 245, 0.3); cursor: pointer; z-index: 1000; display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); user-select: none; } .attachment-floating-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(15, 122, 245, 0.4); background: var(--theme_darken_1, #0E6FD9); } .attachment-floating-btn:active { transform: translateY(0); box-shadow: 0 2px 8px rgba(15, 122, 245, 0.3); } .floating-btn-icon { width: 24px; height: 24px; color: white; display: flex; align-items: center; justify-content: center; } .floating-btn-icon svg { width: 100%; height: 100%; } .floating-btn-tooltip { position: absolute; right: 64px; top: 50%; transform: translateY(-50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 8px 12px; border-radius: 4px; font-size: 14px; white-space: nowrap; opacity: 0; visibility: hidden; transition: all 0.2s; pointer-events: none; } .floating-btn-tooltip::after { content: ''; position: absolute; left: 100%; top: 50%; transform: translateY(-50%); border: 6px solid transparent; border-left-color: rgba(0, 0, 0, 0.8); } .attachment-floating-btn:hover .floating-btn-tooltip { opacity: 1; visibility: visible; } /* 响应式隐藏规则 */ @media (min-width: 1300px) { .attachment-floating-btn { display: none !important; } } @media (max-width: 1299px) { #attachment-downloader-btn { display: none !important; } } `; document.head.appendChild(styles); } setupResponsiveListener() { // 防抖函数 let resizeTimeout; const handleResize = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { const screenWidth = window.innerWidth; const hasToolbarBtn = document.getElementById('attachment-downloader-btn'); const hasFloatingBtn = document.querySelector('.attachment-floating-btn'); if (screenWidth < 1300 && hasToolbarBtn && !hasFloatingBtn) { // 需要切换到浮动按钮 this.createAndInjectButton(); } else if (screenWidth >= 1300 && !hasToolbarBtn && hasFloatingBtn) { // 需要切换到工具栏按钮 this.createAndInjectButton(); } }, 200); }; // 移除旧的监听器 if (this.resizeListener) { window.removeEventListener('resize', this.resizeListener); } this.resizeListener = handleResize; window.addEventListener('resize', this.resizeListener); } setupButtonDuplicationCheck() { // 防止重复按钮的观察器 if (this.buttonObserver) { this.buttonObserver.disconnect(); } this.buttonObserver = new MutationObserver((mutations) => { // 避免在面板初始化过程中进行检查 if (this.isLoading) { return; } const buttons = document.querySelectorAll('#attachment-downloader-btn, [data-attachment-manager-btn="true"], .attachment-floating-btn'); if (buttons.length > 1) { console.log('[按钮检查] 发现重复按钮,数量:', buttons.length); // 保留第一个,移除其他的 for (let i = 1; i < buttons.length; i++) { console.log('[按钮检查] 移除重复按钮:', buttons[i]); buttons[i].remove(); } } }); this.buttonObserver.observe(document.body, { childList: true, subtree: true }); } } class QQMailDownloader { constructor() { this.sid = null; this.headers = this._getDefaultHeaders(); this.attachmentManager = null; } _getDefaultHeaders() { return { 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'accept-language': 'en,zh-CN;q=0.9,zh;q=0.8', 'priority': 'u=0, i', 'sec-ch-ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1' }; } async fetchAttachment(attachment) { try { // 构建初始URL const downloadUrlObj = new URL(attachment.download_url, MAIL_CONSTANTS.BASE_URL); const params = new URLSearchParams(downloadUrlObj.search); const pageUrl = new URL(window.location.href); const sid = pageUrl.searchParams.get('sid') || this.sid; const initialUrl = `${MAIL_CONSTANTS.BASE_URL}${MAIL_CONSTANTS.API_ENDPOINTS.ATTACH_DOWNLOAD}?mailid=${params.get('mailid')}&fileid=${params.get('fileid')}&name=${encodeURIComponent(attachment.name)}&sid=${sid}`; let finalDownloadUrl = null; try { // 尝试获取重定向URL finalDownloadUrl = await this._fetchRedirectUrl(initialUrl, attachment.name); } catch (redirectError) { // 回退方案1: 尝试直接使用原始下载URL if (attachment.download_url) { let directUrl = attachment.download_url; if (!directUrl.startsWith('http')) { directUrl = MAIL_CONSTANTS.BASE_URL + directUrl; } // 确保包含SID参数 if (!directUrl.includes('sid=')) { const separator = directUrl.includes('?') ? '&' : '?'; directUrl += `${separator}sid=${sid}`; } finalDownloadUrl = directUrl; } else { // 回退方案2: 使用初始URL finalDownloadUrl = initialUrl; } } // 获取文件内容 const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: finalDownloadUrl, headers: { ...this.headers, 'Referer': MAIL_CONSTANTS.BASE_URL + '/' }, responseType: 'blob', onload: function (response) { if (response.status === 200) { resolve(response); } else { reject(new Error(`Failed to fetch content: ${response.status} ${response.statusText}`)); } }, onerror: function (error) { reject(error); } }); }); return response; } catch (error) { throw error; } } async _fetchRedirectUrl(initialUrl, attachmentName) { try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: initialUrl, headers: { ...this.headers, 'Referer': MAIL_CONSTANTS.BASE_URL + '/' }, onload: function (response) { if (response.status === 200) { resolve(response); } else { reject(new Error(`Failed to fetch redirect: ${response.status}`)); } }, onerror: function (error) { reject(error); } }); }); // 方法1: 检查是否已经重定向到最终URL if (response.finalUrl && response.finalUrl !== initialUrl) { return response.finalUrl; } // 方法2: 从响应中提取JavaScript重定向URL const responseText = response.responseText; // 尝试多种JavaScript重定向模式 const redirectPatterns = [ /window\.location\.href\s*=\s*['"]([^'"]+)['"]/, /location\.href\s*=\s*['"]([^'"]+)['"]/, /window\.location\s*=\s*['"]([^'"]+)['"]/, /location\s*=\s*['"]([^'"]+)['"]/, /window\.location\.replace\(['"]([^'"]+)['"]\)/, /location\.replace\(['"]([^'"]+)['"]\)/, /document\.location\s*=\s*['"]([^'"]+)['"]/, /document\.location\.href\s*=\s*['"]([^'"]+)['"]/ ]; for (const pattern of redirectPatterns) { const match = responseText.match(pattern); if (match && match[1]) { return match[1]; } } // 方法3: 查找HTML meta refresh重定向 const metaRefreshMatch = responseText.match(/<meta[^>]+http-equiv=['"]refresh['"][^>]+content=['"][^'"]*url=([^'"]+)['"]/i); if (metaRefreshMatch && metaRefreshMatch[1]) { return metaRefreshMatch[1]; } // 方法4: 查找可能的下载链接 const downloadLinkPatterns = [ /href=['"]([^'"]*download[^'"]*)['"]/i, /url\(['"]([^'"]*download[^'"]*)['"]\)/i, /(https?:\/\/[^'">\s]+download[^'">\s]*)/i ]; for (const pattern of downloadLinkPatterns) { const match = responseText.match(pattern); if (match && match[1]) { return match[1]; } } // 方法5: 如果响应是JSON格式,尝试解析 try { const jsonData = JSON.parse(responseText); if (jsonData.url || jsonData.download_url || jsonData.redirect_url) { const foundUrl = jsonData.url || jsonData.download_url || jsonData.redirect_url; return foundUrl; } } catch (e) { // 不是JSON格式,继续其他方法 } throw new Error(`No redirect URL found in response for ${attachmentName}`); } catch (error) { throw error; } } async init() { this.sid = this.getSid(); if (!this.sid) { return; } // 等待页面加载完成 if (document.readyState === 'loading') { await new Promise(resolve => { document.addEventListener('DOMContentLoaded', resolve); }); } // 检查是否在登录(不可用)页面 if (window.location.pathname.includes('/login')) { this.handleLoginPage(); return; } // 处理主页面 this.attachmentManager = new AttachmentManager(this); // 设置全局引用,供HTML中的onclick使用 window.attachmentManager = this.attachmentManager; } handleLoginPage() { // 可以添加登录(不可用)页面的特定处理逻辑 } handleMainPage() { // 初始化附件管理器 this.attachmentManager = new AttachmentManager(this); } getSid() { // 优先从URL参数获取 const urlParams = new URLSearchParams(window.location.search); let sid = urlParams.get('sid'); if (sid) { return sid; } // 从URL hash中获取 const hash = window.location.hash; const hashMatch = hash.match(/sid=([^&]+)/); if (hashMatch) { sid = hashMatch[1]; return sid; } return ''; } getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return ''; } cleanup() { if (this.manager) { if (this.manager.container) { this.manager.container.remove(); } this.manager = null; } if (this.boundFolderChangeHandler) { window.removeEventListener('hashchange', this.boundFolderChangeHandler, false); this.boundFolderChangeHandler = null; } this.isLoggedIn = false; this.currentFolderId = null; this.sid = null; } getCurrentFolderId() { const hash = window.location.hash; const listMatch = hash.match(/\/list\/(\d+)/); return listMatch ? listMatch[1] : '1'; } async fetchMailList(folderId, pageNow = 0, pageSize = 50) { const requestUrl = this.buildMailListUrl(folderId, pageNow, pageSize); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: requestUrl, headers: this._getDefaultHeaders(), timeout: 15000, onload: (response) => this.handleMailListResponse(response, resolve, reject), onerror: () => reject(new Error('网络连接失败')), ontimeout: () => reject(new Error('请求超时')) }); }); } buildMailListUrl(folderId, pageNow, pageSize) { const params = new URLSearchParams({ r: Date.now(), sid: this.sid, dir: folderId, page_now: pageNow, page_size: pageSize, sort_type: 1, sort_direction: 1, func: 1, tag: '', enable_topmail: true }); return `${MAIL_CONSTANTS.BASE_URL}${MAIL_CONSTANTS.API_ENDPOINTS.MAIL_LIST}?${params}`; } handleMailListResponse(response, resolve, reject) { const statusErrorMap = { 302: '需要重新登录(不可用)', 301: '需要重新登录(不可用)', 403: '权限被拒绝,请重新登录(不可用)' }; if (response.status !== 200) { return reject(new Error(statusErrorMap[response.status] || `HTTP错误: ${response.status}`)); } try { const data = JSON.parse(response.responseText); if (!data?.head) return reject(new Error('响应格式错误')); if (data.head.ret !== 0) return reject(new Error(`API错误: ${data.head.msg || data.head.ret}`)); const result = data.body?.list ? { mails: data.body.list, total: data.body.total_num || data.body.list.length, unread: data.body.unread_num } : { mails: [], total: 0, unread: 0 }; resolve(result); } catch { reject(new Error('响应不是有效的JSON格式')); } } async getAllMails(folderId) { const result = await this.fetchMailList(folderId, 0, 50); return result.mails; } async getMailsWithPagination(folderId, maxPages = 5) { const allMails = []; let page = 0; const pageSize = 50; while (page < maxPages) { const result = await this.fetchMailList(folderId, page, pageSize); if (!result.mails?.length) break; allMails.push(...result.mails); if (result.mails.length < pageSize) break; page++; await new Promise(resolve => setTimeout(resolve, 100)); } return allMails; } } // 初始化下载器 let downloader = null; const observer = new MutationObserver(mutationCallback); function initDownloader() { if (downloader?.attachmentManager) return; downloader = new QQMailDownloader(); observer.disconnect(); downloader.init(); } function mutationCallback(mutationsList, obs) { if (document.querySelector('#mailMainApp') && !downloader?.attachmentManager) { initDownloader(); } } function startObserver() { observer.observe(document.body, { childList: true, subtree: true }); } (function initializeScript() { const initialize = () => { if (document.querySelector('#mailMainApp')) { initDownloader(); } else { startObserver(); } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize, { once: true }); } else { initialize(); } })(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址