/**
* --------------------------------------------------------------------
* @description 项目前端核心文件,文件负责处理TVbox规则编辑器的所有前端逻辑,包括表单渲染、数据处理、
* 规则测试、弹窗管理以及与服务器的交互。
* @author https://t.me/CCfork
* @copyright Copyright (c) 2025, https://t.me/CCfork
* --------------------------------------------------------------------
*/
document.addEventListener('DOMContentLoaded', () => {
/**
* @description 全局变量和实例定义
*/
let currentRulesData = {};
let downloadStatus = {};
let currentEditInfo = {};
let rawJsonContent = '';
let currentConfigBaseDir = '';
const defaultJsonUrl = 'https://raw.githubusercontent.com/liu673cn/box/refs/heads/main/m.json';
/**
* @description 预编译所有Handlebars模板
*/
const templates = {
basicTab: Handlebars.compile(document.getElementById('basic-tab-template')?.innerHTML || ''),
simpleItem: Handlebars.compile(document.getElementById('simple-item-template')?.innerHTML || ''),
siteItem: Handlebars.compile(document.getElementById('site-item-template')?.innerHTML || ''),
filterItem: Handlebars.compile(document.getElementById('filter-item-template')?.innerHTML || ''),
tabContent: Handlebars.compile(document.getElementById('tab-content-template')?.innerHTML || ''),
detailsModalBody: Handlebars.compile(document.getElementById('details-modal-body-template')?.innerHTML || ''),
fileBrowserBody: Handlebars.compile(document.getElementById('file-browser-body-template')?.innerHTML || ''),
addSiteModal: Handlebars.compile(document.getElementById('add-site-modal-template')?.innerHTML || ''),
addParseModal: Handlebars.compile(document.getElementById('add-parse-modal-template')?.innerHTML || ''),
addFilterModal: Handlebars.compile(document.getElementById('add-filter-modal-template')?.innerHTML || ''),
downloadModal: Handlebars.compile(document.getElementById('download-modal-template')?.innerHTML || ''),
};
/**
* @description 注册Handlebars助手函数
*/
Handlebars.registerHelper('eq', (a, b) => a === b);
Handlebars.registerHelper('endsWith', (str, suffix) => typeof str === 'string' && str.endsWith(suffix));
Handlebars.registerHelper('buildList', function(children) {
if (children && children.length > 0) {
return new Handlebars.SafeString(templates.fileBrowserBody({ files: children }));
}
return '';
});
/**
* @description Modal弹窗实例化
*/
const detailsModal = new Modal({
id: 'details-modal',
title: '编辑详情',
footer: ''
});
const sourceViewModal = new Modal({
id: 'source-view-modal',
title: '查看源码',
content: '
'
});
const downloadModal = new Modal({
id: 'download-modal',
title: '下载配置及资源',
content: templates.downloadModal(),
footer: ''
});
const fileBrowserModal = new Modal({
id: 'file-browser-modal',
title: '选择服务器上的配置文件',
footer: `
`
});
const addSiteModal = new Modal({
id: 'add-site-modal',
title: '新增爬虫规则',
content: templates.addSiteModal(),
footer: ''
});
const addParseModal = new Modal({
id: 'add-parse-modal',
title: '新增解析接口',
content: templates.addParseModal(),
footer: ''
});
const addFilterModal = new Modal({
id: 'add-filter-modal',
title: '新增过滤规则',
content: templates.addFilterModal(),
footer: ''
});
const historyModal = new Modal({
id: 'history-modal',
title: '加载历史记录',
content: '',
footer: ''
});
/**
* @description 页面主要元素的引用
*/
const jsonUrlInput = document.getElementById('jsonUrlInput');
const localFileInput = document.getElementById('localFileInput');
const loadingDiv = document.getElementById('loading');
/**
* @description 从localStorage获取URL历史记录
* @returns {string[]}
*/
function getUrlHistory() {
try {
const history = localStorage.getItem('urlHistory');
return history ? JSON.parse(history) : [];
} catch (e) {
return [];
}
}
/**
* @description 将新的URL添加到历史记录中
* @param {string} url
*/
function addToUrlHistory(url) {
let history = getUrlHistory();
history = history.filter(item => item !== url);
history.unshift(url);
if (history.length > 20) {
history.pop();
}
localStorage.setItem('urlHistory', JSON.stringify(history));
}
/**
* @description 更新所有列表的列数
*/
function updateGridColumns() {
const columns = document.getElementById('column-select').value;
document.querySelectorAll('.rule-list-grid').forEach(grid => {
grid.style.setProperty('--grid-columns', columns);
});
}
/**
* @description 解析资源路径,移除md5等后缀
* @param {string} pathStr - 原始路径字符串
* @returns {string|null} 解析后的路径
*/
function parseAssetPath(pathStr) {
if (!pathStr || typeof pathStr !== 'string') return null;
return pathStr.split(';')[0];
}
/**
* @description 获取URL的基础路径
* @param {string} url - 完整的URL地址
* @returns {string} URL的基础路径
*/
function getBaseUrl(url) {
const lastSlash = url.lastIndexOf('/');
return url.substring(0, lastSlash + 1);
}
/**
* @description 更新UI上的下载状态指示器
* @param {string} uniqueId - 状态元素的唯一ID
* @param {string} status - 新的状态 (e.g., 'pending', 'downloading', 'downloaded', 'failed')
*/
function updateDownloadStatusUI(uniqueId, status) {
const statusElement = document.getElementById(`status-${uniqueId}`);
if (statusElement) {
statusElement.className = `download-status ${status || 'pending'}`;
}
}
/**
* @description 更新单个爬虫规则项的综合下载状态
* @param {number} index - 爬虫规则的索引
*/
function updateCombinedSiteStatus(index) {
const site = currentRulesData.sites[index];
if (!site) return;
const assetsToCheck = ['jar', 'ext', 'api'];
const statuses = [];
assetsToCheck.forEach(key => {
const assetId = `site-${index}-${key}`;
if (downloadStatus.hasOwnProperty(assetId)) {
statuses.push(downloadStatus[assetId]);
}
});
if (statuses.length === 0) {
updateDownloadStatusUI(`site-item-${index}`, ''); // 如果没有可下载资源,则清除状态
return;
}
let combinedStatus = 'downloaded';
if (statuses.some(s => s === 'failed')) {
combinedStatus = 'failed';
} else if (statuses.some(s => s === 'downloading')) {
combinedStatus = 'downloading';
} else if (statuses.some(s => s === 'pending')) {
combinedStatus = 'pending';
}
updateDownloadStatusUI(`site-item-${index}`, combinedStatus);
}
/**
* @description 从URL加载并渲染规则
*/
function loadAndRenderRulesFromUrl() {
const url = jsonUrlInput.value.trim();
if (!url) {
showToast('请输入有效的JSON链接地址。', 'error');
return;
}
// --- 核心改动:解析并存储当前配置的基础目录 ---
if (url.includes('/box/')) {
const pathAfterBox = url.split('/box/')[1];
const lastSlashIndex = pathAfterBox.lastIndexOf('/');
if (lastSlashIndex !== -1) {
currentConfigBaseDir = pathAfterBox.substring(0, lastSlashIndex + 1);
} else {
currentConfigBaseDir = '';
}
} else {
currentConfigBaseDir = '';
}
loadingDiv.style.display = 'block';
rawJsonContent = '';
const proxyUrl = `index.php/Proxy/load?target_url=${encodeURIComponent(url)}`;
fetch(proxyUrl)
.then(response => {
if (!response.ok) throw new Error(`HTTP 错误! 状态码: ${response.status}`);
return response.text();
})
.then(responseText => {
rawJsonContent = responseText;
addToUrlHistory(url);
localStorage.setItem('savedJsonUrl', url);
document.getElementById('file-name-display').textContent = url.split('/').pop();
processJsonContent(responseText);
})
.catch(error => {
showToast(`读取或解析失败: ${error.message}`, 'error');
})
.finally(() => {
loadingDiv.style.display = 'none';
});
}
/**
* @description 从本地文件加载并渲染规则
* @param {Event} event - 文件输入事件
*/
function loadAndRenderRulesFromFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
rawJsonContent = e.target.result;
document.getElementById('file-name-display').textContent = file.name;
processJsonContent(e.target.result);
};
reader.onerror = () => showToast('读取本地文件失败。', 'error');
reader.readAsText(file);
event.target.value = '';
}
/**
* @description 处理JSON内容并分发渲染
* @param {string} content - JSON字符串内容
*/
function processJsonContent(content) {
document.querySelectorAll('.tab-content').forEach(tab => tab.innerHTML = '');
loadingDiv.style.display = 'block';
try {
let cleanedContent = content
.split('\n')
.filter(line => !line.trim().startsWith('//') && !line.trim().startsWith('/*'))
.join('\n')
.replace(/,\s*([}\]])/g, '$1');
currentRulesData = JSON.parse(cleanedContent);
renderAllTabs(currentRulesData);
setTimeout(() => {
const initialActiveTab = document.querySelector('.tabs .tab-btn.active');
if (initialActiveTab) {
const mockEvent = { currentTarget: initialActiveTab };
openTab(mockEvent, initialActiveTab.dataset.tab);
}
}, 0);
showToast('规则加载并渲染成功!', 'success');
} catch (error) {
showToast(`解析JSON失败: ${error.message}`, 'error');
} finally {
loadingDiv.style.display = 'none';
}
}
/**
* @description 渲染所有选项卡
* @param {object} data - 完整的规则数据
*/
function renderAllTabs(data = {}){
renderBasicTab(data);
renderLivesTab(data.lives);
renderSitesTab(data.sites);
renderParsesTab(data.parses, data.flags);
renderFiltersTab(data.rules, data.ads);
updateGridColumns();
}
/**
* @description 从所有标签页的表单中读取当前值,并更新到 currentRulesData 对象中。
*/
function updateCurrentRulesDataFromForm() {
if (!currentRulesData) return;
currentRulesData.spider = document.getElementById('spider-url')?.value || "";
currentRulesData.wallpaper = document.getElementById('wallpaper-url')?.value || "";
currentRulesData.warningText = document.getElementById('warning-text')?.value || "";
const ijkText = document.getElementById('ijk-url')?.value;
if (ijkText) {
try {
currentRulesData.ijk = JSON.parse(ijkText);
} catch (e) {
currentRulesData.ijk = ijkText;
}
}
const flagsText = document.getElementById('flags')?.value;
if (flagsText !== undefined) {
currentRulesData.flags = flagsText.split(',').map(f => f.trim()).filter(Boolean);
}
const adsText = document.getElementById('ads')?.value;
if (adsText !== undefined) {
currentRulesData.ads = adsText.split('\n').map(a => a.trim()).filter(Boolean);
}
}
/**
* @description 渲染基础信息选项卡
* @param {object} data - 规则数据
*/
function renderBasicTab(data) {
const container = document.getElementById('basic');
if (!container || !data) return;
container.innerHTML = templates.basicTab({
spiderPath: data.spider || '',
wallpaper: data.wallpaper || '',
ijk: data.ijk ? JSON.stringify(data.ijk, null, 2) : '',
warningText: data.warningText || ''
});
updateDownloadStatusUI('spider', downloadStatus['spider'] || 'pending');
}
/**
* @description 渲染爬虫规则选项卡
* @param {Array} sites - 站点规则数组
*/
function renderSitesTab(sites) {
const container = document.getElementById('sites');
if (!container) return;
container.innerHTML = templates.tabContent({
entityName: '爬虫',
itemType: 'sites',
showCreateButton: true
});
const grid = container.querySelector('.rule-list-grid');
grid.addEventListener('click', handleGridItemClick);
let listHtml = '';
(sites || []).forEach((site, index) => {
const ext = parseAssetPath(site.ext);
const jar = parseAssetPath(site.jar);
let combinedStatus = 'pending';
const jarStatusId = `site-${index}-jar`;
const extStatusId = `site-${index}-ext`;
const jarStatus = downloadStatus[jarStatusId];
const extStatus = downloadStatus[extStatusId];
if (jarStatus === 'failed' || extStatus === 'failed') combinedStatus = 'failed';
else if (jarStatus === 'downloading' || extStatus === 'downloading') combinedStatus = 'downloading';
else if (jarStatus === 'downloaded' || extStatus === 'downloaded') combinedStatus = 'downloaded';
listHtml += templates.siteItem({
index: index,
name: site.name,
api: site.api || '',
displayValue: ext || site.api || site.key || '',
hasAssets: (typeof jar === 'string' && jar.startsWith('./')) || (typeof ext === 'string' && ext.startsWith('./')),
combinedStatus: combinedStatus
});
});
grid.innerHTML = listHtml;
}
/**
* @description 渲染解析接口选项卡
* @param {Array} parses - 解析规则数组
* @param {Array} flags - 解析标识数组
*/
function renderParsesTab(parses, flags) {
const container = document.getElementById('parses');
if (!container) return;
container.innerHTML = templates.tabContent({
entityName: '解析',
itemType: 'parses',
showCreateButton: true
});
container.querySelector('.rule-list-grid').addEventListener('click', handleGridItemClick);
const grid = container.querySelector('.rule-list-grid');
let listHtml = '';
(parses || []).forEach((parse, index) => {
listHtml += templates.simpleItem({
itemType: 'parses',
index: index,
name: parse.name,
url: parse.url
});
});
grid.innerHTML = listHtml;
if (Array.isArray(flags)) {
container.insertAdjacentHTML('beforeend', ``);
}
}
/**
* @description 渲染直播规则选项卡
* @param {Array} lives - 直播规则数组
*/
function renderLivesTab(lives){
const container = document.getElementById('lives');
if(!container) return;
container.innerHTML = templates.tabContent({
entityName: '直播',
itemType: 'lives',
showCreateButton: false
});
container.querySelector('.rule-list-grid').addEventListener('click', handleGridItemClick);
const grid = container.querySelector('.rule-list-grid');
let listHtml = '';
(lives || []).forEach((live, index) => {
listHtml += templates.simpleItem({
itemType: 'lives',
index: index,
name: live.name,
url: live.url
});
});
grid.innerHTML = listHtml;
}
/**
* @description 渲染广告过滤选项卡
* @param {Array} rules - 过滤规则数组
* @param {Array} ads - 广告域名数组
*/
function renderFiltersTab(rules, ads) {
const container = document.getElementById('filters');
if (!container) return;
container.innerHTML = templates.tabContent({
entityName: '过滤规则',
itemType: 'rules',
showCreateButton: true
});
container.querySelector('.rule-list-grid').addEventListener('click', handleGridItemClick);
const grid = container.querySelector('.rule-list-grid');
let listHtml = '';
(rules || []).forEach((rule, index) => {
const displayName = rule.name || (Array.isArray(rule.hosts) ? rule.hosts.join(', ') : rule.host);
let displayValue = '';
if (rule.rule) { displayValue = Array.isArray(rule.rule) ? rule.rule.join('\n') : rule.rule; }
else if (rule.regex) { displayValue = Array.isArray(rule.regex) ? rule.regex.join('\n') : rule.regex; }
listHtml += templates.filterItem({
index: index,
displayName: displayName,
displayValue: displayValue
});
});
grid.innerHTML = listHtml;
if (Array.isArray(ads)) {
container.insertAdjacentHTML('beforeend', ``);
}
}
/**
* @description 打开文件浏览器弹窗
*/
async function openFileBrowser() {
fileBrowserModal.open();
const body = fileBrowserModal.getBodyElement();
body.innerHTML = '';
try {
const response = await fetch('index.php/Proxy/listFiles');
if (!response.ok) throw new Error('无法获取文件列表');
const files = await response.json();
if (files.length === 0) {
body.innerHTML = '服务器上的 "box" 目录为空或不存在。
';
} else {
body.innerHTML = templates.fileBrowserBody({ files: files });
}
} catch (error) {
body.innerHTML = `加载失败: ${error.message}
`;
}
}
/**
* @description 打开选中的服务器文件
*/
function openSelectedServerFile() {
const selectedRadio = fileBrowserModal.getBodyElement().querySelector('input[name="server-file-radio"]:checked');
if (!selectedRadio) {
showToast('请先选择一个JSON文件!', 'error');
return;
}
const filePath = selectedRadio.value;
const currentPath = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/'));
const newUrl = `${window.location.origin}${currentPath}/box/${filePath}`;
jsonUrlInput.value = newUrl;
fileBrowserModal.close();
loadAndRenderRulesFromUrl();
}
/**
* @description 打开详情编辑弹窗
* @param {object} itemData - 该项的数据
* @param {string} itemType - 项目类型
* @param {number} index - 项目索引
*/
function openDetailsModal(itemData, itemType, index) {
if (!itemData) return;
const configs = {
sites: siteConfig,
parses: parseConfig,
lives: liveConfig,
rules: filterConfig
};
const config = configs[itemType];
if(!config) {
showToast('该项目类型不支持编辑', 'warning');
return;
}
currentEditInfo = { itemData, itemType, index, config };
detailsModal.setTitle(`编辑 - ${itemData.name || '项目'}`);
const fields = config.fieldOrder.map(key => {
if (!config.translations[key]) return null;
let value = itemData[key];
if (typeof value === 'object' && value !== null) {
value = JSON.stringify(value, null, 2);
}
const isBoolean = config.booleanFields.includes(key);
return {
id: `${itemType}-${index}-detail-${key}`,
label: config.translations[key],
value: value === undefined || value === null ? '' : value,
isTextarea: config.textareaFields.includes(key) || (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))),
isBoolean: isBoolean,
fullWidth: config.fullWidthFields.includes(key),
trueValue: key === 'pass' ? 'true' : 1,
falseValue: key === 'pass' ? 'false' : 0,
trueText: key === 'pass' ? 'True' : '是',
falseText: key === 'pass' ? 'False' : '否',
};
}).filter(Boolean);
detailsModal.setBody(templates.detailsModalBody({ fields: fields }));
detailsModal.open();
}
/**
* @description 保存弹窗修改
*/
function saveModalChanges() {
const { itemType, index, config } = currentEditInfo;
if (!itemType || index === undefined || !config) return;
const updatedData = { ...currentRulesData[itemType][index] };
config.fieldOrder.forEach(key => {
const inputElement = document.getElementById(`${itemType}-${index}-detail-${key}`);
if (inputElement) {
let value = inputElement.value;
if (config.booleanFields.includes(key)) {
if (key === 'pass') {
updatedData[key] = value === 'true';
} else {
updatedData[key] = value === '1' ? 1 : 0;
}
} else if (inputElement.tagName === 'TEXTAREA' && (value.startsWith('[') || value.startsWith('{'))) {
try {
updatedData[key] = JSON.parse(value);
} catch (e) { updatedData[key] = value; }
} else {
updatedData[key] = value;
}
}
});
currentRulesData[itemType][index] = updatedData;
renderAllTabs(currentRulesData);
detailsModal.close();
showToast('修改已确认', 'success');
}
/**
* @description 删除指定项目
* @param {string} itemType - 项目类型
* @param {number} index - 项目索引
*/
function deleteItem(itemType, index) {
if (!currentRulesData[itemType] || currentRulesData[itemType][index] === undefined) return;
const itemElement = document.querySelector(`[data-item-type="${itemType}"][data-index="${index}"]`);
if(itemElement){
itemElement.classList.add('item-fade-out');
}
setTimeout(() => {
currentRulesData[itemType].splice(index, 1);
renderAllTabs(currentRulesData);
showToast('项目已删除', 'success');
}, 300);
}
/**
* @description 应用一个统一的站点过滤器
* @param {HTMLElement} clickedBtn - 被点击的按钮元素
*/
function applySiteFilter(clickedBtn) {
const isAlreadyActive = clickedBtn.classList.contains('active');
document.querySelectorAll('.site-filter-btn').forEach(btn => {
btn.classList.remove('active');
});
const items = document.querySelectorAll('#sites .rule-item-container');
if (!isAlreadyActive) {
clickedBtn.classList.add('active');
const filterType = clickedBtn.dataset.filterType;
const filterValue = clickedBtn.dataset.filterValue;
items.forEach(item => {
const index = parseInt(item.dataset.index, 10);
const siteData = currentRulesData.sites[index];
if (!siteData) return;
const itemApi = siteData.api || '';
const itemExt = siteData.ext || '';
let isMatch = false;
switch (filterType) {
case 'equals':
isMatch = itemApi === filterValue;
break;
case 'endsWith':
isMatch = itemExt.endsWith(filterValue);
break;
}
item.style.display = isMatch ? '' : 'none';
});
} else {
items.forEach(item => {
item.style.display = '';
});
}
}
/**
* @description 添加新的爬虫规则
*/
async function addSpider() {
const newSite = {
key: document.getElementById('new-site-key-modal').value.trim(),
name: document.getElementById('new-site-name-modal').value.trim(),
type: parseInt(document.getElementById('new-site-type-modal').value, 10),
api: document.getElementById('new-site-api-modal').value.trim(),
searchable: document.getElementById('new-site-searchable-modal').checked ? 1 : 0,
quickSearch: document.getElementById('new-site-quick-modal').checked ? 1 : 0,
filterable: document.getElementById('new-site-filterable-modal').checked ? 1 : 0,
ext: document.getElementById('new-site-ext-modal').value.trim(),
jar: document.getElementById('new-site-jar-modal').value.trim(),
};
if (!newSite.name || !newSite.key) {
showToast('规则名称和唯一标识不能为空!', 'error');
return;
}
if (newSite.ext.startsWith('./') && newSite.ext.endsWith('.json')) {
const customContent = document.getElementById('new-site-custom-content-modal').value;
const saveAsDefault = document.getElementById('save-as-default-toggle-modal').checked;
// --- 核心改动:拼接基础目录和相对路径 ---
const pathFromInput = newSite.ext.substring(2); // 去掉 './'
const finalRelativePath = currentConfigBaseDir + pathFromInput;
const formData = new FormData();
formData.append('relativePath', finalRelativePath);
formData.append('apiName', newSite.api);
formData.append('customContent', customContent);
formData.append('saveAsDefault', saveAsDefault);
try {
showToast('正在创建规则文件...', 'info');
const response = await fetch('index.php/Proxy/createRuleFile', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message);
}
showToast(result.message, 'success');
} catch (error) {
showToast(`文件创建失败: ${error.message}`, 'error');
return;
}
}
if (!currentRulesData.sites) currentRulesData.sites = [];
currentRulesData.sites.unshift(newSite);
renderSitesTab(currentRulesData.sites);
showToast('新爬虫规则已成功添加!', 'success');
addSiteModal.close();
}
/**
* @description 添加新的解析接口 (适配弹窗)
*/
function addParse() {
const newParse = {
name: document.getElementById('new-parse-name-modal').value.trim(),
type: parseInt(document.getElementById('new-parse-type-modal').value, 10),
url: document.getElementById('new-parse-url-modal').value.trim(),
};
const extValue = document.getElementById('new-parse-ext-modal').value.trim();
if (extValue) {
try {
newParse.ext = JSON.parse(extValue);
} catch (e) {
newParse.ext = extValue;
}
}
if (!newParse.name || !newParse.url) {
showToast('接口名称和接口地址不能为空!', 'error');
return;
}
if (isNaN(newParse.type)) {
showToast('类型必须是数字!', 'error');
return;
}
if (!currentRulesData.parses) currentRulesData.parses = [];
currentRulesData.parses.unshift(newParse);
renderParsesTab(currentRulesData.parses, currentRulesData.flags);
showToast('新解析接口已成功添加!', 'success');
addParseModal.close();
}
/**
* @description 添加新的过滤规则 (适配弹窗)
*/
function addFilterRule() {
const newRule = {
name: document.getElementById('new-filter-name-modal').value.trim() || undefined,
host: document.getElementById('new-filter-host-modal').value.trim() || undefined,
};
const hostsText = document.getElementById('new-filter-hosts-modal').value.trim();
const rulesText = document.getElementById('new-filter-rules-modal').value.trim();
try {
if (hostsText) newRule.hosts = JSON.parse(hostsText);
if (rulesText) newRule.rule = JSON.parse(rulesText);
} catch (e) {
showToast('主机列表或规则列表的JSON格式无效!', 'error');
return;
}
Object.keys(newRule).forEach(key => newRule[key] === undefined && delete newRule[key]);
if (!currentRulesData.rules) currentRulesData.rules = [];
currentRulesData.rules.unshift(newRule);
renderFiltersTab(currentRulesData.rules, currentRulesData.ads);
showToast('新过滤规则已添加!', 'success');
addFilterModal.close();
}
/**
* @description 启动下载流程 (已优化下载状态提示)
*/
async function startDownloadProcess() {
const targetDir = document.getElementById('download-dir-input').value.trim();
const targetFilename = document.getElementById('download-filename-input').value.trim();
const mainJsonUrl = jsonUrlInput.value.trim();
if (!targetDir || !targetFilename) {
showToast('目录和文件名不能为空!', 'error');
return;
}
const fileNameElement = document.getElementById('file-name-display');
const originalTitle = document.title;
const originalFileName = fileNameElement.textContent;
const downloadFailures = new Map();
const assetsToDownload = [];
downloadStatus = {};
try {
showToast('开始下载流程...', 'info');
downloadModal.close();
document.getElementById('basic').classList.add('show-status');
document.getElementById('sites').classList.add('show-status');
updateCurrentRulesDataFromForm();
let dataToSave = JSON.parse(JSON.stringify(currentRulesData));
const baseUrl = getBaseUrl(mainJsonUrl);
const discoverAndRegisterAsset = (originalPath, assetId, site = null) => {
if (!originalPath || typeof originalPath !== 'string') return;
if (site && assetId.endsWith('-ext') && (originalPath.startsWith('http://127.0.0.1') || originalPath.startsWith('http://localhost'))) return;
if (site && (site.type === 1 || site.type === 2)) return;
if (site && assetId.endsWith('-api') && site.type !== 3) return;
if (site && assetId.endsWith('-ext') && (site.api === 'csp_AppYs' || site.api === 'csp_AppYsV2')) return;
const parsedPath = parseAssetPath(originalPath);
const isLocalRelative = parsedPath.startsWith('./');
const isRemote = parsedPath.startsWith('http');
if (isLocalRelative || isRemote) {
const sourceUrl = isLocalRelative ? new URL(parsedPath, baseUrl).href : parsedPath;
const alreadyExists = assetsToDownload.some(task => task.sourceUrl === sourceUrl);
if (!alreadyExists) {
assetsToDownload.push({
sourceUrl: sourceUrl,
originalPath: originalPath,
targetRelativePath: parsedPath,
id: assetId
});
downloadStatus[assetId] = 'pending';
}
}
};
discoverAndRegisterAsset(dataToSave.spider, 'spider');
(dataToSave.sites || []).forEach((site, index) => {
discoverAndRegisterAsset(site.jar, `site-${index}-jar`, site);
discoverAndRegisterAsset(site.api, `site-${index}-api`, site);
discoverAndRegisterAsset(site.ext, `site-${index}-ext`, site);
});
renderAllTabs(currentRulesData);
const totalCount = assetsToDownload.length;
showToast(`共找到 ${totalCount} 个资源需要下载...`, 'info');
let downloadedCount = 0;
const updateStatusText = () => {
const statusText = `下载中 (已完成 ${downloadedCount} / 总计 ${totalCount})...`;
document.title = statusText;
fileNameElement.textContent = statusText;
};
updateStatusText();
for (const task of assetsToDownload) {
downloadStatus[task.id] = 'downloading';
updateDownloadStatusUI(task.id, 'downloading');
if (task.id.startsWith('site-')) updateCombinedSiteStatus(parseInt(task.id.split('-')[1]));
try {
const formData = new FormData();
formData.append('action', 'download_asset');
formData.append('source_url', task.sourceUrl);
formData.append('target_dir', targetDir);
formData.append('relative_path', task.targetRelativePath);
const response = await fetch('index.php/Proxy/downloadAsset', { method: 'POST', body: formData });
if (!response.ok) throw new Error(`服务器返回错误: ${response.status}`);
const result = await response.json();
if (!result.success) throw new Error(result.message);
downloadStatus[task.id] = 'downloaded';
} catch (error) {
downloadStatus[task.id] = 'failed';
downloadFailures.set(task.originalPath, error.message);
}
downloadedCount++;
updateStatusText();
updateDownloadStatusUI(task.id, downloadStatus[task.id]);
if (task.id.startsWith('site-')) updateCombinedSiteStatus(parseInt(task.id.split('-')[1]));
}
const remapPath = (originalPath) => {
if (downloadFailures.has(originalPath)) return originalPath;
for (const asset of assetsToDownload) {
if (asset.originalPath === originalPath) return asset.targetRelativePath;
}
return originalPath;
};
dataToSave.spider = remapPath(dataToSave.spider);
(dataToSave.sites || []).forEach(site => {
site.jar = remapPath(site.jar);
site.ext = remapPath(site.ext);
site.api = remapPath(site.api);
});
const finalContentToSave = JSON.stringify(dataToSave, null, 2);
const saveFormData = new FormData();
saveFormData.append('action', 'save_config');
saveFormData.append('dir', targetDir);
saveFormData.append('filename', targetFilename);
saveFormData.append('content', finalContentToSave);
const saveResponse = await fetch('index.php/Proxy/saveConfig', { method: 'POST', body: saveFormData });
if (!saveResponse.ok) throw new Error(`服务器返回错误: ${saveResponse.status}`);
const saveResult = await saveResponse.json();
if (!saveResult.success) throw new Error(saveResult.message);
showToast('主配置文件保存成功!', 'success');
const failureCount = downloadFailures.size;
let reportMessage = `总计任务: ${totalCount}
成功: ${totalCount - failureCount}
失败: ${failureCount}
`;
if (failureCount > 0) {
let failureList = Array.from(downloadFailures.keys()).map(file => `${file}`).join('');
reportMessage += `失败列表 (已在配置中保留原始链接):
`;
}
await showDialog({ type: 'alert', title: '下载报告', message: reportMessage, okText: '关闭' });
const currentPath = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/'));
const newUrl = `${window.location.origin}${currentPath}/box/${targetDir}/${targetFilename}`;
jsonUrlInput.value = newUrl;
addToUrlHistory(newUrl);
} catch (error) {
showToast(`下载流程发生严重错误: ${error.message}`, 'error');
} finally {
document.title = originalTitle;
fileNameElement.textContent = originalFileName;
setTimeout(() => {
document.getElementById('basic').classList.remove('show-status');
document.getElementById('sites').classList.remove('show-status');
downloadStatus = {};
renderAllTabs(currentRulesData);
}, 5000);
}
}
/**
* @description 项目编辑的配置对象
*/
const siteConfig = {
translations: { key: '唯一标识', name: '规则名称', type: '类型', api: '爬虫接口', ext: '规则链接', searchable: '可搜索', quickSearch: '快速搜索', filterable: '可筛选', jar: 'Jar文件' },
fieldOrder: ['key', 'name', 'type', 'api', 'ext', 'searchable', 'quickSearch', 'filterable', 'jar'],
booleanFields: ['searchable', 'quickSearch', 'filterable'],
textareaFields: ['ext', 'jar'],
fullWidthFields: ['ext', 'jar']
};
const parseConfig = {
translations: { name: '接口名称', type: '类型', url: '接口地址', ext: '扩展参数' },
fieldOrder: ['name', 'type', 'url', 'ext'],
booleanFields: [],
textareaFields: ['url', 'ext'],
fullWidthFields: ['url', 'ext']
};
const liveConfig = {
translations: { name: '名称', type: '类型', pass: 'Pass', url: '链接', epg: 'EPG', logo: 'Logo' },
fieldOrder: ['name', 'type', 'pass', 'url', 'epg', 'logo'],
booleanFields: ['pass'],
textareaFields: ['url', 'epg', 'logo'],
fullWidthFields: ['url', 'epg', 'logo']
};
const filterConfig = {
translations: { host: '主机名', rule: '规则列表', name: '规则名称', hosts: '主机列表' },
fieldOrder: ['name', 'host', 'hosts', 'rule'],
booleanFields: [],
textareaFields: ['host', 'hosts', 'rule'],
fullWidthFields: ['host', 'hosts', 'rule']
};
/**
* @description 为规则列表(grid)中的项处理点击事件(事件委托)
* @param {Event} e - 点击事件对象
*/
async function handleGridItemClick(e) {
const itemContainer = e.target.closest('.rule-item-container');
if (!itemContainer) return;
const itemType = itemContainer.dataset.itemType;
const index = parseInt(itemContainer.dataset.index, 10);
const itemData = currentRulesData[itemType]?.[index];
if (!itemData) return;
const deleteButton = e.target.closest('.delete-item-btn');
if(deleteButton){
e.stopPropagation();
deleteItem(itemType, index);
return;
}
const actionButton = e.target.closest('.action-btn');
if(actionButton){
e.stopPropagation();
const action = actionButton.dataset.action;
if (action === 'test-url' && actionButton.dataset.url) {
window.open(actionButton.dataset.url, '_blank');
} else if (action === 'edit-file') {
updateCurrentRulesDataFromForm();
const fileContent = JSON.stringify(currentRulesData, null, 2);
const blob = new Blob([fileContent], { type: 'application/json' });
const tempUrl = URL.createObjectURL(blob);
let targetFile = null;
const extPath = parseAssetPath(itemData.ext);
if (extPath && /\.(json|js|py)$/i.test(extPath)) {
targetFile = extPath;
}
const jarPath = parseAssetPath(itemData.jar);
if (!targetFile && jarPath && /\.(js|py)$/i.test(jarPath)) {
targetFile = jarPath;
}
if (!targetFile) {
showToast('该规则为内置或不可编辑文件类型', 'info');
return;
}
const isLocal = jsonUrlInput.value.includes(window.location.origin) && jsonUrlInput.value.includes('/box/');
let fileUrlPath;
if (isLocal) {
const mainConfigPath = new URL(jsonUrlInput.value).pathname;
const baseDir = mainConfigPath.substring(0, mainConfigPath.lastIndexOf('/'));
const relativeFilePath = targetFile.replace('./', '');
fileUrlPath = `${baseDir}/${relativeFilePath}`.replace('/box/', '');
try {
const response = await fetch(`index.php/Proxy/checkFileExists?path=${encodeURIComponent(fileUrlPath)}`);
const result = await response.json();
if (!result.exists) {
showToast('文件在服务器上不存在,请先下载', 'error');
return;
}
} catch (error) {
showToast('检查文件是否存在时出错', 'error');
return;
}
} else {
const targetDir = document.getElementById('download-dir-input').value.trim();
const targetStatusId = /\.(json|js|py)$/i.test(extPath) ? `site-${index}-ext` : `site-${index}-jar`;
if (downloadStatus[targetStatusId] !== 'downloaded') {
showToast('请先下载此规则文件才能进行编辑', 'error');
return;
}
if (!targetDir) {
showToast('下载目录未设置,无法确定文件路径', 'error');
return;
}
fileUrlPath = `${targetDir}/${targetFile.replace('./', '')}`;
}
const openUrl = `index.php/Edit?file=${encodeURIComponent(fileUrlPath)}&api=${encodeURIComponent(itemData.api || '')}`;
window.open(openUrl, '_blank');
}
} else {
openDetailsModal(itemData, itemType, index);
}
}
/**
* @description 页面初始化和事件绑定
*/
jsonUrlInput.value = localStorage.getItem('savedJsonUrl') || defaultJsonUrl;
document.getElementById('readUrlBtn').addEventListener('click', loadAndRenderRulesFromUrl);
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const url = jsonUrlInput.value.trim();
if (!url.startsWith(window.location.origin) || !url.includes('/box/')) {
showToast('错误:只能保存在此服务器上的文件!', 'error');
return;
}
const pathParts = url.split('/box/');
const relativePath = pathParts.length > 1 ? pathParts[1] : null;
if (!relativePath) {
showToast('无法从URL中解析出有效的文件路径!', 'error');
return;
}
if (Object.keys(currentRulesData).length === 0) {
showToast('没有可保存的数据,请先加载一个规则文件。', 'warning');
return;
}
updateCurrentRulesDataFromForm();
const fileContent = JSON.stringify(currentRulesData, null, 2);
const formData = new FormData();
formData.append('filePath', relativePath);
formData.append('fileContent', fileContent);
showToast('正在保存...', 'info');
fetch('index.php/Edit/save', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.message, 'success');
} else {
throw new Error(result.message);
}
})
.catch(error => {
showToast(`保存失败: ${error.message}`, 'error');
});
});
}
document.getElementById('selectFileBtn').addEventListener('click', openFileBrowser);
document.getElementById('downloadRulesBtn').addEventListener('click', () => {
if (Object.keys(currentRulesData).length === 0) {
showToast('请先加载一个配置文件!', 'error');
return;
}
downloadModal.open();
});
document.getElementById('historyBtn').addEventListener('click', () => {
const history = getUrlHistory();
const listElement = document.getElementById('historyList');
if (!listElement) return;
listElement.innerHTML = '';
if (history.length === 0) {
listElement.innerHTML = '没有历史记录。';
} else {
history.forEach(url => {
const li = document.createElement('li');
li.textContent = url;
li.className = 'history-item';
li.title = `加载: ${url}`;
li.dataset.url = url;
listElement.appendChild(li);
});
}
historyModal.open();
});
historyModal.getBodyElement().addEventListener('click', (e) => {
if (e.target && e.target.classList.contains('history-item')) {
const url = e.target.dataset.url;
jsonUrlInput.value = url;
historyModal.close();
loadAndRenderRulesFromUrl();
}
});
historyModal.getFooterElement().addEventListener('click', (e) => {
if (e.target && e.target.id === 'clearHistoryBtn') {
localStorage.removeItem('urlHistory');
document.getElementById('historyList').innerHTML = '历史记录已清空。';
showToast('历史记录已清空', 'success');
}
});
document.getElementById('online-edit-btn').addEventListener('click', () => {
const url = jsonUrlInput.value.trim();
if (url.startsWith(window.location.origin) && url.includes('/box/')) {
const pathParts = new URL(url).pathname.split('/box/');
if (pathParts.length > 1) {
const filePath = pathParts[1];
window.open(`index.php/Edit?file=${encodeURIComponent(filePath)}`, '_blank');
} else {
showToast('无法从当前URL解析出本地文件路径', 'error');
}
} else {
showToast('请先将规则下载到本地,并加载本地规则后才能进行在线编辑', 'warning');
}
});
document.getElementById('viewSourceBtn').addEventListener('click', () => {
if (rawJsonContent) {
const codeElement = document.getElementById('sourceCodeView');
if (codeElement) {
codeElement.textContent = rawJsonContent;
sourceViewModal.open();
}
} else {
showToast('请先加载一个规则文件以查看源码。', 'warning');
}
});
document.getElementById('column-select').addEventListener('change', updateGridColumns);
localFileInput.addEventListener('change', loadAndRenderRulesFromFile);
document.body.addEventListener('click', async (e) => {
if (e.target.id === 'add-spider-btn-modal') addSpider();
if (e.target.id === 'add-parse-btn-modal') addParse();
if (e.target.id === 'add-filter-btn-modal') addFilterRule();
if (e.target.id === 'modal-save-btn') saveModalChanges();
if (e.target.id === 'start-download-btn') startDownloadProcess();
if (e.target.id === 'select-local-file-btn') {
fileBrowserModal.close();
localFileInput.click();
}
if (e.target.id === 'open-selected-file-btn') openSelectedServerFile();
const dirHeader = e.target.closest('.file-list-item.is-dir');
if (dirHeader) {
const parentLi = dirHeader.parentElement;
if (parentLi && parentLi.classList.contains('dir')) {
parentLi.classList.toggle('collapsed');
const icon = dirHeader.querySelector('.toggle-icon');
if (icon) icon.textContent = parentLi.classList.contains('collapsed') ? '+' : '−';
}
}
const boolSetter = e.target.closest('.bool-setter');
if(boolSetter){
const input = document.getElementById(boolSetter.dataset.targetId);
if (input) {
input.value = boolSetter.dataset.value;
}
}
const siteFilterBtn = e.target.closest('.site-filter-btn');
if (siteFilterBtn) {
applySiteFilter(siteFilterBtn);
}
if (e.target.id === 'toggle-custom-content-btn') {
const ruleLinkInput = document.getElementById('new-site-ext-modal');
const ruleLinkValue = ruleLinkInput ? ruleLinkInput.value.trim() : '';
if (ruleLinkValue.startsWith('http://') || ruleLinkValue.startsWith('https://')) {
showToast('“内容”功能仅适用于创建本地相对路径规则文件。', 'warning');
return;
}
const wrapper = document.getElementById('custom-content-wrapper');
if (wrapper) {
const isHidden = wrapper.style.display === 'none';
wrapper.style.display = isHidden ? 'block' : 'none';
e.target.textContent = isHidden ? '收起' : '内容';
}
}
const deleteAllBtn = e.target.closest('.delete-all-btn');
if (deleteAllBtn) {
const itemType = deleteAllBtn.dataset.itemType;
if (!itemType || !currentRulesData[itemType]) return;
const entityNames = { sites: '爬虫规则', parses: '解析接口', lives: '直播源', rules: '过滤规则' };
const entityName = entityNames[itemType] || '项目';
try {
await showDialog({
type: 'confirm',
title: '危险操作确认',
message: `您确定要清空所有【${entityName}】吗?此操作不可撤销。`
});
const container = document.getElementById(itemType);
container.querySelectorAll('.rule-item-container').forEach(item => {
item.classList.add('shake-on-delete');
setTimeout(() => item.classList.remove('shake-on-delete'), 800);
});
setTimeout(() => {
currentRulesData[itemType] = [];
if (itemType === 'parses') currentRulesData.flags = [];
if (itemType === 'rules') currentRulesData.ads = [];
renderAllTabs(currentRulesData);
showToast(`所有${entityName}已清空!`, 'success');
}, 100);
} catch (error) {
showToast('操作已取消', 'info');
}
}
const createNewBtn = e.target.closest('.create-new-btn');
if (createNewBtn) {
const itemType = createNewBtn.dataset.itemType;
if (itemType === 'sites') {
const apiInput = document.getElementById('new-site-api-modal');
const label = document.querySelector('#add-site-modal label[for="save-as-default-toggle-modal"]');
if(apiInput && label) {
const apiName = apiInput.value.trim();
if (apiName) {
label.textContent = `将以上内容保存为 ${apiName} 的默认模板`;
} else {
label.textContent = '将以上内容保存为该接口的默认模板';
}
}
addSiteModal.open();
} else if (itemType === 'parses') {
addParseModal.open();
} else if (itemType === 'rules') {
addFilterModal.open();
}
}
});
/**
* @description 为“新增爬虫规则”弹窗添加动态交互
*/
const addSiteModalElement = document.getElementById('add-site-modal');
if (addSiteModalElement) {
// 使用事件委托,监听弹窗内部的输入事件
addSiteModalElement.addEventListener('input', (e) => {
if (e.target.id === 'new-site-api-modal') {
const apiName = e.target.value.trim();
const label = addSiteModalElement.querySelector('label[for="save-as-default-toggle-modal"]');
if (label) {
if (apiName) {
label.textContent = `将以上内容保存为 ${apiName} 的默认模板`;
} else {
label.textContent = '将以上内容保存为该接口的默认模板';
}
}
}
});
}
loadAndRenderRulesFromUrl();
updateGridColumns();
});