/** * -------------------------------------------------------------------- * @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 = ''; 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: '服务器上的 "box" 目录为空或不存在。
'; } else { body.innerHTML = templates.fileBrowserBody({ files: files }); } } catch (error) { body.innerHTML = ``; } } /** * @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 添加新的爬虫规则 (适配弹窗) */ 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 (!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; } updateCurrentRulesDataFromForm(); // 保存前同步一次数据 showToast('开始下载流程...', 'info'); downloadModal.close(); document.getElementById('basic').classList.add('show-status'); document.getElementById('sites').classList.add('show-status'); try { const formData = new FormData(); formData.append('action', 'save_config'); formData.append('dir', targetDir); formData.append('filename', targetFilename); formData.append('content', JSON.stringify(currentRulesData, null, 2)); const response = await fetch('index.php/Proxy/saveConfig', { 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); showToast('主配置文件保存成功!', 'success'); } catch (error) { showToast(`保存主配置失败: ${error.message}`, 'error'); setTimeout(() => { document.getElementById('basic').classList.remove('show-status'); document.getElementById('sites').classList.remove('show-status'); }, 3000); return; } const assetsToDownload = new Map(); const baseUrl = getBaseUrl(mainJsonUrl); downloadStatus = {}; const processAsset = (path, assetId) => { const parsedPath = parseAssetPath(path); if (typeof parsedPath === 'string' && parsedPath.startsWith('./')) { const fullUrl = new URL(parsedPath, baseUrl).href; assetsToDownload.set(fullUrl, { type: assetId.split('-')[0] , path: parsedPath, id: assetId }); } }; processAsset(currentRulesData.spider, 'spider'); (currentRulesData.sites || []).forEach((site, index) => { processAsset(site.jar, `site-${index}-jar`); processAsset(site.ext, `site-${index}-ext`); }); renderBasicTab(currentRulesData); renderSitesTab(currentRulesData.sites); showToast(`共找到 ${assetsToDownload.size} 个本地资源需要下载...`, 'info'); const updateCombinedSiteStatus = (index) => { const jarStatusId = `site-${index}-jar`; const extStatusId = `site-${index}-ext`; const jarStatus = downloadStatus[jarStatusId]; const extStatus = downloadStatus[extStatusId]; let combinedStatus = 'downloaded'; if (jarStatus === 'failed' || extStatus === 'failed') { combinedStatus = 'failed'; } else if (jarStatus === 'downloading' || extStatus === 'downloading') { combinedStatus = 'downloading'; } else if (jarStatus === 'pending' || extStatus === 'pending') { if(jarStatus === 'downloaded' || extStatus === 'downloaded') { combinedStatus = 'downloading'; } else { combinedStatus = 'pending'; } } const siteItem = document.querySelector(`#site-item-${index} .download-status`); if (siteItem) { siteItem.className = `download-status ${combinedStatus}`; } }; for (const [fullUrl, assetInfo] of assetsToDownload.entries()) { downloadStatus[assetInfo.id] = 'downloading'; updateDownloadStatusUI(assetInfo.id, 'downloading'); if (assetInfo.type === 'site') { updateCombinedSiteStatus(parseInt(assetInfo.id.split('-')[1])); } try { const formData = new FormData(); formData.append('action', 'download_asset'); formData.append('source_url', fullUrl); formData.append('target_dir', targetDir); formData.append('relative_path', assetInfo.path); 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[assetInfo.id] = 'downloaded'; updateDownloadStatusUI(assetInfo.id, 'downloaded'); } catch (error) { downloadStatus[assetInfo.id] = 'failed'; updateDownloadStatusUI(assetInfo.id, 'failed'); showToast(`下载失败: ${assetInfo.path}`, 'error'); } finally { if (assetInfo.type === 'site') { updateCombinedSiteStatus(parseInt(assetInfo.id.split('-')[1])); } } } showToast('所有下载任务已完成!', 'success'); const currentPath = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/')); const newUrl = `${window.location.origin}${currentPath}/box/${targetDir}/${targetFilename}`; jsonUrlInput.value = newUrl; showToast('规则地址已更新为本地路径', 'info'); setTimeout(() => { document.getElementById('basic').classList.remove('show-status'); document.getElementById('sites').classList.remove('show-status'); }, 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 = '