// ==UserScript==
// @name Via风格自定义规则拦截器
// @namespace http://tampermonkey.net/
// @version 5.5.1
// @description 完全手动控制折叠/展开,支持域名级和规则级单独禁用,删除规则/域名时原地生效无闪烁,默认按拦截时间排序(当前域名优先),累计拦截次数统计(显示“拦截 X 次”)
// @author Custom Rules Manager
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMzAiIGZpbGw9InVybCgjZ3JhZCkiLz4KPHBvbHlsaW5lIHBvaW50cz0iMjQsMjQgMzIsNDAgNDAsMjQiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSJub25lIi8+CjxkZWZzPgo8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQiIHgxPSIwIiB5MT0iMCIgeDI9IjY0IiB5Mj0iNjQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iIzY2N0VFQSIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM3NjRCQTIiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4=
// ==/UserScript==
(function() {
'use strict';
// ========== 存储与数据结构 ==========
const STORAGE_KEY = 'custom_rules_v9';
const STORAGE_VERSION = 6; // 升级版本,支持累计拦截次数
let dataCache = null;
let currentDomain = window.location.hostname;
let uiVisible = false;
let shadowRoot = null;
let styleElement = null;
let lastRecordedHref = '';
function getDefaultData() {
return {
version: STORAGE_VERSION,
rules: [],
domainStatus: {},
ruleStatus: {},
domainStats: {} // 结构: { lastIntercept: timestamp, totalCount: number }
};
}
// 迁移旧版本统计到新格式
function migrateStats(stats) {
if (!stats) return { lastIntercept: null, totalCount: 0 };
let total = 0;
if (typeof stats.totalCount === 'number') {
total = stats.totalCount;
} else {
// 兼容旧格式:todayCount 和 lastDate 可能曾经存在,将它们累加到总次数(或者保留原有今天次数作为总次数)
if (typeof stats.todayCount === 'number') total += stats.todayCount;
if (typeof stats.lastDate === 'string' && stats.lastDate !== null) {
// 旧格式中可能有历史数据,但未保存总次数,只保留今天次数作为保守估计
}
}
return {
lastIntercept: stats.lastIntercept || null,
totalCount: total
};
}
function loadData() {
if (dataCache) return dataCache;
const saved = GM_getValue(STORAGE_KEY, null);
if (saved) {
try {
const data = JSON.parse(saved);
if (data.version === STORAGE_VERSION) {
data.ruleStatus = data.ruleStatus || {};
data.domainStatus = data.domainStatus || {};
data.domainStats = data.domainStats || {};
// 清理无效规则
const validRules = new Set(data.rules);
Object.keys(data.ruleStatus).forEach(r => { if (!validRules.has(r)) delete data.ruleStatus[r]; });
const validDomains = new Set();
data.rules.forEach(r => { const p = parseRule(r); if(p) validDomains.add(p.domain); });
Object.keys(data.domainStatus).forEach(d => { if (!validDomains.has(d)) delete data.domainStatus[d]; });
// 迁移每个域名的统计
Object.keys(data.domainStats).forEach(d => {
if (!validDomains.has(d)) delete data.domainStats[d];
else data.domainStats[d] = migrateStats(data.domainStats[d]);
});
// 为新域名初始化统计
validDomains.forEach(d => {
if (!data.domainStats[d]) data.domainStats[d] = { lastIntercept: null, totalCount: 0 };
});
dataCache = data;
return data;
}
} catch(e) {}
}
// 尝试加载旧版本 v8 数据(包含 todayCount)
const oldKey = 'custom_rules_v8';
const old = GM_getValue(oldKey, null);
if (old) {
try {
const oldData = JSON.parse(old);
if (oldData.rules) {
const newData = {
version: STORAGE_VERSION,
rules: oldData.rules,
domainStatus: oldData.domainStatus || {},
ruleStatus: oldData.ruleStatus || {},
domainStats: {}
};
// 迁移统计
if (oldData.domainStats) {
for (const [domain, stats] of Object.entries(oldData.domainStats)) {
newData.domainStats[domain] = migrateStats(stats);
}
}
newData.rules.forEach(r => { if (newData.ruleStatus[r] === undefined) newData.ruleStatus[r] = true; });
dataCache = newData;
saveData(dataCache);
return dataCache;
}
} catch(e) {}
}
dataCache = getDefaultData();
return dataCache;
}
function saveData(data) {
dataCache = data;
GM_setValue(STORAGE_KEY, JSON.stringify(data));
}
function getAllRules() {
return loadData().rules.slice();
}
function saveRules(rulesArray) {
const data = loadData();
const oldRuleStatus = data.ruleStatus;
const newRuleStatus = {};
rulesArray.forEach(r => { newRuleStatus[r] = oldRuleStatus[r] !== undefined ? oldRuleStatus[r] : true; });
data.rules = rulesArray;
data.ruleStatus = newRuleStatus;
const validDomains = new Set();
rulesArray.forEach(r => { const p = parseRule(r); if(p) validDomains.add(p.domain); });
Object.keys(data.domainStatus).forEach(d => { if (!validDomains.has(d)) delete data.domainStatus[d]; });
validDomains.forEach(d => { if (data.domainStatus[d] === undefined) data.domainStatus[d] = true; });
validDomains.forEach(d => { if (!data.domainStats[d]) data.domainStats[d] = { lastIntercept: null, totalCount: 0 }; });
saveData(data);
applyRules();
if (shadowRoot) refreshManagePanel();
}
function replaceAllRules(rulesArray) {
const data = getDefaultData();
data.rules = rulesArray;
data.ruleStatus = {};
rulesArray.forEach(r => { data.ruleStatus[r] = true; });
const validDomains = new Set();
rulesArray.forEach(r => { const p = parseRule(r); if(p) validDomains.add(p.domain); });
validDomains.forEach(d => { data.domainStatus[d] = true; });
data.domainStats = {};
validDomains.forEach(d => { data.domainStats[d] = { lastIntercept: null, totalCount: 0 }; });
saveData(data);
applyRules();
if (shadowRoot) refreshManagePanel();
}
function updateInterceptStats(domain) {
if (!domain) return;
const data = loadData();
if (!data.domainStats[domain]) {
data.domainStats[domain] = { lastIntercept: null, totalCount: 0 };
}
const stats = data.domainStats[domain];
stats.totalCount = (stats.totalCount || 0) + 1;
stats.lastIntercept = Date.now();
saveData(data);
}
function getDomainStats(domain) {
const data = loadData();
const stats = data.domainStats[domain];
return stats ? { lastIntercept: stats.lastIntercept, totalCount: stats.totalCount || 0 } : { lastIntercept: null, totalCount: 0 };
}
function formatLastIntercept(timestamp) {
if (!timestamp) return '无拦截记录';
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
const date = new Date(timestamp);
return `${date.getMonth()+1}/${date.getDate()} ${date.getHours().toString().padStart(2,'0')}:${date.getMinutes().toString().padStart(2,'0')}`;
}
function hasActiveRulesForDomain(domain) {
const data = loadData();
if (data.domainStatus[domain] === false) return false;
for (const ruleStr of data.rules) {
const rule = parseRule(ruleStr);
if (rule && rule.domain === domain && data.ruleStatus[ruleStr] !== false) {
return true;
}
}
return false;
}
function recordInterceptionIfNeeded() {
const domain = currentDomain;
const href = location.href;
if (lastRecordedHref === href) return;
if (hasActiveRulesForDomain(domain)) {
updateInterceptStats(domain);
}
lastRecordedHref = href;
}
function isDomainEnabled(domain) {
const data = loadData();
return data.domainStatus[domain] !== false;
}
function setDomainEnabled(domain, enabled) {
const data = loadData();
if (data.domainStatus[domain] === enabled) return;
data.domainStatus[domain] = enabled;
saveData(data);
applyRules();
if (shadowRoot) {
const group = shadowRoot.querySelector(`.domain-group[data-domain="${CSS.escape(domain)}"]`);
if (group) {
const isCurrent = domainMatches(domain, currentDomain);
const statusClass = (isCurrent && enabled) ? 'status-active' : 'status-inactive';
const indicator = group.querySelector('.status-indicator');
if (indicator) indicator.className = `status-indicator ${statusClass}`;
if (enabled) group.classList.remove('disabled');
else group.classList.add('disabled');
const toggleBtn = group.querySelector('.domain-toggle-btn');
if (toggleBtn) {
toggleBtn.textContent = enabled ? '✓ 启用' : '✗ 禁用';
if (enabled) toggleBtn.classList.remove('disabled');
else toggleBtn.classList.add('disabled');
}
updateStatsBar();
} else {
refreshManagePanel();
}
}
}
function isRuleEnabled(ruleStr) {
const data = loadData();
return data.ruleStatus[ruleStr] !== false;
}
function setRuleEnabled(ruleStr, enabled) {
const data = loadData();
if (data.ruleStatus[ruleStr] === enabled) return;
data.ruleStatus[ruleStr] = enabled;
saveData(data);
applyRules();
if (shadowRoot) {
const ruleItem = shadowRoot.querySelector(`.rule-item[data-rule-key="${CSS.escape(ruleStr)}"]`);
if (ruleItem) {
const toggleBtn = ruleItem.querySelector('.rule-toggle-btn');
if (toggleBtn) {
toggleBtn.textContent = enabled ? '✓ 启用' : '✗ 禁用';
if (enabled) {
toggleBtn.classList.remove('disabled');
ruleItem.classList.remove('rule-disabled');
} else {
toggleBtn.classList.add('disabled');
ruleItem.classList.add('rule-disabled');
}
}
}
updateStatsBar();
}
}
function toggleRuleEnabled(ruleStr) {
setRuleEnabled(ruleStr, !isRuleEnabled(ruleStr));
}
function parseRule(ruleString) {
const rule = ruleString.trim();
if (!rule || rule.startsWith('!') || rule.startsWith('#')) return null;
const match = rule.match(/^([^#]+)##(.+)$/);
if (match) {
return { domain: match[1].trim(), selector: match[2].trim(), raw: rule };
}
return null;
}
function domainMatches(ruleDomain, currentDomain) {
if (ruleDomain === '*') return true;
if (ruleDomain.startsWith('*.')) {
const base = ruleDomain.substring(2);
return currentDomain === base || currentDomain.endsWith('.' + base);
}
return currentDomain === ruleDomain;
}
function applyRules() {
const hostname = window.location.hostname;
const data = loadData();
const cssParts = [];
for (const ruleStr of data.rules) {
const rule = parseRule(ruleStr);
if (!rule) continue;
if (data.domainStatus[rule.domain] !== false && data.ruleStatus[ruleStr] !== false && domainMatches(rule.domain, hostname)) {
cssParts.push(`${rule.selector} { display: none !important; }`);
}
}
const css = cssParts.join('\n');
if (!styleElement) {
styleElement = document.getElementById('custom-rules-style');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'custom-rules-style';
document.head.appendChild(styleElement);
}
}
if (styleElement.textContent !== css) {
styleElement.textContent = css;
}
}
function escapeHtml(str) {
return str.replace(/[&<>]/g, m => m === '&' ? '&' : m === '<' ? '<' : '>');
}
function showMessage(msg, type = 'success') {
if (shadowRoot) {
const statusDiv = shadowRoot.querySelector('#status-message');
if (statusDiv) {
statusDiv.textContent = msg;
statusDiv.style.color = type === 'error' ? '#f44336' : '#4CAF50';
setTimeout(() => {
if (statusDiv.textContent === msg) statusDiv.textContent = '';
}, 2500);
}
}
}
function copyToClipboard(text) {
return new Promise(resolve => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
let success = false;
try { success = document.execCommand('copy'); } catch(e) {}
if (!success && navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => success = true).catch(() => {});
}
document.body.removeChild(ta);
resolve(success);
});
}
function downloadTextFile(text, filename) {
const blob = new Blob([text], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
}
function updateStatsBar() {
if (!shadowRoot) return;
const data = loadData();
const totalRules = data.rules.length;
const domains = new Set();
data.rules.forEach(r => { const p = parseRule(r); if(p) domains.add(p.domain); });
const activeRules = data.rules.filter(r => {
const p = parseRule(r);
return p && isDomainEnabled(p.domain) && isRuleEnabled(r) && domainMatches(p.domain, currentDomain);
}).length;
const statsDiv = shadowRoot.querySelector('.stats');
if (statsDiv) {
statsDiv.innerHTML = `共 ${totalRules} 条规则,${domains.size} 个域名
当前域名: ${currentDomain} (${activeRules}条生效)`;
}
}
function collapseGroup(group) {
const rulesContainer = group.querySelector('.rules-container');
const chevron = group.querySelector('.chevron');
if (rulesContainer) {
rulesContainer.style.maxHeight = '0px';
rulesContainer.classList.remove('expanded');
}
if (chevron) chevron.classList.remove('down');
}
function expandGroup(group) {
const rulesContainer = group.querySelector('.rules-container');
const chevron = group.querySelector('.chevron');
if (!rulesContainer) return;
const scrollHeight = rulesContainer.scrollHeight;
rulesContainer.style.maxHeight = scrollHeight + 'px';
rulesContainer.classList.add('expanded');
if (chevron) chevron.classList.add('down');
const onTransitionEnd = () => {
if (rulesContainer.classList.contains('expanded')) {
rulesContainer.style.maxHeight = 'none';
}
rulesContainer.removeEventListener('transitionend', onTransitionEnd);
};
rulesContainer.addEventListener('transitionend', onTransitionEnd, { once: true });
if (scrollHeight === 0) onTransitionEnd();
}
function deleteRuleLocally(ruleStr, ruleElement) {
const group = ruleElement.closest('.domain-group');
if (!group) return false;
const domain = group.getAttribute('data-domain');
let newRules = getAllRules().filter(r => r !== ruleStr);
const data = loadData();
data.rules = newRules;
delete data.ruleStatus[ruleStr];
const remainingDomains = new Set();
newRules.forEach(r => { const p = parseRule(r); if(p) remainingDomains.add(p.domain); });
if (!remainingDomains.has(domain)) {
delete data.domainStatus[domain];
}
saveData(data);
applyRules();
ruleElement.remove();
const rulesContainer = group.querySelector('.rules-container');
const remainingRuleItems = rulesContainer ? rulesContainer.querySelectorAll('.rule-item') : [];
if (remainingRuleItems.length === 0) {
group.remove();
} else {
const ruleCountSpan = group.querySelector('.rule-count');
if (ruleCountSpan) {
ruleCountSpan.textContent = `${remainingRuleItems.length} 条规则`;
}
}
updateStatsBar();
return true;
}
function deleteDomainLocally(domain, groupElement) {
let newRules = getAllRules().filter(r => {
const p = parseRule(r);
return !p || p.domain !== domain;
});
const data = loadData();
data.rules = newRules;
Object.keys(data.ruleStatus).forEach(rule => {
const p = parseRule(rule);
if (p && p.domain === domain) delete data.ruleStatus[rule];
});
delete data.domainStatus[domain];
saveData(data);
applyRules();
groupElement.remove();
updateStatsBar();
const contentArea = shadowRoot.querySelector('#tab-content');
if (contentArea && contentArea.querySelectorAll('.domain-group').length === 0) {
refreshManagePanel();
}
}
async function copyDomainRules(domain) {
const data = loadData();
const rules = data.rules.filter(ruleStr => {
const p = parseRule(ruleStr);
return p && p.domain === domain;
});
if (rules.length === 0) {
showMessage('该域名下没有规则', 'error');
return;
}
const text = rules.join('\n');
const success = await copyToClipboard(text);
showMessage(success ? `已复制 ${rules.length} 条规则` : '复制失败', success ? 'success' : 'error');
}
function sortDomainsByCurrentAndTime(domains, currentDomain, sortByCurrentEnabled) {
const currentSet = new Set();
const otherSet = new Set();
for (const d of domains) {
if (sortByCurrentEnabled && domainMatches(d, currentDomain)) {
currentSet.add(d);
} else {
otherSet.add(d);
}
}
const currentArr = Array.from(currentSet);
const otherArr = Array.from(otherSet);
otherArr.sort((a, b) => {
const timeA = getDomainStats(a).lastIntercept || 0;
const timeB = getDomainStats(b).lastIntercept || 0;
if (timeA !== timeB) return timeB - timeA;
return a.localeCompare(b);
});
return currentArr.concat(otherArr);
}
function renderManagePanel() {
const data = loadData();
const groups = new Map();
for (const ruleStr of data.rules) {
const rule = parseRule(ruleStr);
if (!rule) continue;
if (!groups.has(rule.domain)) groups.set(rule.domain, []);
groups.get(rule.domain).push({ ruleStr, selector: rule.selector });
}
const sortByCurrent = GM_getValue('sort_by_current_domain', true);
let domains = Array.from(groups.keys());
domains = sortDomainsByCurrentAndTime(domains, currentDomain, sortByCurrent);
if (domains.length === 0) {
return `
📝
暂无规则
点击"添加规则"标签创建第一条规则,或导入现有规则
`;
}
const sortHtml = ``;
let groupsHtml = [];
for (const domain of domains) {
const rules = groups.get(domain);
const isCurrent = domainMatches(domain, currentDomain);
const domainEnabled = isDomainEnabled(domain);
const statusClass = (isCurrent && domainEnabled) ? 'status-active' : 'status-inactive';
const domainGroupClass = `domain-group ${isCurrent ? 'current-domain' : ''} ${!domainEnabled ? 'disabled' : ''}`;
const toggleBtnText = domainEnabled ? '✓ 启用' : '✗ 禁用';
const toggleBtnClass = `domain-toggle-btn ${!domainEnabled ? 'disabled' : ''}`;
const stats = getDomainStats(domain);
const lastInterceptStr = formatLastIntercept(stats.lastIntercept);
const totalCount = stats.totalCount || 0;
let rulesHtml = [];
for (const rule of rules) {
const ruleEnabled = isRuleEnabled(rule.ruleStr);
const ruleToggleText = ruleEnabled ? '✓ 启用' : '✗ 禁用';
const ruleToggleClass = `rule-toggle-btn ${!ruleEnabled ? 'disabled' : ''}`;
const ruleItemClass = `rule-item ${!ruleEnabled ? 'rule-disabled' : ''}`;
rulesHtml.push(`
${escapeHtml(domain)}##
${escapeHtml(rule.selector)}
`);
}
groupsHtml.push(`
▶
${escapeHtml(domain)}
${isCurrent ? '当前' : ''}
${rules.length} 条规则
⏱️ ${lastInterceptStr}
📊 拦截 ${totalCount} 次
${rulesHtml.join('')}
`);
}
return sortHtml + groupsHtml.join('');
}
function refreshManagePanel() {
if (!shadowRoot) return;
const container = shadowRoot.querySelector('#tab-content');
if (!container) return;
const collapsedDomains = new Set();
const currentGroups = shadowRoot.querySelectorAll('.domain-group');
currentGroups.forEach(group => {
const domain = group.getAttribute('data-domain');
const rulesContainer = group.querySelector('.rules-container');
if (domain && rulesContainer && !rulesContainer.classList.contains('expanded')) {
collapsedDomains.add(domain);
}
});
container.innerHTML = renderManagePanel();
setTimeout(() => {
const newGroups = shadowRoot.querySelectorAll('.domain-group');
newGroups.forEach(group => {
const domain = group.getAttribute('data-domain');
const rulesContainer = group.querySelector('.rules-container');
const chevron = group.querySelector('.chevron');
if (domain && collapsedDomains.has(domain)) {
if (rulesContainer) {
rulesContainer.style.maxHeight = '0px';
rulesContainer.classList.remove('expanded');
if (chevron) chevron.classList.remove('down');
}
} else {
if (rulesContainer) {
rulesContainer.style.maxHeight = '0px';
rulesContainer.classList.remove('expanded');
if (chevron) chevron.classList.remove('down');
}
}
});
}, 20);
updateStatsBar();
setupEventDelegation();
}
function setupEventDelegation() {
if (!shadowRoot) return;
const content = shadowRoot.querySelector('#tab-content');
if (!content) return;
if (content._delegateListener) content.removeEventListener('click', content._delegateListener);
const clickHandler = (e) => {
const target = e.target;
if (target.classList.contains('delete-domain')) {
e.preventDefault();
e.stopPropagation();
const domain = target.getAttribute('data-domain');
const group = target.closest('.domain-group');
if (domain && group && confirm(`确定要删除域名“${domain}”下的所有规则吗?\n此操作不可撤销。`)) {
deleteDomainLocally(domain, group);
showMessage(`已删除域名 ${domain} 的所有规则`);
}
return;
}
if (target.classList.contains('domain-toggle-btn')) {
e.preventDefault();
e.stopPropagation();
const domain = target.getAttribute('data-domain-toggle');
if (domain) setDomainEnabled(domain, !isDomainEnabled(domain));
return;
}
if (target.classList.contains('copy-domain-rules')) {
e.preventDefault();
e.stopPropagation();
const domain = target.getAttribute('data-domain-copy');
if (domain) copyDomainRules(domain);
return;
}
if (target.classList.contains('rule-toggle-btn')) {
e.preventDefault();
e.stopPropagation();
const rule = decodeURIComponent(target.getAttribute('data-rule-toggle'));
if (rule) toggleRuleEnabled(rule);
return;
}
if (target.classList.contains('copy-rule')) {
e.preventDefault();
e.stopPropagation();
const rule = decodeURIComponent(target.getAttribute('data-rule'));
if (rule) {
copyToClipboard(rule).then(success => {
showMessage(success ? `已复制:${rule.substring(0, 50)}${rule.length>50?'…':''}` : '复制失败', success ? 'success' : 'error');
});
}
return;
}
if (target.classList.contains('delete-rule')) {
e.preventDefault();
e.stopPropagation();
const rule = decodeURIComponent(target.getAttribute('data-rule'));
const ruleItem = target.closest('.rule-item');
if (rule && ruleItem && confirm(`确定要删除规则吗?\n\n${rule}`)) {
deleteRuleLocally(rule, ruleItem);
showMessage('规则已删除');
}
return;
}
const header = target.closest('.domain-header');
if (header && !target.closest('.domain-actions')) {
e.preventDefault();
e.stopPropagation();
const group = header.closest('.domain-group');
if (group) {
const rulesContainer = group.querySelector('.rules-container');
const chevron = header.querySelector('.chevron');
if (rulesContainer) {
if (rulesContainer.classList.contains('expanded')) {
collapseGroup(group);
} else {
expandGroup(group);
}
}
}
return;
}
if (target.classList.contains('domain-name')) {
e.preventDefault();
e.stopPropagation();
const full = target.getAttribute('data-full-domain') || target.textContent;
alert(`完整域名:${full}`);
return;
}
};
content.addEventListener('click', clickHandler);
content._delegateListener = clickHandler;
const sortSwitch = shadowRoot.querySelector('#sort-switch');
if (sortSwitch && !sortSwitch._changeListener) {
sortSwitch.addEventListener('change', function() {
GM_setValue('sort_by_current_domain', this.checked);
refreshManagePanel();
});
sortSwitch._changeListener = true;
}
}
function renderAddTab() {
return ``;
}
function bindAddEvents() {
if (!shadowRoot) return;
const addBtn = shadowRoot.querySelector('#batch-add-btn');
const clearBtn = shadowRoot.querySelector('#batch-clear-btn');
const textarea = shadowRoot.querySelector('#batch-input');
if (addBtn) {
addBtn.onclick = () => {
const input = textarea ? textarea.value : '';
const newRules = input.split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('#') && !l.startsWith('!') && l.includes('##'));
if (newRules.length === 0) {
showMessage('没有找到有效的规则', 'error');
return;
}
const existing = getAllRules();
const unique = newRules.filter(r => !existing.includes(r));
if (unique.length === 0) {
showMessage('所有规则都已存在', 'error');
return;
}
saveRules([...existing, ...unique]);
if (textarea) textarea.value = '';
showMessage(`成功添加 ${unique.length} 条新规则`);
const manageTab = shadowRoot.querySelector('.tab-btn[data-tab="manage"]');
if (manageTab) manageTab.click();
};
}
if (clearBtn) {
clearBtn.onclick = () => {
if (textarea) textarea.value = '';
showMessage('已清空输入框');
};
}
}
function renderToolsTab() {
const rules = getAllRules();
return `
工具
统计信息
总规则数: ${rules.length}
当前域名: ${currentDomain}
生效规则: ${rules.filter(r => { const p = parseRule(r); return p && isDomainEnabled(p.domain) && isRuleEnabled(r) && domainMatches(p.domain, currentDomain); }).length} 条
⚠️ 注意事项
• 导入模式:合并导入(保留现有规则)
• 导出规则可以选择复制或下载
• 建议先导出备份再进行操作
• 域名开关:可临时关闭某个域名的所有规则
• 规则开关:可单独启用/禁用某条规则
• 拦截时间:每次访问该域名且有生效规则时自动记录
• 累计拦截次数:该域名下规则总共生效的次数
• 复制域名规则:复制该域名下所有规则到剪贴板
`;
}
// 导入导出对话框(保持不变,略)
function showExportDialog() {
const dialog = document.createElement('div');
dialog.className = 'dialog-overlay';
dialog.innerHTML = `
共有 ${getAllRules().length} 条规则
📋
复制到剪贴板
将规则复制到剪贴板,然后可以粘贴到其他地方
`;
shadowRoot.appendChild(dialog);
setTimeout(() => dialog.classList.add('active'), 10);
const closeDialog = () => {
dialog.classList.remove('active');
setTimeout(() => dialog.remove(), 300);
};
dialog.querySelector('#export-dialog-close')?.addEventListener('click', closeDialog);
dialog.querySelector('#export-cancel-btn')?.addEventListener('click', closeDialog);
dialog.querySelector('#export-copy-option')?.addEventListener('click', async () => {
const rules = getAllRules();
if (rules.length === 0) {
showMessage('没有规则可以导出', 'error');
closeDialog();
return;
}
const success = await copyToClipboard(rules.join('\n'));
showMessage(success ? `成功复制 ${rules.length} 条规则` : '复制失败', success ? 'success' : 'error');
closeDialog();
});
dialog.querySelector('#export-download-option')?.addEventListener('click', () => {
const rules = getAllRules();
if (rules.length === 0) {
showMessage('没有规则可以导出', 'error');
closeDialog();
return;
}
const filename = `custom-rules-${new Date().toISOString().slice(0,10)}.txt`;
downloadTextFile(rules.join('\n'), filename);
showMessage(`已下载 ${rules.length} 条规则`);
closeDialog();
});
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
}
function showImportDialog() {
const dialog = document.createElement('div');
dialog.className = 'dialog-overlay';
dialog.innerHTML = `
`;
shadowRoot.appendChild(dialog);
setTimeout(() => dialog.classList.add('active'), 10);
const closeDialog = () => {
dialog.classList.remove('active');
setTimeout(() => dialog.remove(), 300);
};
dialog.querySelector('#import-dialog-close')?.addEventListener('click', closeDialog);
dialog.querySelector('#import-cancel-btn')?.addEventListener('click', closeDialog);
dialog.querySelector('#import-file-option')?.addEventListener('click', () => {
closeDialog();
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.txt,.text';
fileInput.style.display = 'none';
fileInput.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => showImportModeDialog(shadowRoot, ev.target.result, 'file');
reader.readAsText(file);
document.body.removeChild(fileInput);
};
document.body.appendChild(fileInput);
fileInput.click();
});
dialog.querySelector('#import-text-option')?.addEventListener('click', () => {
closeDialog();
showTextImportDialog();
});
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
}
function showTextImportDialog() {
const dialog = document.createElement('div');
dialog.className = 'dialog-overlay';
dialog.innerHTML = `
在下方文本框中粘贴规则,每行一条规则
`;
shadowRoot.appendChild(dialog);
setTimeout(() => dialog.classList.add('active'), 10);
const closeDialog = () => {
dialog.classList.remove('active');
setTimeout(() => dialog.remove(), 300);
};
dialog.querySelector('#text-import-dialog-close')?.addEventListener('click', closeDialog);
dialog.querySelector('#text-import-cancel-btn')?.addEventListener('click', closeDialog);
dialog.querySelector('#text-import-next-btn')?.addEventListener('click', () => {
const content = dialog.querySelector('#text-import-textarea').value;
if (!content.trim()) {
showMessage('请输入规则文本', 'error');
return;
}
closeDialog();
showImportModeDialog(shadowRoot, content, 'text');
});
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
}
function showImportModeDialog(shadow, content, sourceType) {
const dialog = document.createElement('div');
dialog.className = 'dialog-overlay';
dialog.innerHTML = `
从${sourceType === 'file' ? '文件' : '文本'}中检测到规则
`;
shadow.appendChild(dialog);
setTimeout(() => dialog.classList.add('active'), 10);
let selectedMode = 'merge';
const mergeOpt = dialog.querySelector('#merge-import-option');
const replaceOpt = dialog.querySelector('#replace-import-option');
mergeOpt.classList.add('selected');
mergeOpt.addEventListener('click', () => {
mergeOpt.classList.add('selected');
replaceOpt.classList.remove('selected');
selectedMode = 'merge';
});
replaceOpt.addEventListener('click', () => {
replaceOpt.classList.add('selected');
mergeOpt.classList.remove('selected');
selectedMode = 'replace';
});
const closeDialog = () => {
dialog.classList.remove('active');
setTimeout(() => dialog.remove(), 300);
};
dialog.querySelector('#import-mode-dialog-close')?.addEventListener('click', closeDialog);
dialog.querySelector('#import-mode-cancel-btn')?.addEventListener('click', closeDialog);
dialog.querySelector('#import-mode-confirm-btn')?.addEventListener('click', () => {
const newRules = content.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#') && !line.startsWith('!') && line.includes('##'));
if (newRules.length === 0) {
showMessage('没有找到有效的规则', 'error');
closeDialog();
return;
}
if (selectedMode === 'merge') {
const existing = getAllRules();
const unique = newRules.filter(r => !existing.includes(r));
if (unique.length === 0) {
showMessage('没有新规则可添加', 'error');
closeDialog();
return;
}
saveRules([...existing, ...unique]);
showMessage(`合并导入 ${unique.length} 条新规则,共 ${existing.length + unique.length} 条`);
} else {
replaceAllRules(newRules);
showMessage(`替换导入 ${newRules.length} 条规则`);
}
closeDialog();
const manageTab = shadowRoot?.querySelector('.tab-btn[data-tab="manage"]');
if (manageTab) manageTab.click();
});
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
}
function bindToolsEvents() {
if (!shadowRoot) return;
const exportCard = shadowRoot.querySelector('#export-card');
const importCard = shadowRoot.querySelector('#import-card');
if (exportCard) exportCard.onclick = () => showExportDialog();
if (importCard) importCard.onclick = () => showImportDialog();
}
// 主UI构建(样式已包含垂直排列统计)
function buildUI() {
if (uiVisible) return;
if (!document.body) {
window.addEventListener('DOMContentLoaded', buildUI, { once: true });
return;
}
const oldHost = document.getElementById('custom-rules-host');
if (oldHost) oldHost.remove();
const host = document.createElement('div');
host.id = 'custom-rules-host';
const shadow = host.attachShadow({ mode: 'open' });
shadowRoot = shadow;
const style = document.createElement('style');
style.textContent = `
:host {
all: initial;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 2147483647;
opacity: 0;
transition: opacity 0.3s;
}
.ui-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
width: 90%;
max-width: 700px;
height: 500px;
max-height: 85vh;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
border: none !important;
outline: none !important;
z-index: 2147483647;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
position: relative;
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.stats {
margin-top: 8px;
font-size: 12px;
opacity: 0.9;
line-height: 1.4;
}
.tabs {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: 12px;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #666;
transition: all 0.2s;
}
.tab-btn.active {
color: #667eea;
border-bottom: 2px solid #667eea;
}
.main-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 16px;
position: relative;
}
.footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.help-btn {
background: none;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.help-btn:hover {
background-color: #f0f0f0;
}
.status-message {
font-size: 12px;
color: #666;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.domain-group {
margin-bottom: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
background: white;
transition: transform 0.2s, box-shadow 0.2s;
}
.domain-group.current-domain {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.domain-group.current-domain .domain-header {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
}
.domain-group.disabled {
opacity: 0.7;
background: #f9f9f9;
}
.domain-group.disabled .rules-container .rule-item {
filter: grayscale(0.2);
opacity: 0.7;
}
.domain-header {
background: #f5f5f5;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
cursor: pointer;
user-select: none;
position: relative;
z-index: 1;
gap: 8px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
margin-top: 4px;
}
.status-active {
background-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.status-inactive {
background-color: #ccc;
}
.domain-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.domain-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
min-width: 0;
}
.domain-name {
font-weight: 500;
color: #333;
font-size: 14px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: color 0.2s;
}
.domain-name:hover {
color: #667eea;
text-decoration: underline;
}
.current-domain-tag {
background: #667eea;
color: white;
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
font-weight: bold;
margin-left: 4px;
flex-shrink: 0;
}
.rule-count-row {
display: flex;
align-items: center;
}
.rule-count {
font-size: 12px;
color: #666;
font-weight: normal;
padding-left: 16px;
}
.stats-row {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
padding-left: 16px;
font-size: 11px;
color: #888;
}
.stats-last, .stats-total {
display: inline-flex;
align-items: center;
gap: 4px;
}
.domain-actions {
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
flex-shrink: 0;
}
.domain-toggle-btn, .copy-domain-rules, .delete-domain {
white-space: nowrap;
text-align: center;
min-width: 80px;
}
.domain-toggle-btn {
background: #667eea;
color: white;
border: none;
padding: 4px 10px;
border-radius: 16px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: 0.2s;
}
.domain-toggle-btn.disabled {
background: #9e9e9e;
}
.copy-domain-rules {
background: #2196F3;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.delete-domain {
background: #ff4444;
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.rules-container {
background: white;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
position: relative;
z-index: 0;
}
.rules-container.expanded {
max-height: none;
}
.rule-item {
padding: 12px 16px;
border-bottom: 1px solid #f5f5f5;
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
transition: background 0.2s;
}
.rule-item.rule-disabled {
background: #f5f5f5;
opacity: 0.7;
}
.rule-item:nth-child(odd) {
background: #fff;
}
.rule-item:nth-child(odd).rule-disabled {
background: #f9f9f9;
}
.rule-item:hover {
background: #e6f0ff !important;
}
.rule-info {
flex: 1;
}
.rule-domain {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.rule-selector {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: #333;
word-break: break-all;
background: rgba(0, 0, 0, 0.02);
padding: 4px 8px;
border-radius: 4px;
border-left: 2px solid #667eea;
}
.rule-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
}
.rule-toggle-btn {
background: #4CAF50;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
min-width: 52px;
}
.rule-toggle-btn.disabled {
background: #9e9e9e;
}
.copy-rule {
background: #2196F3;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.delete-rule {
background: #ff9800;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.copy-rule:hover, .delete-rule:hover, .rule-toggle-btn:hover, .copy-domain-rules:hover {
opacity: 0.85;
}
.action-btn {
flex: 1;
padding: 12px;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.batch-textarea {
width: 100%;
height: 200px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
box-sizing: border-box;
margin-bottom: 10px;
resize: vertical;
}
.empty-state {
text-align: center;
padding: 30px 16px;
}
.empty-icon {
font-size: 36px;
color: #ddd;
margin-bottom: 12px;
}
.empty-text {
color: #999;
margin-bottom: 6px;
font-size: 13px;
}
.empty-hint {
color: #aaa;
font-size: 12px;
}
.chevron {
display: inline-block;
transition: transform 0.3s;
font-size: 10px;
color: #666;
flex-shrink: 0;
margin-right: 8px;
margin-top: 4px;
}
.chevron.down {
transform: rotate(90deg);
}
.content-area::-webkit-scrollbar {
width: 8px;
}
.content-area::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.content-area::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.content-area::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.sort-options {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
flex-wrap: wrap;
}
.sort-label {
font-size: 13px;
color: #666;
font-weight: 500;
}
.sort-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.sort-toggle:hover {
background-color: #f0f0f0;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-switch input:checked + .toggle-slider {
background-color: #667eea;
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.toggle-switch input {
display: none;
}
.toggle-text {
font-size: 13px;
color: #333;
font-weight: 500;
}
.tools-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.tool-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.tool-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.tool-card.export {
border-color: #2196F3;
}
.tool-card.import {
border-color: #4CAF50;
}
.tool-icon {
font-size: 32px;
margin-bottom: 8px;
}
.tool-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #333;
}
.tool-desc {
font-size: 12px;
color: #666;
line-height: 1.4;
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2147483648;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.dialog-overlay.active {
opacity: 1;
pointer-events: all;
}
.dialog-container {
background: white;
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
transform: translateY(20px);
transition: transform 0.3s;
}
.dialog-overlay.active .dialog-container {
transform: translateY(0);
}
.dialog-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
position: relative;
border-radius: 12px 12px 0 0;
}
.dialog-header.export {
background: #2196F3;
}
.dialog-header.import {
background: #4CAF50;
}
.dialog-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.dialog-close {
position: absolute;
top: 12px;
right: 12px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-body {
padding: 20px;
}
.dialog-info {
margin-bottom: 16px;
font-size: 14px;
color: #333;
text-align: center;
}
.export-options, .import-options, .import-mode-options {
display: flex;
flex-direction: column;
gap: 12px;
margin: 20px 0;
}
.export-option, .import-option, .import-mode-option {
display: flex;
align-items: center;
padding: 12px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.export-option:hover, .import-option:hover, .import-mode-option:hover {
background: #e3f2fd;
border-color: #2196F3;
}
.export-option-icon, .import-option-icon, .import-mode-option-icon {
font-size: 24px;
margin-right: 12px;
width: 40px;
text-align: center;
}
.export-option-content, .import-option-content, .import-mode-option-content {
flex: 1;
}
.export-option-title, .import-option-title, .import-mode-option-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.export-option-desc, .import-option-desc, .import-mode-option-desc {
font-size: 12px;
color: #666;
}
.export-cancel-btn, .import-cancel-btn {
width: 100%;
padding: 12px;
background: #f5f5f5;
color: #666;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-top: 10px;
}
.export-cancel-btn:hover, .import-cancel-btn:hover {
background: #e0e0e0;
}
.text-import-area {
margin: 20px 0;
}
.text-import-textarea {
width: 100%;
height: 200px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
margin-bottom: 16px;
}
.text-import-textarea:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.dialog-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.dialog-button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.dialog-button.primary {
background: #4CAF50;
color: white;
}
.dialog-button.primary:hover {
background: #388E3C;
}
.dialog-button.secondary {
background: #2196F3;
color: white;
}
.dialog-button.secondary:hover {
background: #0b7dda;
}
.dialog-button.cancel {
background: #f5f5f5;
color: #666;
}
.dialog-button.cancel:hover {
background: #e0e0e0;
}
.import-mode-option.selected {
background: #e3f2fd;
border-color: #2196F3;
}
`;
shadow.appendChild(style);
const overlay = document.createElement('div');
overlay.className = 'overlay';
const ui = document.createElement('div');
ui.className = 'ui-container';
shadow.appendChild(overlay);
shadow.appendChild(ui);
document.body.appendChild(host);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
ui.style.opacity = '1';
ui.style.transform = 'translate(-50%, -50%) scale(1)';
});
overlay.addEventListener('click', () => closeUI());
uiVisible = true;
function closeUI() {
overlay.style.opacity = '0';
ui.style.opacity = '0';
ui.style.transform = 'translate(-50%, -50%) scale(0.9)';
setTimeout(() => {
host.remove();
uiVisible = false;
shadowRoot = null;
}, 300);
}
function renderMain() {
const data = loadData();
const totalRules = data.rules.length;
const domainsSet = new Set();
data.rules.forEach(r => { const p = parseRule(r); if(p) domainsSet.add(p.domain); });
const activeRules = data.rules.filter(r => {
const p = parseRule(r);
return p && isDomainEnabled(p.domain) && isRuleEnabled(r) && domainMatches(p.domain, currentDomain);
}).length;
ui.innerHTML = `
`;
const closeBtn = ui.querySelector('#close-ui');
if (closeBtn) closeBtn.onclick = closeUI;
const tabs = ui.querySelectorAll('.tab-btn');
const contentDiv = ui.querySelector('#tab-content');
const switchTab = (tabId) => {
if (tabId === 'manage') {
contentDiv.innerHTML = renderManagePanel();
setupEventDelegation();
} else if (tabId === 'add') {
contentDiv.innerHTML = renderAddTab();
bindAddEvents();
} else if (tabId === 'tools') {
contentDiv.innerHTML = renderToolsTab();
bindToolsEvents();
}
};
tabs.forEach(btn => {
btn.onclick = () => {
tabs.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
switchTab(btn.getAttribute('data-tab'));
};
});
switchTab('manage');
const helpBtn = ui.querySelector('#help-btn');
if (helpBtn) helpBtn.onclick = () => {
alert(`自定义规则管理器使用说明:
规则格式:
域名##CSS选择器
示例:
1. 隐藏特定元素:
www.123pan.com##div.ant-modal-root:has(.tuia-modal-wrap)
2. 使用通配符:
*.baidu.com##.advertisement
匹配所有 baidu.com 的子域名
3. 常用选择器:
.class-name # 按类名
#element-id # 按ID
div[class*='ad'] # 按属性包含
div:has(.ad) # 按子元素
4. 管理规则:
- 按域名分组显示,每条规则显示为两行(域名行 + 选择器行)
- 点击域名分组头可展开/折叠规则列表
- 绿色圆点表示规则在当前页面生效(且域名开关和规则开关都开启)
- 当前域名分组高亮显示,并带有"当前"标签
- 支持复制单条规则、删除单条规则、删除整个域名组
- 域名开关/复制规则/删除全部按钮垂直排列,互不遮挡
- 拦截统计信息(最后拦截时间、累计拦截次数)垂直排列
- 累计拦截次数:记录该域名下规则总共生效的次数
- 禁用后规则不会生效,规则项会变灰显示
- 域名过长会自动截断显示省略号,点击可弹出完整域名
- 排序选项可让当前域名规则置顶,其余域名默认按拦截时间排序(最近拦截的在前)
- 规则项交替背景色,鼠标悬停显示浅蓝色反馈
- 所有域名分组默认折叠,点击头部手动展开
- 删除规则/域名时原地移除,无闪烁,不刷新整个列表
5. 拦截统计:
- 每个域名下显示最后拦截时间和累计拦截次数
- 每次访问该域名且有生效规则时自动累加
6. 批量添加:
- 每行一条规则,格式:域名##选择器
7. 导入导出:
- 导出规则:复制到剪贴板或下载为文件
- 导入规则:从文件或文本粘贴,支持合并/替换模式
规则立即生效,无需刷新页面。`);
};
}
renderMain();
}
// 初始化
if (document.head) {
if (!document.getElementById('custom-rules-style')) {
const style = document.createElement('style');
style.id = 'custom-rules-style';
document.head.appendChild(style);
styleElement = style;
} else {
styleElement = document.getElementById('custom-rules-style');
}
} else {
const observer = new MutationObserver(() => {
if (document.head) {
observer.disconnect();
if (!document.getElementById('custom-rules-style')) {
const style = document.createElement('style');
style.id = 'custom-rules-style';
document.head.appendChild(style);
styleElement = style;
} else {
styleElement = document.getElementById('custom-rules-style');
}
applyRules();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
applyRules();
setTimeout(() => recordInterceptionIfNeeded(), 100);
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
currentDomain = location.hostname;
applyRules();
recordInterceptionIfNeeded();
if (uiVisible && shadowRoot) {
updateStatsBar();
const activeTab = shadowRoot.querySelector('.tab-btn.active');
if (activeTab && activeTab.getAttribute('data-tab') === 'manage') {
refreshManagePanel();
}
}
}
});
urlObserver.observe(document, { subtree: true, childList: true });
GM_registerMenuCommand('📋 自定义规则', () => buildUI());
unsafeWindow.customRulesManager = {
getAllRules,
saveRules,
replaceAllRules,
applyRules,
showUI: buildUI,
isDomainEnabled,
setDomainEnabled,
isRuleEnabled,
setRuleEnabled,
toggleRuleEnabled,
getDomainStats
};
console.log('自定义规则管理器 v5.5.1 已启动(累计拦截次数显示为“拦截 X 次”,无闪烁删除)');
})();