Files
tvbox/tvbox-editor.html
2026-02-24 11:21:27 +08:00

713 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TVBox 片源編輯器</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Noto+Sans+TC:wght@300;400;500;700&display=swap');
:root {
--bg: #0b0d12;
--s1: #12151e;
--s2: #181c28;
--s3: #1f2336;
--s4: #262b40;
--border: #272c42;
--border2: #323856;
--accent: #4f7cff;
--accent2: #7c5cfc;
--success: #1fd6a0;
--warn: #f0a500;
--danger: #f04060;
--text: #dde1f0;
--text2: #7880a0;
--text3: #404668;
--mono: 'JetBrains Mono', monospace;
--sans: 'Noto Sans TC', sans-serif;
--r: 8px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--sans);
font-size: 13px;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
/* HEADER */
header {
height: 50px;
display: flex;
align-items: center;
padding: 0 20px;
gap: 14px;
background: var(--s1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.logo { font-family: var(--mono); font-size: 14px; font-weight: 700; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.logo em { font-style: normal; font-weight: 400; color: var(--text2); font-size: 12px; }
.hspace { flex: 1; }
.merge-status { font-size: 11px; color: var(--text3); font-family: var(--mono); }
/* BUTTONS */
.btn {
border: none; border-radius: 6px; cursor: pointer;
font-family: var(--sans); font-size: 12px; font-weight: 500;
padding: 6px 14px; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
transition: all .15s; white-space: nowrap;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #3d6af0; }
.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border2); }
.btn-ghost:hover { background: var(--s3); color: var(--text); }
.btn-success { background: transparent; color: var(--success); border: 1px solid var(--success); }
.btn-success:hover { background: rgba(31,214,160,.1); }
.btn-success:disabled { opacity: .35; cursor: not-allowed; }
.btn-warn { background: transparent; color: var(--warn); border: 1px solid var(--warn); }
.btn-warn:hover { background: rgba(240,165,0,.1); }
.btn-warn:disabled { opacity: .35; cursor: not-allowed; }
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
.btn-danger:hover { background: rgba(240,64,96,.1); }
.btn-danger:disabled { opacity: .35; cursor: not-allowed; }
.btn-sm { font-size: 11px; padding: 4px 9px; }
/* LAYOUT */
.workspace { display: flex; flex: 1; overflow: hidden; }
.panel { display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--border); }
.panel:last-child { border-right: none; }
.phead {
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
color: var(--text2); display: flex; align-items: center; gap: 8px;
flex-shrink: 0; background: var(--s1);
}
.pill { background: var(--s4); color: var(--text3); font-size: 10px; font-family: var(--mono); padding: 1px 7px; border-radius: 10px; }
.pill.green { background: rgba(31,214,160,.12); color: var(--success); }
.pml { margin-left: auto; }
/* PANEL 1: JSON INPUT */
.panel-json { width: 280px; flex-shrink: 0; background: var(--s1); }
#jsonInput {
flex: 1; background: var(--bg); border: none; outline: none;
color: var(--text2); font-family: var(--mono); font-size: 11px;
line-height: 1.65; padding: 14px; resize: none; overflow-y: auto;
}
.json-footer { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); background: var(--s1); flex-shrink: 0; }
.btn-group { display: flex; gap: 8px; }
/* PANEL 2: FETCH */
.panel-fetch { width: 300px; flex-shrink: 0; background: var(--s2); }
.fetch-form { padding: 14px; display: flex; flex-direction: column; gap: 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.flabel { font-size: 11px; font-weight: 600; color: var(--text2); letter-spacing: .06em; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
.fhint { font-size: 10px; color: var(--text3); font-weight: 400; }
.field { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; color: var(--text); font-family: var(--mono); font-size: 11.5px; outline: none; width: 100%; transition: border-color .15s; }
.field:focus { border-color: var(--accent); }
.field::placeholder { color: var(--text3); }
.field-row { display: flex; gap: 6px; }
.field-row .field { flex: 1; }
.progress-bar { height: 2px; background: var(--border); margin: 0 14px 6px; border-radius: 1px; overflow: hidden; display: none; flex-shrink: 0; }
.progress-bar.show { display: block; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 1px; transition: width .3s; }
.fetch-log { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
.log-item { font-family: var(--mono); font-size: 11px; padding: 5px 8px; border-radius: 5px; background: var(--s3); display: flex; align-items: flex-start; gap: 6px; animation: fin .2s ease; }
@keyframes fin { from{opacity:0;transform:translateY(3px)} to{opacity:1;transform:none} }
.li-ic { flex-shrink: 0; font-size: 12px; margin-top: 1px; }
.li-bd { flex: 1; line-height: 1.5; color: var(--text); }
.li-sub { color: var(--text3); font-size: 10px; display: block; word-break: break-all; }
.log-item.ok { border-left: 2px solid var(--success); } .log-item.ok .li-ic { color: var(--success); }
.log-item.err { border-left: 2px solid var(--danger); } .log-item.err .li-ic { color: var(--danger); }
.log-item.info{ border-left: 2px solid var(--accent); } .log-item.info .li-ic { color: var(--accent); }
.log-item.warn{ border-left: 2px solid var(--warn); } .log-item.warn .li-ic { color: var(--warn); }
/* PANEL 3: EDITOR (TABS & MASTER-DETAIL) */
.panel-editor { flex: 1; background: var(--bg); display: flex; flex-direction: column; }
.tab-btn { font-size: 11px; padding: 4px 10px; border-radius: 5px; border: 1px solid transparent; cursor: pointer; background: transparent; color: var(--text2); font-family: var(--sans); transition: all .15s; }
.tab-btn.active { background: var(--s3); color: var(--text); border-color: var(--border2); }
.tab-btn:hover:not(.active) { color: var(--text); }
#editorBody { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* MASTER-DETAIL VIEW */
.editor-wrap { display: flex; flex: 1; overflow: hidden; }
.editor-sidebar { width: 160px; border-right: 1px solid var(--border); background: var(--s1); overflow-y: auto; display: flex; flex-direction: column; flex-shrink: 0; }
.s-item { padding: 12px 14px; font-size: 12px; cursor: pointer; border-bottom: 1px solid var(--border); color: var(--text2); display: flex; flex-direction: column; gap: 4px; transition: background .15s; }
.s-item:hover { background: var(--s3); color: var(--text); }
.s-item.active { background: rgba(79,124,255,.1); color: #fff; border-left: 3px solid var(--accent); padding-left: 11px; }
.s-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
.s-meta { font-size: 10px; font-family: var(--mono); color: var(--text3); }
.s-meta.has-cat { color: var(--success); }
.editor-main { flex: 1; display: flex; flex-direction: column; background: var(--bg); overflow: hidden; }
.editor-toolbar { padding: 8px 14px; background: var(--s2); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; gap: 10px;}
.editor-textarea { flex: 1; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 12px; line-height: 1.6; padding: 14px; resize: none; overflow-y: auto; white-space: pre; }
/* EMPTY */
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--text3); text-align: center; padding: 40px; }
.empty .eico { font-size: 36px; }
/* TOAST */
#toast { position: fixed; bottom: 20px; right: 20px; background: var(--s3); border: 1px solid var(--border2); border-radius: 8px; padding: 10px 16px; font-size: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.5); opacity: 0; transform: translateY(6px); transition: all .2s; pointer-events: none; z-index: 1000; max-width: 300px; }
#toast.show { opacity: 1; transform: translateY(0); }
#toast.ok { border-color: var(--success); color: var(--success); }
#toast.err { border-color: var(--danger); color: var(--danger); }
</style>
</head>
<body>
<header>
<div class="logo">📺 TVBox <em>片源編輯器</em></div>
<div class="hspace"></div>
<span class="merge-status" id="mergeStatus"></span>
<button class="btn btn-ghost" onclick="copyOutput()">📋 複製 JSON</button>
<button class="btn btn-primary" onclick="downloadOutput()">⬇ 匯出 JSON</button>
</header>
<div class="workspace">
<div class="panel panel-json">
<div class="phead">
<span>① 原始 JSON</span>
<span class="pill" id="rawPill">未載入</span>
</div>
<textarea id="jsonInput" placeholder="貼入 TVBox JSON…&#10;&#10;支援帶 // 注釋的格式&#10;貼入後自動解析,或按 Ctrl+Enter"></textarea>
<div class="json-footer">
<input type="file" id="fileInput" accept=".json,.txt" style="display:none">
<div class="btn-group">
<button class="btn btn-ghost" style="flex:1" onclick="document.getElementById('fileInput').click()">📁 選擇檔案</button>
<button class="btn btn-primary" style="flex:1" onclick="loadJSON()">✦ 解析載入</button>
</div>
<button class="btn btn-ghost" onclick="clearAll()">清除全部</button>
</div>
</div>
<div class="panel panel-fetch">
<div class="phead"><span>② 抓取分類並合併</span></div>
<div class="fetch-form">
<div>
<div class="flabel">API 網址 <span class="fhint">苹果CMS 格式</span></div>
<div class="field-row" style="margin-bottom:6px">
<input class="field" id="apiUrl" placeholder="https://example.com/api.php/provide/vod/" onkeydown="if(event.key==='Enter')fetchOne()">
</div>
<div class="field-row">
<input class="field" id="mergeKey" placeholder="site key留空自動比對">
<input class="field" id="mergeName" placeholder="名稱(新增時用)">
</div>
<div style="margin-top:6px">
<button class="btn btn-success" style="width:100%" onclick="fetchOne()" id="fetchOneBtn">↻ 抓取並合併</button>
</div>
</div>
<div>
<div class="flabel">CORS Proxy</div>
<div class="field-row" style="flex-wrap:wrap;gap:5px">
<input class="field" id="proxyInput" value="https://corsproxy.io/?" style="flex:1;min-width:0">
</div>
<div style="display:flex;gap:5px;margin-top:5px">
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setProxy('https://corsproxy.io/?')">corsproxy</button>
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setProxy('https://api.allorigins.win/raw?url=')">allorigins</button>
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setProxy('')">直連</button>
</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-warn" style="flex:1" id="fetchAllBtn" onclick="fetchAllSites()">⚡ 批量抓取</button>
<button class="btn btn-danger" style="flex:1" id="removeInvalidBtn" onclick="removeInvalidSites()">🗑️ 清理失效 API</button>
</div>
<div style="margin-top:2px; display:none" id="stopWrap">
<button class="btn btn-ghost btn-sm" style="width:100%" id="stopBtn" onclick="stopFetch()">停止操作</button>
</div>
</div>
<div class="progress-bar" id="progressBar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<div class="fetch-log" id="fetchLog"><div style="color:var(--text3);font-size:11px;font-family:var(--mono);padding:4px">等待操作…</div></div>
</div>
<div class="panel panel-editor">
<div class="phead">
<span>③ 編輯區</span>
<div class="pml" style="display:flex; gap:8px; align-items:center;">
<button class="btn btn-danger btn-sm" onclick="removeNonType1()" title="移除所有非 type: 1 的站點 (Sources)">🗑️ 移除非 Type 1</button>
<div style="width:1px; height:12px; background:var(--border2);"></div>
<div style="display:flex; gap:4px;">
<button class="tab-btn active" id="btn_view_tab" onclick="setViewMode('tab')">Tab 模式</button>
<button class="tab-btn" id="btn_view_source" onclick="setViewMode('source')">Source 模式</button>
</div>
</div>
</div>
<div id="editorBody">
<div class="empty"><div class="eico">📭</div><p>請先載入 JSON 或開始抓取</p></div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
// STATE
let tvbox = null;
let abortFlag = false;
// 視圖與資料模式
let viewMode = 'tab'; // 'tab' (清單), 'source' (完整原始碼)
let currentEditMode = 'sites'; // 'sites', 'parses'
let selectedItemIndex = 0;
// ── FILE UPLOAD ──
document.getElementById('fileInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
document.getElementById('jsonInput').value = evt.target.result;
loadJSON();
};
reader.readAsText(file);
e.target.value = '';
});
// ── LOAD JSON ──
function stripComments(s) { return s.split('\n').filter(l => !l.trim().startsWith('//')).join('\n'); }
function loadJSON() {
const raw = document.getElementById('jsonInput').value.trim();
if (!raw) { toast('沒有可解析的 JSON','err'); return; }
try {
tvbox = JSON.parse(stripComments(raw));
if (!tvbox.sites) tvbox.sites = [];
if (!tvbox.parses) tvbox.parses = [];
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
document.getElementById('rawPill').className = 'pill green';
selectedItemIndex = 0;
renderEditor(); updateStatus();
toast(`✓ 載入成功:${tvbox.sites.length} Sites`, 'ok');
} catch(e) { toast('解析失敗:' + e.message, 'err'); }
}
function clearAll() {
tvbox = null;
document.getElementById('jsonInput').value = '';
document.getElementById('rawPill').textContent = '未載入';
document.getElementById('rawPill').className = 'pill';
document.getElementById('mergeStatus').textContent = '';
clearLog();
renderEditor();
}
// ── BUILD URL ──
function buildClassUrl(api) {
let u = api.replace(/[?&]ac=(list|detail|videolist|video)\b/g, '').replace(/\/from\/[^/?&]*/g, '').replace(/\/+$/, '');
return u + (u.includes('?') ? '&' : '?') + 'ac=class';
}
function wrapProxy(url) {
const p = document.getElementById('proxyInput').value.trim();
return p ? p + encodeURIComponent(url) : url;
}
function getBaseApi(urlStr) {
if (!urlStr) return '';
try {
const u = new URL(urlStr); u.searchParams.delete('ac');
return u.toString().replace(/\/+$/, '');
} catch(e) { return urlStr.replace(/[?&]ac=[^&]*/g, '').replace(/[?&]$/, '').replace(/\/+$/, ''); }
}
// ── FETCH CATS ──
async function doFetch(apiUrl, timeoutMs = 13000) {
const final = wrapProxy(buildClassUrl(apiUrl));
const r = await fetch(final, { signal: AbortSignal.timeout(timeoutMs) });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
if (d.class && Array.isArray(d.class) && d.class.length) return d.class.map(c => c.type_name).filter(Boolean);
for (const k of ['list','data','result','categories']) {
if (Array.isArray(d[k]) && d[k].length && d[k][0]?.type_name) return d[k].map(c => c.type_name).filter(Boolean);
}
throw new Error('無法解析分類');
}
// ── MERGE ──
function mergeCategories(apiUrl, cats, key, name) {
let idx = -1;
if (key) idx = tvbox.sites.findIndex(s => s.key === key);
if (idx === -1) {
const targetBase = getBaseApi(apiUrl);
idx = tvbox.sites.findIndex(s => getBaseApi(s.api) === targetBase);
}
if (idx === -1) {
try {
const host = new URL(apiUrl).hostname;
idx = tvbox.sites.findIndex(s => s.api && new URL(s.api).hostname === host);
} catch(e) {}
}
if (idx !== -1) {
tvbox.sites[idx].categories = cats;
return { action: 'updated', site: tvbox.sites[idx], index: idx };
}
const newSite = {
key: key || (() => { try { return new URL(apiUrl).hostname; } catch(e) { return apiUrl; } })(),
name: name || (() => { try { return new URL(apiUrl).hostname; } catch(e) { return apiUrl; } })(),
type: 1, api: apiUrl, searchable: 1, quickSearch: 1, filterable: 1, categories: cats
};
tvbox.sites.push(newSite);
return { action: 'added', site: newSite, index: tvbox.sites.length - 1 };
}
// ── FETCH ONE ──
async function fetchOne() {
const apiUrl = document.getElementById('apiUrl').value.trim();
if (!apiUrl) { toast('請輸入 API 網址','err'); return; }
if (!tvbox) { tvbox = { sites: [], parses: [] }; toast('已自動建立新的 JSON 結構', 'info'); }
const key = document.getElementById('mergeKey').value.trim();
const name = document.getElementById('mergeName').value.trim();
const btn = document.getElementById('fetchOneBtn');
btn.disabled = true; btn.textContent = '抓取中…';
log('info','⟳', '抓取 ' + apiUrl);
try {
const cats = await doFetch(apiUrl);
const result = mergeCategories(apiUrl, cats, key, name);
const act = result.action === 'added' ? '新增' : '更新';
log('ok','✓', `${act}${result.site.name}」→ ${cats.length} 個分類`, apiUrl);
if (viewMode === 'tab') {
switchEditMode('sites', false);
selectedItemIndex = result.index;
}
renderEditor(); updateStatus();
toast(`${act} ${cats.length} 個分類`, 'ok');
} catch(e) {
log('err','✗', e.message, apiUrl); toast('失敗:' + e.message, 'err');
} finally { btn.disabled = false; btn.textContent = '↻ 抓取並合併'; }
}
// ── FETCH ALL ──
async function fetchAllSites() {
if (!tvbox) { toast('尚未載入 JSON無法批量抓取','err'); return; }
const httpSites = tvbox.sites.filter(s => s.api?.startsWith('http'));
if (!httpSites.length) { toast('沒有找到 HTTP API 的站點','err'); return; }
abortFlag = false;
document.getElementById('fetchAllBtn').disabled = true;
document.getElementById('removeInvalidBtn').disabled = true;
document.getElementById('stopWrap').style.display = 'block';
document.getElementById('progressBar').classList.add('show');
clearLog(); log('info','⚡', `批量抓取 ${httpSites.length} 個 Sites…`);
let done = 0, ok = 0, fail = 0;
for (const site of httpSites) {
if (abortFlag) { log('warn','⊘','已停止'); break; }
const lbl = site.name || site.key || site.api;
log('info','⟳', `[${done+1}/${httpSites.length}] ${lbl}`);
try {
const cats = await doFetch(site.api);
site.categories = cats; ok++;
log('ok','✓', `${lbl}${cats.length} 個分類`);
} catch(e) { fail++; log('err','✗', `${lbl}${e.message}`); }
done++;
document.getElementById('progressFill').style.width = (done/httpSites.length*100) + '%';
scrollLog(); await sleep(700);
}
log(fail===0?'ok':'warn','■', `完成:${ok} 成功,${fail} 失敗`);
renderEditor(); updateStatus();
document.getElementById('fetchAllBtn').disabled = false;
document.getElementById('removeInvalidBtn').disabled = false;
document.getElementById('stopWrap').style.display = 'none';
setTimeout(() => document.getElementById('progressBar').classList.remove('show'), 2000);
toast(`批量完成:${ok} 成功,${fail} 失敗`, ok>0?'ok':'err');
}
// ── REMOVE INVALID API SITES ──
async function removeInvalidSites() {
if (!tvbox) { toast('尚未載入 JSON', 'err'); return; }
const httpSites = tvbox.sites.filter(s => s.api?.startsWith('http'));
if (!httpSites.length) { toast('沒有找到 HTTP API 的站點', 'err'); return; }
const msg = `警告:系統將測試 ${httpSites.length} 個 HTTP 站點,並「自動刪除」無法連線或失效的 API\n\n如果您的 CORS Proxy 暫時失效,可能會導致所有站點被誤判刪除,建議先手動測試一個站點。\n\n確定要開始清理嗎?`;
if (!confirm(msg)) return;
abortFlag = false;
document.getElementById('fetchAllBtn').disabled = true;
document.getElementById('removeInvalidBtn').disabled = true;
document.getElementById('stopWrap').style.display = 'block';
document.getElementById('progressBar').classList.add('show');
clearLog();
log('warn', '🗑️', `開始檢測並清理失效站點 (共 ${httpSites.length} 個)…`);
let done = 0, ok = 0, fail = 0;
let validSites = [];
// 保留所有非 http 開頭的特殊來源 (例如 clan://, file://)
const nonHttpSites = tvbox.sites.filter(s => !s.api?.startsWith('http'));
for (const site of httpSites) {
if (abortFlag) {
log('warn','⊘','已中斷清理,將保留剩餘未檢測之站點。');
// 把剩下的加回去
const remainingIndex = httpSites.indexOf(site);
validSites = validSites.concat(httpSites.slice(remainingIndex));
break;
}
const lbl = site.name || site.key || site.api;
log('info','⟳', `[${done+1}/${httpSites.length}] 測試 ${lbl}`);
try {
// 這裡採用較短的 timeout(8秒),讓失效判定更快
const cats = await doFetch(site.api, 8000);
site.categories = cats;
validSites.push(site); // 測試成功,保留此站點
ok++;
log('ok','✓', `${lbl} 正常 (${cats.length} 分類)`);
} catch(e) {
fail++;
log('err','✗', `已移除 ${lbl}${e.message}`);
}
done++;
document.getElementById('progressFill').style.width = (done/httpSites.length*100) + '%';
scrollLog(); await sleep(500);
}
// 將保留下來的合併寫回
tvbox.sites = [...nonHttpSites, ...validSites];
log(fail>0?'warn':'ok','■', `檢測完成:保留 ${ok} 個,自動移除了 ${fail} 個失效站點`);
renderEditor();
updateStatus();
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
document.getElementById('fetchAllBtn').disabled = false;
document.getElementById('removeInvalidBtn').disabled = false;
document.getElementById('stopWrap').style.display = 'none';
setTimeout(() => document.getElementById('progressBar').classList.remove('show'), 2000);
toast(`清理完成:移除了 ${fail} 個失效站點`, fail > 0 ? 'ok' : 'info');
}
function stopFetch() { abortFlag = true; document.getElementById('stopWrap').style.display = 'none'; }
// ── DATA TOOLS ──
function removeNonType1() {
if (!tvbox || !tvbox.sites) {
toast('尚未載入 JSON', 'err');
return;
}
const oldLen = tvbox.sites.length;
// 過濾保留 type 為 1 或 "1" 的來源
tvbox.sites = tvbox.sites.filter(s => s.type == 1);
const diff = oldLen - tvbox.sites.length;
toast(`✓ 已清理 ${diff} 個非 Type 1 站點`, 'ok');
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
updateStatus();
if (currentEditMode === 'sites' && selectedItemIndex >= tvbox.sites.length) {
selectedItemIndex = Math.max(0, tvbox.sites.length - 1);
}
renderEditor();
}
// ── LOG ──
function log(type, icon, msg, sub) {
const el = document.getElementById('fetchLog');
if (el.querySelector('div[style]')) el.innerHTML = '';
const d = document.createElement('div'); d.className = 'log-item ' + type;
d.innerHTML = `<span class="li-ic">${icon}</span><span class="li-bd">${esc(msg)}${sub?`<span class="li-sub">${esc(sub)}</span>`:''}</span>`;
el.appendChild(d); scrollLog();
}
function clearLog() { document.getElementById('fetchLog').innerHTML = '<div style="color:var(--text3);font-size:11px;font-family:var(--mono);padding:4px">等待操作…</div>'; }
function scrollLog() { const l = document.getElementById('fetchLog'); l.scrollTop = l.scrollHeight; }
// ── EDITOR & VIEW MODES ──
function setViewMode(mode) {
viewMode = mode;
document.getElementById('btn_view_tab').classList.toggle('active', mode === 'tab');
document.getElementById('btn_view_source').classList.toggle('active', mode === 'source');
renderEditor();
}
function switchEditMode(mode, doRender = true) {
currentEditMode = mode;
selectedItemIndex = 0;
if (doRender) renderEditor();
}
function selectItem(index) {
selectedItemIndex = index;
renderEditor();
}
function renderEditor() {
const body = document.getElementById('editorBody');
if (!tvbox) {
body.innerHTML = '<div class="empty"><div class="eico">📭</div><p>請先載入 JSON 或開始抓取</p></div>';
return;
}
// ==== Source Mode ====
if (viewMode === 'source') {
body.innerHTML = `
<div class="editor-main">
<div class="editor-toolbar">
<span style="font-size:11px;color:var(--text2)">此處為 Source 模式 (完整 JSON 原始碼)</span>
<button class="btn btn-primary btn-sm" onclick="saveFullJson()">💾 套用全部修改</button>
</div>
<textarea class="editor-textarea" id="fullJsonArea">${esc(JSON.stringify(tvbox, null, 2))}</textarea>
</div>`;
return;
}
// ==== Tab Mode ====
const isSites = currentEditMode === 'sites';
const items = isSites ? tvbox.sites : tvbox.parses;
let topNavHtml = `
<div style="padding: 6px 14px; background: var(--s2); border-bottom: 1px solid var(--border); display:flex; gap:4px; flex-shrink: 0;">
<button class="tab-btn ${isSites ? 'active' : ''}" onclick="switchEditMode('sites')">Sites (片源)</button>
<button class="tab-btn ${!isSites ? 'active' : ''}" onclick="switchEditMode('parses')">Parses (解析)</button>
</div>
`;
if (!items || items.length === 0) {
body.innerHTML = topNavHtml + `<div class="empty"><div class="eico">🔍</div><p>此分類無任何資料</p></div>`;
return;
}
if (selectedItemIndex >= items.length) selectedItemIndex = Math.max(0, items.length - 1);
// Build Sidebar
let sidebarHtml = '<div class="editor-sidebar">';
items.forEach((it, i) => {
const name = esc(it.name || it.key || `Item ${i}`);
const cls = i === selectedItemIndex ? 's-item active' : 's-item';
const catCount = it.categories ? it.categories.length : 0;
const catText = catCount > 0 ? `${catCount} 個分類` : '無分類';
const catClass = catCount > 0 ? 's-meta has-cat' : 's-meta';
sidebarHtml += `
<div class="${cls}" onclick="selectItem(${i})" title="${name}">
<span class="s-name">${name}</span>
${isSites ? `<span class="${catClass}">${catText}</span>` : ''}
</div>`;
});
sidebarHtml += '</div>';
// Build Main Detail
const activeItem = items[selectedItemIndex];
const detailHtml = `
<div class="editor-main">
<div class="editor-toolbar">
<span style="font-size:11px;color:var(--text2)">
目前編輯:<strong style="color:var(--text)">${esc(activeItem.name || activeItem.key || '未命名')}</strong>
<span style="margin-left:8px;color:var(--text3)">(清空並儲存即可刪除)</span>
</span>
<button class="btn btn-primary btn-sm" onclick="saveNodeJson()">💾 儲存節點修改</button>
</div>
<textarea class="editor-textarea" id="nodeJsonArea">${esc(JSON.stringify(activeItem, null, 2))}</textarea>
</div>`;
body.innerHTML = topNavHtml + `<div class="editor-wrap">${sidebarHtml}${detailHtml}</div>`;
// Scroll sidebar to active item
setTimeout(() => {
const activeEl = document.querySelector('.s-item.active');
if (activeEl) activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 10);
}
function saveNodeJson() {
const ta = document.getElementById('nodeJsonArea');
const val = ta.value.trim();
const items = currentEditMode === 'sites' ? tvbox.sites : tvbox.parses;
// 如果使用者清空了文字框,執行刪除邏輯
if (!val) {
items.splice(selectedItemIndex, 1);
toast('✓ 已刪除該節點', 'ok');
// 調整索引避免越界
if (selectedItemIndex >= items.length) {
selectedItemIndex = Math.max(0, items.length - 1);
}
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
renderEditor();
updateStatus();
return;
}
// 正常儲存邏輯
try {
const parsed = JSON.parse(val);
items[selectedItemIndex] = parsed;
toast('✓ 節點儲存成功', 'ok');
renderEditor(); // Re-render to update sidebar info
updateStatus();
} catch(e) {
toast('JSON 格式錯誤:' + e.message, 'err');
}
}
function saveFullJson() {
const ta = document.getElementById('fullJsonArea');
try {
tvbox = JSON.parse(ta.value);
if (!tvbox.sites) tvbox.sites = [];
if (!tvbox.parses) tvbox.parses = [];
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
toast('✓ 全部修改已套用', 'ok');
updateStatus();
} catch(e) { toast('套用失敗JSON 格式錯誤:' + e.message, 'err'); }
}
// ── OUTPUT ──
function getOutput() {
if (!tvbox) { toast('目前沒有資料可以輸出','err'); return null; }
return JSON.stringify(tvbox, null, 2);
}
function copyOutput() {
const j = getOutput(); if(!j) return;
navigator.clipboard.writeText(j).then(()=>toast('✓ 已複製完整 JSON','ok'));
}
function downloadOutput() {
const j = getOutput(); if(!j) return;
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([j],{type:'application/json'})), download: 'tvbox_updated.json'
}); a.click(); toast('✓ JSON 檔案已下載','ok');
}
function updateStatus() {
if(!tvbox) return;
const n = (tvbox.sites || []).filter(s => s.categories?.length > 0).length;
document.getElementById('mergeStatus').textContent = `${n}/${tvbox.sites.length} sites 有分類`;
}
// ── UTILS ──
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function setProxy(v){ document.getElementById('proxyInput').value=v; }
function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }
let _t; function toast(msg, type='') {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'show ' + type;
clearTimeout(_t); _t = setTimeout(()=>el.className='', 3000);
}
// ── SHORTCUTS ──
document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter') loadJSON(); });
document.getElementById('jsonInput').addEventListener('paste', () => {
setTimeout(()=>{ const v=document.getElementById('jsonInput').value.trim(); if(v.startsWith('{')) setTimeout(loadJSON,80); },50);
});
</script>
</body>
</html>