'use strict'; 'require view'; 'require form'; 'require uci'; 'require ui'; 'require rpc'; 'require tools.clashoo as clashoo'; function getThemeClass() { var h = document.documentElement; // Bootstrap: explicit data-bs-theme attribute wins first if (h.dataset.bsTheme === 'dark') return 'cl-theme-dark'; if (h.dataset.bsTheme === 'light') return 'cl-theme-light'; // Argon: explicit darkmode attribute if (h.dataset.darkmode === 'true') return 'cl-theme-dark'; // Argon: dark.css loaded in document = dark mode active (most reliable) var links = document.querySelectorAll('link[rel="stylesheet"]'); for (var i = 0; i < links.length; i++) { if (links[i].href && links[i].href.indexOf('dark.css') !== -1) return 'cl-theme-dark'; } // Generic: .dark class on root element if (h.classList.contains('dark')) return 'cl-theme-dark'; // Body background luminance (skip transparent/rgba backgrounds) var bg = window.getComputedStyle(document.body).backgroundColor; if (bg && bg.indexOf('rgba') === -1 && bg.indexOf('rgb') !== -1) { var m = bg.match(/\d+/g); if (m && m.length >= 3 && (parseInt(m[0]) + parseInt(m[1]) + parseInt(m[2])) / 3 < 100) return 'cl-theme-dark'; } return 'cl-theme-light'; } var CSS = [ '.cl-wrap{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;--cl-card-border:rgba(128,128,128,.22);--cl-card-bg:rgba(128,128,128,.08);--cl-card-shadow:0 4px 12px rgba(0,0,0,.08);--cl-muted:rgba(74,85,104,.9);--cl-meta:var(--cl-muted);--cl-primary:rgba(0,122,255,.8);--cl-primary-border:rgba(0,122,255,.45);--cl-primary-soft:rgba(0,122,255,.08)}', '.cl-tabs{display:flex;border-bottom:2px solid rgba(128,128,128,.15);margin-bottom:18px}', '.cl-tab{padding:10px 20px;cursor:pointer;font-size:13px;opacity:.55;border-bottom:2px solid transparent;margin-bottom:-2px;transition:opacity .15s}', '.cl-tab.active{opacity:1;border-bottom-color:currentColor;font-weight:600}', '.cl-panel{display:none}.cl-panel.active{display:block}', '.cl-sub-list{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px;table-layout:fixed}', '.cl-sub-list th,.cl-sub-list td{padding:8px 10px;border-bottom:1px solid rgba(128,128,128,.15);text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.cl-sub-list th{font-size:11px;opacity:.55;font-weight:600}', '.cl-sub-list th:nth-child(2),.cl-sub-list td:nth-child(2){width:72px}', '.cl-sub-list th:nth-child(3),.cl-sub-list td:nth-child(3){width:220px;text-align:right;white-space:nowrap}', '.cl-sub-url{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:8px 10px;width:100%;box-sizing:border-box;font-size:13px;margin-bottom:8px}', '.cl-sub-traffic{margin-top:4px;font-size:11px;color:var(--cl-meta,var(--cl-muted,#666))}', '.cl-sub-traffic-bar{height:4px;border-radius:2px;background:rgba(128,128,128,.18);margin-top:3px;overflow:hidden}', '.cl-sub-traffic-fill{height:100%;border-radius:2px;background:var(--primary-color,#0b68dd);transition:width .3s}', '.cl-sub-traffic-fill.cl-traffic-warn{background:#f59e0b}', '.cl-sub-traffic-fill.cl-traffic-danger{background:#ef4444}', '.cl-sub-expire{font-size:11px;color:var(--cl-meta,var(--cl-muted,#666));margin-top:2px}', '.cl-sub-expire.cl-expire-soon{color:#f59e0b}', '.cl-btn-sm{padding:4px 10px;font-size:12px;border-radius:4px;cursor:pointer}', '.cl-section{margin-bottom:24px}', '.cl-section h4{font-size:1.15rem;font-weight:600;margin-bottom:10px;color:var(--title-color,var(--cl-muted));opacity:.95}', /* constrain form inputs on desktop, table stays full-width */ '.cl-form-wrap{max-width:640px}', '.cl-file-list{display:flex;flex-direction:column;gap:10px;margin-top:10px}', '.cl-file-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;border:1px solid var(--cl-card-border);border-radius:8px;background:var(--cl-card-bg);box-shadow:var(--cl-card-shadow)}', '.cl-file-item.is-active{background:rgba(var(--primary-rgb,0,122,255),.08);border-color:rgba(var(--primary-rgb,0,122,255),.2)}', '.cl-file-meta{display:flex;flex:1;min-width:0;align-items:center;justify-content:space-between;gap:12px}', '.cl-file-name{display:flex;align-items:center;gap:8px;min-width:0}', '.cl-file-name-text{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.cl-sb-file-name{display:flex;align-items:center;gap:8px;min-width:0;white-space:normal !important;overflow:visible !important;text-overflow:clip !important}', '.cl-sb-file-name .cl-file-name-text{min-width:0}', '.cl-file-size{font-size:12px;color:var(--cl-meta);white-space:nowrap}', '.cl-file-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}', '.cl-rewrite-wrap{max-width:760px;padding:0;border:0;background:transparent;box-shadow:none}', '.cl-rewrite-group{display:flex;flex-direction:column;gap:8px}', '.cl-rewrite-group-title{font-size:12px;font-weight:700;opacity:.68}', '.cl-rw-divider{height:1px;background:rgba(128,128,128,.18);margin:10px 0}', '.cl-rewrite-wrap .cl-sub-url,.cl-rewrite-wrap .cbi-input-select{padding:8px 12px;border-radius:6px;width:100%;box-sizing:border-box;margin-bottom:0}', '.cl-rewrite-actions{margin-top:4px}', '.cl-actions{display:flex;gap:8px;flex-wrap:wrap}', '.cl-btn-update-sub{border-color:var(--cl-primary-border);color:var(--cl-primary)}', '.cl-btn-switch{background:var(--cl-primary-soft);border:1px solid var(--cl-primary-border);color:var(--cl-primary)}', '.cl-btn-switch:hover{background:rgba(0,122,255,.14);border-color:rgba(0,122,255,.62);color:rgba(0,96,220,.92)}', '.cl-btn-delete{border:1px solid rgba(var(--primary-rgb,0,122,255),.32);color:var(--cl-primary);background:rgba(var(--primary-rgb,0,122,255),.1)}', '.cl-btn-delete:hover{background:rgba(var(--primary-rgb,0,122,255),.16);border-color:rgba(var(--primary-rgb,0,122,255),.4);color:var(--cl-primary)}', '.cl-btn-generate-switch{box-shadow:0 4px 10px rgba(0,0,128,.2)}', '.cl-save-bar{display:flex;gap:8px;margin-top:14px;padding-top:12px;border-top:1px solid rgba(128,128,128,.15)}', '.cl-dns-auto{display:flex;flex-direction:column;gap:8px;max-width:640px}', '.cl-dns-auto-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}', '.cl-dns-auto-status{font-size:12px;color:var(--cl-meta);line-height:1.55}', '.cl-dns-auto-result{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:6px 14px;padding:8px 10px;border:1px solid var(--cl-card-border);border-radius:8px;background:var(--cl-card-bg);color:var(--cl-meta);font-size:12px;line-height:1.5}', '.cl-dns-auto-result b{font-weight:700;opacity:.72;margin-right:4px}', '.cl-sub-schedule{display:flex;flex-direction:column;gap:5px;max-width:760px;box-sizing:border-box;margin:2px 0 14px;padding:8px 2px 4px 6px}', '.cl-sub-schedule-row{display:flex;align-items:center;gap:10px;flex-wrap:nowrap}', '.cl-sub-schedule-toggle{display:inline-flex;align-items:center;gap:8px;height:30px;font-size:13px;font-weight:600;line-height:30px;cursor:pointer;white-space:nowrap}', '.cl-sub-schedule-toggle input[type="checkbox"]{appearance:none!important;-webkit-appearance:none!important;position:static!important;top:auto!important;bottom:auto!important;width:16px!important;height:16px!important;min-width:16px!important;min-height:16px!important;margin:0!important;padding:0!important;border:1px solid rgba(128,128,128,.7)!important;border-radius:3px!important;background:transparent!important;box-shadow:none!important;vertical-align:middle!important;flex:0 0 16px}', '.cl-sub-schedule-toggle input[type="checkbox"]:checked{border-color:var(--cl-primary)!important;background:var(--cl-primary) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3E%3Cpath fill=%27none%27 stroke=%27white%27 stroke-width=%272.2%27 d=%27M3 8l3 3 7-7%27/%3E%3C/svg%3E") center/12px 12px no-repeat!important}', '.cl-sub-schedule-status{font-size:11px;color:var(--cl-meta);line-height:1.45}', '.cl-sub-schedule-interval{display:inline-flex;align-items:center;gap:6px;height:30px;font-size:12px;line-height:30px;color:var(--cl-meta);white-space:nowrap}', '.cl-sub-schedule-interval input[type="number"]{width:52px!important;height:30px!important;min-height:30px!important;box-sizing:border-box!important;margin:0!important;padding:2px 6px!important;line-height:24px!important;text-align:center}', '.cl-sub-schedule-row .btn,.cl-sub-schedule-row .cbi-button{height:30px!important;min-height:30px!important;box-sizing:border-box!important;padding:3px 10px!important;line-height:22px!important}', '.cl-sub-schedule-row :disabled{cursor:not-allowed;opacity:.62}', '.cl-section-toggle{font-size:12px;cursor:pointer;flex-shrink:0;margin-left:auto}', '.cl-collapsible.cl-closed>*:not(h3){display:none!important}', /* 折叠标题:通过 .clashoo-section-header wrapper class 上色,不裸改 LuCI 默认 h3 样式 */ '.clashoo-section-header{display:flex!important;align-items:center;gap:8px;position:relative}', '.clashoo-section-header::before{content:"";flex:0 0 3px;width:3px;height:14px;border-radius:2px;background:var(--primary-color,var(--cl-primary,#007aff))}', '.cl-wrap .cbi-section-remove.right{background:transparent!important}', '.cl-json-editor{width:100%;height:340px;font-family:monospace;font-size:11px;border:1px solid rgba(128,128,128,.25);border-radius:8px;padding:10px;box-sizing:border-box;resize:vertical;background:rgba(0,0,0,.02)}', '.cl-editor-hdr{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;font-weight:600}', /* CodeMirror 编辑器外观(小文件套 CM,大文件回退 cl-json-editor textarea)*/ '.cl-cm-wrap{width:100%}', '.cl-cm-host .CodeMirror{height:340px;border:1px solid rgba(128,128,128,.25);border-radius:8px;font-size:12px;font-family:monospace}', '.cl-cm-host.cl-cm-dark .CodeMirror{background:#1e2228;color:#d4d4d4}', '.cl-cm-host.cl-cm-dark .CodeMirror-gutters{background:#23282f;border-right:1px solid rgba(255,255,255,.08)}', '.cl-cm-host.cl-cm-dark .CodeMirror-linenumber{color:#5c6370}', '.cl-cm-host.cl-cm-dark .CodeMirror-cursor{border-left-color:#d4d4d4}', '.cl-cm-host.cl-cm-dark .CodeMirror-selected{background:rgba(255,255,255,.12)}', '.cl-cm-host.cl-cm-dark .CodeMirror-activeline-background{background:rgba(255,255,255,.04)}', '.cl-cm-host.cl-cm-dark .cm-string{color:#ce9178}', '.cl-cm-host.cl-cm-dark .cm-number{color:#b5cea8}', '.cl-cm-host.cl-cm-dark .cm-keyword,.cl-cm-host.cl-cm-dark .cm-atom{color:#569cd6}', '.cl-cm-host.cl-cm-dark .cm-property,.cl-cm-host.cl-cm-dark .cm-attribute{color:#9cdcfe}', '.cl-cm-host.cl-cm-dark .cm-comment{color:#6a9955}', '.cl-cm-host.cl-cm-dark .cm-meta,.cl-cm-host.cl-cm-dark .cm-def{color:#dcdcaa}', '.cl-active-badge{font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:rgba(var(--primary-rgb,0,122,255),.12);color:var(--cl-primary)}', '.cl-hint{font-size:11px;opacity:.45;margin-left:auto}', /* hide auto-generated section IDs in TypedSection */ '.cbi-section-table-titles .cbi-section-table-cell:first-child{display:none}', '.cbi-section-table-row .cbi-section-table-cell:first-child{display:none}', '.cl-mode-tabs{display:inline-flex;gap:4px;margin:6px 0}', '.cl-mode-tab-active{font-weight:700}', '.cl-panel .cbi-section>h3{font-size:13px !important;font-weight:600;margin-bottom:8px}', '.cl-panel .cbi-value-title{font-size:13px !important}', '.cl-panel .cbi-value-field input,.cl-panel .cbi-value-field select,.cl-panel .cbi-value-field textarea{font-size:13px !important}', '.cl-panel .cbi-section-descr,.cl-panel .cbi-value-helptext{font-size:12px !important}', '.cl-panel .cbi-section{margin-bottom:12px}', '.cl-wrap .cbi-section>h3,.cl-wrap .cbi-value-title,.cl-wrap .cbi-section-descr,.cl-wrap .cbi-value-helptext{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif !important}', '.cl-wrap .cbi-input-text,.cl-wrap .cbi-input-select,.cl-wrap select,.cl-wrap input,.cl-wrap textarea,.cl-wrap .btn,.cl-wrap .cbi-button{font-size:13px !important;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif !important}', '.cl-wrap .btn,.cl-wrap .cbi-button{padding:4px 10px;line-height:1.35}', '.cl-sb-list td,.cl-sb-size{color:var(--cl-meta)}', '@media(prefers-color-scheme:dark){.cl-wrap{--cl-card-border:rgba(255,255,255,.14);--cl-card-bg:rgba(255,255,255,.04);--cl-card-shadow:0 4px 12px rgba(0,0,0,.28);--cl-muted:rgba(220,228,244,.58);--cl-meta:rgba(220,228,244,.72)}}', '.cl-theme-dark{--cl-card-border:rgba(255,255,255,.14);--cl-card-bg:rgba(255,255,255,.04);--cl-card-shadow:0 4px 12px rgba(0,0,0,.28);--cl-muted:rgba(220,228,244,.58);--cl-meta:rgba(220,228,244,.72)}', '@media(max-width:680px){.cl-wrap{--cl-meta:#4b5870}.cl-sub-list.cl-sb-list td:first-child{white-space:normal;overflow:visible;text-overflow:clip}}', '@media(max-width:680px){.cl-file-name-text,.cl-file-size,.cl-sub-traffic,.cl-sub-expire,.cl-sb-list td,.cl-sb-size,.cl-dns-auto-status,.cl-dns-auto-result{color:#4b5870!important}}', '@media(max-width:680px){html body .cl-wrap .cl-file-item .cl-file-meta .cl-file-size{color:#4b5870!important}}', '@media(max-width:680px){.cl-file-item{flex-direction:column;align-items:stretch;gap:8px}.cl-file-meta{flex-direction:column;align-items:stretch;gap:6px}.cl-file-name-text{white-space:normal;overflow-wrap:anywhere}.cl-file-actions{justify-content:flex-end}.cl-sub-expire,.cl-sub-traffic{white-space:normal}}', '@media(max-width:680px){.cl-form-wrap{max-width:100%}}', '@media(max-width:680px){.cl-dns-auto,.cl-dns-auto-result{max-width:100%;width:100%;box-sizing:border-box}.cl-dns-auto-result{grid-template-columns:1fr}}', '@media(max-width:680px){.cl-sub-schedule{width:100%;padding:8px 0 4px 4px}.cl-sub-schedule-row{gap:8px;flex-wrap:wrap}.cl-sub-schedule-toggle{width:100%}.cl-sub-schedule-interval{gap:5px}.cl-sub-schedule-row .cbi-button-action{white-space:nowrap}}' ].join(''); var callListSubs = rpc.declare({ object: 'luci.clashoo', method: 'list_subscriptions', expect: {} }); var callListDir = rpc.declare({ object: 'luci.clashoo', method: 'list_dir_files', params: ['type'], expect: {} }); var callDownloadSubs = rpc.declare({ object: 'luci.clashoo', method: 'download_subs', expect: {} }); var callDownloadSubsStatus = rpc.declare({ object: 'luci.clashoo', method: 'download_subs_status', expect: {} }); var callUpdateSub = rpc.declare({ object: 'luci.clashoo', method: 'update_sub', params: ['name'], expect: {} }); var callSubscriptionUpdateAll = rpc.declare({ object: 'luci.clashoo', method: 'subscription_update_all', expect: {} }); var callSubscriptionUpdateStatus = rpc.declare({ object: 'luci.clashoo', method: 'subscription_update_status', expect: {} }); var callSetSubscriptionUpdateSchedule = rpc.declare({ object: 'luci.clashoo', method: 'set_subscription_update_schedule', params: ['enabled', 'interval'], expect: {} }); var callSetConfig = rpc.declare({ object: 'luci.clashoo', method: 'set_config', params: ['name'], expect: {} }); var callDeleteCfg = rpc.declare({ object: 'luci.clashoo', method: 'delete_config', params: ['name', 'type'], expect: {} }); var callUploadConfigChunk = rpc.declare({ object: 'luci.clashoo', method: 'upload_config_chunk', params: ['name', 'content', 'type', 'index', 'total'], expect: {} }); var callReadOtherConfig = rpc.declare({ object: 'luci.clashoo', method: 'read_other_config', params: ['name', 'type'], expect: {} }); var callListTemplates = rpc.declare({ object: 'luci.clashoo', method: 'list_templates', expect: {} }); var callUploadTemplate= rpc.declare({ object: 'luci.clashoo', method: 'upload_template', params: ['name', 'content'], expect: {} }); var callApplyRewrite = rpc.declare({ object: 'luci.clashoo', method: 'apply_rewrite', params: ['base_type','base_name','rewrite_type','rewrite_name','output_name','set_active'], expect: {} }); var callFetchUrl = rpc.declare({ object: 'luci.clashoo', method: 'fetch_rewrite_url', params: ['url','name'], expect: {} }); var callApplyTplUrl = rpc.declare({ object: 'luci.clashoo', method: 'apply_template_with_url', params: ['template_source','sub_url','output_name','set_active'], expect: {} }); var callMigrateSbProfile = rpc.declare({ object: 'luci.clashoo', method: 'migrate_singbox_profile', params: ['name'], expect: {} }); var callSmartFlushCache = rpc.declare({ object: 'luci.clashoo', method: 'smart_flush_cache', expect: {} }); var callDetectPrimaryGroup = rpc.declare({ object: 'luci.clashoo', method: 'detect_primary_group', expect: {} }); function fastResolve(promise, timeoutMs, fallback) { var t = new Promise(function (resolve) { setTimeout(function () { resolve(fallback); }, timeoutMs); }); return Promise.race([L.resolveDefault(promise, fallback), t]); } function uploadConfigContent(name, content, type) { var chunkSize = 24576; var total = Math.max(1, Math.ceil((content || '').length / chunkSize)); var index = 0; function sendNext() { var chunk = (content || '').slice(index * chunkSize, (index + 1) * chunkSize); return L.resolveDefault(callUploadConfigChunk(name, chunk, type || '2', String(index), String(total)), {}).then(function (r) { if (!r || !r.success) throw new Error((r && (r.message || r.error)) || _("Upload failed")); index++; return index < total ? sendNext() : r; }); } return sendNext(); } function loadUiState() { return L.resolveDefault(uci.load('clashoo'), null).then(function () { return { core_type: uci.get('clashoo', 'config', 'core_type') || 'mihomo', subscribe_url: uci.get('clashoo', 'config', 'subscribe_url') || '', config_name: uci.get('clashoo', 'config', 'config_name') || '', sub_ua: uci.get('clashoo', 'config', 'sub_ua') || '', auto_subscription_update: uci.get('clashoo', 'config', 'auto_subscription_update') || '0', subscription_update_interval: uci.get('clashoo', 'config', 'subscription_update_interval') || '72' }; }); } function buildSubscriptionSchedule(uiData, initialStatus) { var enabled = E('input', { type: 'checkbox', checked: uiData.auto_subscription_update === '1' ? '' : null }); var interval = E('input', { type: 'number', min: '1', max: '8760', value: uiData.subscription_update_interval || '72' }); var statusEl = E('div', { 'class': 'cl-sub-schedule-status' }); var saveBtn, runBtn, timer; function syncScheduleState() { interval.disabled = !enabled.checked; } enabled.addEventListener('change', syncScheduleState); function renderStatus(st) { st = st || {}; if (st.running) { statusEl.textContent = _("Updating all subscriptions…"); return; } if (!st.last_run) { statusEl.textContent = _("Only subscriptions with recorded source URLs are overwritten; no duplicate files are created."); return; } var when = new Date(st.last_run * 1000).toLocaleString(); statusEl.textContent = _("Last run: ") + when + _("; updated ") + (st.updated || 0) + _(", unchanged ") + (st.unchanged || 0) + _(", failed ") + (st.failed || 0) + _(", skipped ") + (st.skipped || 0) + '.'; } function pollStatus() { L.resolveDefault(callSubscriptionUpdateStatus(), {}).then(function (st) { renderStatus(st); if (!st.running) { if (timer) clearInterval(timer); timer = null; runBtn.disabled = false; runBtn.textContent = _("Update All Now"); } }); } saveBtn = E('button', { 'class': 'btn cbi-button cl-btn-sm', click: function () { var hours = parseInt(interval.value, 10); if (!hours || hours < 1 || hours > 8760) { ui.addNotification(null, E('p', _("Update interval must be 1 to 8760 hours"))); return; } saveBtn.disabled = true; L.resolveDefault(callSetSubscriptionUpdateSchedule(enabled.checked ? '1' : '0', String(hours)), {}).then(function (r) { saveBtn.disabled = false; ui.addNotification(null, E('p', r && r.success ? _("Scheduled update settings saved") : (_("Save failed: ") + ((r && r.message) || '')))); }); } }, _("Save")); runBtn = E('button', { 'class': 'btn cbi-button-action cl-btn-sm', click: function () { runBtn.disabled = true; runBtn.textContent = _("Updating…"); L.resolveDefault(callSubscriptionUpdateAll(), {}).then(function (r) { if (!r || (!r.success && !r.running)) { runBtn.disabled = false; runBtn.textContent = _("Update All Now"); ui.addNotification(null, E('p', _("Start failed: ") + ((r && r.message) || ''))); return; } renderStatus({ running: true }); timer = setInterval(pollStatus, 2000); setTimeout(pollStatus, 500); }); } }, _("Update All Now")); syncScheduleState(); renderStatus(initialStatus); return E('div', { 'class': 'cl-sub-schedule' }, [ E('div', { 'class': 'cl-sub-schedule-row' }, [ E('label', { 'class': 'cl-sub-schedule-toggle' }, [enabled, E('span', {}, _("Scheduled Subscription Update"))]), E('div', { 'class': 'cl-sub-schedule-interval' }, [ E('span', {}, _("Every")), interval, E('span', {}, _("hours")) ]), saveBtn, runBtn ]), statusEl ]); } var SUB_UA_PRESETS = ['', 'clash', 'clash.meta', 'mihomo']; // 构造 UA 选择器:select 预设 + custom 切到 input // 返回 { wrap, getValue() } function buildUaPicker(initialValue) { var presets = SUB_UA_PRESETS; var isPreset = presets.indexOf(initialValue) !== -1; var customVal = (!initialValue || isPreset) ? '' : initialValue; var pickerVal = isPreset ? initialValue : (initialValue ? '__custom__' : ''); var customInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("Custom User-Agent"), value: customVal, style: 'margin-top:0;' + (pickerVal === '__custom__' ? '' : 'display:none') }); var selectEl = E('select', { 'class': 'cbi-input-select cl-sub-url', style: 'margin-top:0', change: function (ev) { customInput.style.display = (ev.target.value === '__custom__') ? '' : 'none'; if (ev.target.value === '__custom__') customInput.focus(); } }, [ E('option', { value: '', selected: pickerVal === '' ? '' : null }, _("Default")), E('option', { value: 'clash', selected: pickerVal === 'clash' ? '' : null }, 'clash'), E('option', { value: 'clash.meta', selected: pickerVal === 'clash.meta' ? '' : null }, 'clash.meta'), E('option', { value: 'mihomo', selected: pickerVal === 'mihomo' ? '' : null }, 'mihomo'), E('option', { value: '__custom__', selected: pickerVal === '__custom__' ? '' : null }, _("Customize...")) ]); return { wrap: E('div', { 'class': 'cl-ua-picker', style: 'display:flex;flex-direction:column;gap:6px;margin-top:0' }, [ E('label', { style: 'font-size:12px;color:var(--text-secondary, #888)' }, _("User-Agent (UA)")), selectEl, customInput, E('div', { style: 'font-size:12px;color:var(--text-secondary, #888)' }, _("Keep the default if unsure.")) ]), getValue: function () { return selectEl.value === '__custom__' ? customInput.value.trim() : selectEl.value; } }; } function readSavedTab(key, fallback, allowed) { var raw = ''; if (window.location.hash) raw = window.location.hash.replace(/^#/, ''); if (!raw) { try { raw = window.localStorage.getItem(key) || ''; } catch (e) {} } return allowed.indexOf(raw) >= 0 ? raw : fallback; } function rememberTab(key, id) { try { window.localStorage.setItem(key, id); } catch (e) {} if (window.history && window.history.replaceState) window.history.replaceState(null, '', '#' + id); else window.location.hash = id; } function fmtBytes(b) { b = parseInt(b) || 0; if (b >= 1e12) return (b / 1e12).toFixed(2) + ' TB'; if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB'; if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB'; return Math.round(b / 1e3) + ' KB'; } function fmtExpireDate(ts) { if (!ts || ts === 0) return ''; var d = new Date(ts * 1000); var pad2 = function (n) { return n < 10 ? '0' + n : '' + n; }; return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } /* 渲染流量条;返回 DOM 节点或 null */ function renderTrafficBar(used, total) { total = parseInt(total) || 0; if (total <= 0) return null; used = parseInt(used) || 0; var pct = Math.min(100, Math.round(used / total * 100)); var fillCls = 'cl-sub-traffic-fill' + (pct >= 90 ? ' cl-traffic-danger' : pct >= 75 ? ' cl-traffic-warn' : ''); return E('div', { 'class': 'cl-sub-traffic' }, [ fmtBytes(used) + ' / ' + fmtBytes(total) + ' (' + pct + '%)', E('div', { 'class': 'cl-sub-traffic-bar' }, [ E('div', { 'class': fillCls, 'style': 'width:' + pct + '%' }) ]) ]); } /* 渲染到期文本;返回 DOM 节点或 null */ function renderExpire(ts) { if (!ts) return null; var daysLeft = Math.ceil((ts * 1000 - Date.now()) / 86400000); var cls = 'cl-sub-expire' + (daysLeft <= 7 ? ' cl-expire-soon' : ''); return E('div', { 'class': cls }, _("Expires: ") + fmtExpireDate(ts) + (daysLeft > 0 ? ' (' + daysLeft + _(" days)") : _(" (expired)"))); } function decorateControlWraps(root) { if (!root || !root.querySelectorAll) return; var fields = root.querySelectorAll('.cbi-value-field'); for (var i = 0; i < fields.length; i++) { if (fields[i] && fields[i].classList) fields[i].classList.add('cl-control-wrap'); } } function clearClashooDirty() { var applyPromise; try { applyPromise = (L.uci && typeof L.uci.callApply === 'function') ? Promise.resolve(L.uci.callApply(0, false)).catch(function () {}) : Promise.resolve(); } catch (e) { applyPromise = Promise.resolve(); } return applyPromise.then(function () { try { if (L.ui && L.ui.changes && L.ui.changes.changes) { delete L.ui.changes.changes.clashoo; var n = Object.keys(L.ui.changes.changes).length; if (typeof L.ui.changes.renderChangeIndicator === 'function') L.ui.changes.renderChangeIndicator(n); else if (typeof L.ui.changes.setIndicator === 'function') L.ui.changes.setIndicator(n); } } catch (e) {} }); } function saveCommitApplyMaybeReload(m, runningMsg, stoppedMsg) { return clashoo.status() .then(function (st) { return !!(st && st.running); }) .catch(function () { return false; }) .then(function (running) { /* silent=true so the caller's catch reports validation errors once (no double popup) */ return m.save(null, true) .then(function () { return clashoo.commitConfig(); }) .then(function () { return running ? clashoo.reload() : { success: true, skipped: true }; }) .then(function () { return clearClashooDirty(); }) .then(function () { ui.addNotification(null, E('p', running ? runningMsg : stoppedMsg)); window.setTimeout(function () { location.reload(); }, 300); }); }); } function dnsAutoSummaryNode(res) { if (!res || !res.success) return E('div', { 'class': 'cl-dns-auto-status' }, _("No auto setup result yet")); var elapsed = Math.max(0, parseInt(res.elapsed_ms || 0, 10)); var failed = parseInt(res.failed_count || 0, 10); return E('div', { 'class': 'cl-dns-auto-result' }, [ E('span', [E('b', _("Domestic")), res.nameserver || '-']), E('span', [E('b', _("Proxy")), res.proxy_nameserver || '-']), E('span', [E('b', _("Fallback")), res.fallback || '-']), E('span', [E('b', _("Bootstrap")), res.bootstrap || res.direct_nameserver || '-']), E('span', [E('b', _("Time")), elapsed ? (elapsed / 1000).toFixed(1) + _(" seconds") : '-']), E('span', [E('b', _("Failed")), failed + _(" candidates")]) ]); } function dnsAutoResultMessage(res) { if (!res || !res.success) return (res && res.message) || _("DNS auto setup failed"); return res.restarted ? _("DNS auto setup applied, service is restarting") : _("DNS auto setup saved, service is not running"); } function storeDnsAutoResult(res) { try { res._saved_at = Date.now(); window.localStorage.setItem('clashoo.dns_auto.last', JSON.stringify(res)); } catch (e) {} } function readDnsAutoResult() { try { var raw = window.localStorage.getItem('clashoo.dns_auto.last') || ''; return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function clearDnsAutoResult() { try { window.localStorage.removeItem('clashoo.dns_auto.last'); } catch (e) {} } function setDnsAutoStatus(statusEl, nodeOrText) { statusEl.textContent = ''; if (nodeOrText && nodeOrText.nodeType) statusEl.appendChild(nodeOrText); else statusEl.textContent = nodeOrText || ''; } function findCbiSection(root, title) { var heads = root.querySelectorAll('.cbi-section > h3'); for (var i = 0; i < heads.length; i++) { if ((heads[i].textContent || '').trim() === title) return heads[i].parentNode; } return null; } function makeSectionCollapsible(root, title, open) { var section = findCbiSection(root, title); if (!section || section.classList.contains('cl-collapsible')) return; section.classList.add('cl-collapsible'); if (!open) section.classList.add('cl-closed'); var btn = E('button', { 'class': 'btn cbi-button cl-section-toggle', click: function (ev) { ev.preventDefault(); section.classList.toggle('cl-closed'); btn.textContent = section.classList.contains('cl-closed') ? _("Expand") : _("Collapse"); } }, open ? _("Collapse") : _("Expand")); var h3 = section.querySelector('h3'); if (h3) { h3.classList.add('clashoo-section-header'); h3.appendChild(btn); } } /* ── CodeMirror 懒加载(资源打包在 luci-static/resources/cm/)── */ var _cmLoad = null; function loadCodeMirror() { if (_cmLoad) return _cmLoad; _cmLoad = new Promise(function (resolve, reject) { var base = L.resource('cm'), ver = '?v=20260520'; var link = document.createElement('link'); link.rel = 'stylesheet'; link.href = base + '/codemirror.css' + ver; document.head.appendChild(link); var inject = function (src) { return new Promise(function (res, rej) { var s = document.createElement('script'); s.src = src; s.async = false; s.onload = res; s.onerror = function () { rej(new Error('load failed: ' + src)); }; document.head.appendChild(s); }); }; /* 核心先就位,mode/addon 注册在全局 CodeMirror 上 */ inject(base + '/codemirror.js' + ver) .then(function () { return Promise.all([ inject(base + '/mode/yaml.js' + ver), inject(base + '/mode/javascript.js' + ver), inject(base + '/addon/matchbrackets.js' + ver) ]); }) .then(function () { if (window.CodeMirror) resolve(); else reject(new Error('CodeMirror not available')); }) .catch(reject); }); return _cmLoad; } /* CodeMirror for files ≤512KB; fallback to textarea for oversized or failed load */ function createConfigEditor(mode) { var LARGE = 512 * 1024; var ta = E('textarea', { 'class': 'cl-json-editor', placeholder: _("Configuration content appears here after selecting a file…") }); var host = E('div', { 'class': 'cl-cm-host', style: 'display:none' }); var wrap = E('div', { 'class': 'cl-cm-wrap' }, [host, ta]); var cm = null, usingCM = false; function showTextarea(content) { usingCM = false; ta.value = content; ta.style.display = ''; host.style.display = 'none'; } function showCM(content) { usingCM = true; cm.setValue(content); ta.style.display = 'none'; host.style.display = ''; setTimeout(function () { cm.refresh(); }, 0); } return { el: wrap, textarea: ta, setValue: function (content) { content = (content == null) ? '' : String(content); if (content.length > LARGE) { showTextarea(content); return; } if (cm) { showCM(content); return; } loadCodeMirror().then(function () { if (!cm && window.CodeMirror) { cm = window.CodeMirror(host, { value: '', mode: mode, lineNumbers: true, matchBrackets: true, lineWrapping: true, tabSize: 2, indentUnit: 2, viewportMargin: 60 }); if (getThemeClass().indexOf('dark') >= 0) host.classList.add('cl-cm-dark'); } if (cm) showCM(content); else showTextarea(content); }).catch(function () { showTextarea(content); }); }, getValue: function () { return (usingCM && cm) ? cm.getValue() : ta.value; } }; } return view.extend({ _tab: null, _sbTab: null, load: function () { return Promise.all([ fastResolve(callListSubs(), 1200, { subs: [], url: '' }), fastResolve(callListDir('1'), 1200, { files: [] }), fastResolve(callListDir('2'), 1200, { files: [] }), fastResolve(callListDir('3'), 1200, { files: [] }), fastResolve(callListTemplates(), 1200, { files: [] }), fastResolve(loadUiState(), 1200, { core_type: 'mihomo', subscribe_url: '', config_name: '', sub_ua: '' }), fastResolve(clashoo.listSingboxProfiles(), 1200, { profiles: [], active: '' }), fastResolve(callSubscriptionUpdateStatus(), 1200, {}) ]); }, render: function (data) { var self = this; var subsData = data[0] || {}; var subFiles = (data[1] && data[1].files) || []; var upFiles = (data[2] && data[2].files) || []; var customFiles= (data[3] && data[3].files) || []; var tplFiles = (data[4] && data[4].files) || []; var uiData = data[5] || { core_type: 'mihomo', subscribe_url: '', config_name: '', sub_ua: '' }; var sbData = data[6] || { profiles: [], active: '' }; var subscriptionUpdateStatus = data[7] || {}; var coreType = uiData.core_type || 'mihomo'; if (!document.getElementById('cl-css')) { var s = document.createElement('style'); s.id = 'cl-css'; s.textContent = CSS; document.head.appendChild(s); } if (!document.getElementById('cl-css-ext')) { var link = document.createElement('link'); link.id = 'cl-css-ext'; link.rel = 'stylesheet'; link.href = L.resource('view/clashoo/clashoo.css') + '?v=20260806a1'; document.head.appendChild(link); } else { document.getElementById('cl-css-ext').href = L.resource('view/clashoo/clashoo.css') + '?v=20260806a1'; } if (coreType === 'singbox') return this._renderSingbox(sbData, uiData, subscriptionUpdateStatus); var tabs = [ { id: 'subs', label: _("Subscription") }, { id: 'proxy', label: _("Proxy") }, { id: 'dns', label: 'DNS' } ]; var allowedTabs = tabs.map(function (t) { return t.id; }); this._tab = readSavedTab('clashoo.config.tab', this._tab || 'subs', allowedTabs); rememberTab('clashoo.config.tab', this._tab); var tabEls = {}; var panelEls = {}; var subPanel = E('div', { 'class': 'cl-panel' + (this._tab === 'subs' ? ' active' : ''), id: 'cl-panel-subs' }, this._buildSubsPanel(subsData, subFiles, upFiles, customFiles, tplFiles, uiData, subscriptionUpdateStatus) ); panelEls['subs'] = subPanel; var proxyPanel = E('div', { 'class': 'cl-panel' + (this._tab === 'proxy' ? ' active' : ''), id: 'cl-panel-proxy' }); panelEls['proxy'] = proxyPanel; var dnsPanel = E('div', { 'class': 'cl-panel' + (this._tab === 'dns' ? ' active' : ''), id: 'cl-panel-dns' }); panelEls['dns'] = dnsPanel; var built = { subs: true, proxy: false, dns: false }; var ensureBuilt = function (id) { if (built[id]) return; if (id === 'proxy') self._buildProxyForm(proxyPanel); else if (id === 'dns') self._buildDnsForm(dnsPanel); built[id] = true; }; if (this._tab !== 'subs') ensureBuilt(this._tab); var tabBar = E('div', { 'class': 'cl-tabs' }, tabs.map(function (t) { var el = E('div', { 'class': 'cl-tab' + (self._tab === t.id ? ' active' : ''), click: function () { ensureBuilt(t.id); Object.keys(tabEls).forEach(function (k) { tabEls[k].className = 'cl-tab' + (k === t.id ? ' active' : ''); panelEls[k].className = 'cl-panel' + (k === t.id ? ' active' : ''); }); self._tab = t.id; rememberTab('clashoo.config.tab', t.id); } }, t.label); tabEls[t.id] = el; return el; }) ); return E('div', { 'class': 'cl-wrap clashoo-container cl-config-page cl-form-page ' + getThemeClass() }, [tabBar, subPanel, proxyPanel, dnsPanel]); }, _buildSubsPanel: function (subsData, subFiles, upFiles, customFiles, tplFiles, uiData, subscriptionUpdateStatus) { var self = this; var sanitizeText = function (v) { return (v == null || v === 'null') ? '' : String(v); }; var subUrl = sanitizeText(uiData && uiData.subscribe_url); var savedName = sanitizeText(uiData && uiData.config_name); var savedUa = sanitizeText(uiData && uiData.sub_ua); var subs = subsData.subs || []; var safeText = function (v) { return (v == null || v === 'null') ? '' : String(v); }; var urlInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("Subscription URLs (one per line)"), value: subUrl }); var nameInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("File name (optional, blank to auto-generate)"), value: savedName, style: 'margin-top:0' }); var uaPicker = buildUaPicker(savedUa); var dlStatusEl = E('div', { 'class': 'cl-update-status', style: 'margin-top:6px;font-size:12px;min-height:18px;line-height:1.4' }); var setDlStatus = function (text, tone) { dlStatusEl.textContent = text || ''; dlStatusEl.style.color = tone === 'success' ? 'var(--success-color, #2e7d32)' : tone === 'error' ? 'var(--error-color, #d32f2f)' : tone === 'progress'? 'var(--tip-color, #1976d2)' : ''; }; var stopDlPoll = function () { if (self._subDlTimer) { clearInterval(self._subDlTimer); self._subDlTimer = null; } }; var dlBtn = E('button', { 'class': 'btn cbi-button-action cl-btn-sm', click: function () { if (!urlInput.value.trim()) { setDlStatus(_("✗ Please enter a subscription URL first"), 'error'); setTimeout(function () { setDlStatus(''); }, 4000); return; } stopDlPoll(); dlBtn.disabled = true; dlBtn.textContent = _("Downloading…"); setDlStatus(_("⏳ Submitting download task..."), 'progress'); var pollCount = 0; var pollDl = function () { pollCount++; callDownloadSubsStatus().then(function (st) { st = st || {}; var raw = st.last_line || ''; var line = clashoo.localizeLogLine(raw); if (st.running) { setDlStatus('⏳ ' + (line || _("Downloading subscription...")), 'progress'); return; } /* sh has not flushed yet, wait a few polls */ if (!raw && pollCount < 4) return; stopDlPoll(); /* 收尾行只有三种:「订阅下载完成:成功 N 个,失败 M 个」=成功(含"失败 0 个" 字样,不能按"失败"判负);「订阅下载失败:全部链接失败」「未找到订阅链接」=失败 */ var ok = /下载完成|completed/i.test(raw); /* fail_detail carries the real cause(rc=/HTTP/校验失败): all fail: show real cause; partial: summary + cause */ var detail = st.fail_detail ? clashoo.localizeLogLine(st.fail_detail) : ''; var shown = ok ? (line + (detail ? ' · ' + detail : '')) : (detail || line || _("Subscription download failed")); dlBtn.disabled = false; dlBtn.textContent = _("Download Subscription"); setDlStatus((ok ? '✓ ' : '✗ ') + shown, ok ? 'success' : 'error'); setTimeout(function () { location.reload(); }, 1200); }).catch(function () {}); }; L.resolveDefault(uci.load('clashoo'), null) .then(function () { uci.set('clashoo', 'config', 'subscribe_url', urlInput.value); uci.set('clashoo', 'config', 'config_name', nameInput.value.trim()); uci.set('clashoo', 'config', 'sub_ua', uaPicker.getValue()); return uci.save(); }) .then(function () { return clashoo.commitConfig(); }) .then(function () { return clearClashooDirty(); }) .then(function () { return L.resolveDefault(callDownloadSubs(), {}); }) .then(function (r) { if (r && r.running) setDlStatus(_("⏳ A download task is already running..."), 'progress'); self._subDlTimer = setInterval(pollDl, 2000); setTimeout(pollDl, 800); }) .catch(function (e) { stopDlPoll(); dlBtn.disabled = false; dlBtn.textContent = _("Download Subscription"); setDlStatus(_("✗ Start failed: ") + (e && e.message || e), 'error'); }); } }, _("Download Subscription")); var subCards = subs.map(function (sub) { var nameNodes = []; if (sub.active) nameNodes.push(E('span', { 'class': 'cl-active-badge' }, _("Active"))); nameNodes.push(E('span', { 'class': 'cl-file-name-text' }, safeText(sub.name))); var used = (parseInt(sub.sub_upload) || 0) + (parseInt(sub.sub_download) || 0); var trafficEl = renderTrafficBar(used, sub.sub_total); var expireEl = renderExpire(sub.sub_expire); return E('div', { 'class': 'cl-file-item' + (sub.active ? ' is-active' : '') }, [ E('div', { 'class': 'cl-file-meta' }, [ E('div', { 'class': 'cl-file-name' }, nameNodes), E('div', { 'class': 'cl-file-size' }, safeText(sub.size)), trafficEl, expireEl ]), E('div', { 'class': 'cl-file-actions' }, [ E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-update-sub', click: function () { L.resolveDefault(callUpdateSub(sub.name), {}).then(function (r) { ui.addNotification(null, E('p', r.success ? sub.name + _(" update succeeded") : _("Update failed"))); location.reload(); }); } }, _("Update")), E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-switch', click: function () { L.resolveDefault(callSetConfig(sub.name), {}).then(function () { location.reload(); }); } }, _("Switch Profile")), E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-delete', click: function () { if (!confirm(_("Delete ") + sub.name + '?')) return; L.resolveDefault(callDeleteCfg(sub.name, '1'), {}).then(function () { location.reload(); }); } }, _("Delete")) ]) ]); }); var uploadInput = E('input', { type: 'file', accept: '.yaml,.yml', style: 'display:none', id: 'cl-upload-input' }); uploadInput.addEventListener('change', function (ev) { var file = ev.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function (e) { uploadConfigContent(file.name, e.target.result, '2').then(function (r) { ui.addNotification(null, E('p', _("Upload succeeded: ") + r.name)); location.reload(); }).catch(function (err) { ui.addNotification(null, E('p', _("Upload failed: ") + (err && err.message ? err.message : err))); }); }; reader.readAsText(file); }); var mkSel = function (files, placeholder) { return E('select', { 'class': 'cbi-input-select' }, [E('option', { value: '' }, placeholder)].concat( files.map(function (f) { return E('option', { value: f.name }, f.name); }) ) ); }; /* ── 模板复写(注入订阅 URL 模式)── */ var tplSel = mkSel(tplFiles, _("Select local template file")); tplSel.classList.add('cl-template-select'); tplSel.setAttribute('title', tplSel.value || ''); tplSel.addEventListener('change', function () { tplSel.setAttribute('title', tplSel.value || ''); }); var tplUploadInput = E('input', { type: 'file', accept: '.yaml,.yml', style: 'display:none', id: 'cl-template-upload-input' }); tplUploadInput.addEventListener('change', function (ev) { var file = ev.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function (e) { L.resolveDefault(callUploadTemplate(file.name, e.target.result), {}).then(function (r) { if (r && r.success) { ui.addNotification(null, E('p', _("Template uploaded: ") + (r.name || file.name))); location.reload(); return; } ui.addNotification(null, E('p', _("Template upload failed: ") + ((r && (r.message || r.error)) || _("Unknown error")))); }); }; reader.readAsText(file); }); var tplUrlIn = E('input', { type: 'text', 'class': 'cl-sub-url', placeholder: _("Enter remote template URL, e.g. https://raw.githubusercontent.com/…/Clash.yaml") }); var subUrlIn = E('input', { type: 'text', 'class': 'cl-sub-url', placeholder: _("Enter subscription URL (injected into template proxy-providers)") }); var outNameIn = E('input', { type: 'text', 'class': 'cl-sub-url', placeholder: _("Output file name (without extension, blank to auto-fill)") }); var rwMode = 'local'; function rwAutoFill() { if (outNameIn.value) return; var tpl = rwMode === 'local' ? tplSel.value.replace(/\.(yaml|yml)$/, '') : 'remote-tpl'; if (tpl) outNameIn.value = tpl + '-rewrite'; } tplSel.addEventListener('change', rwAutoFill); var localPanel = E('div', { 'class': 'cl-rw-pane cl-rw-pane-local' }, [ tplUploadInput, E('div', { 'class': 'cl-template-row' }, [ E('div', { 'class': 'cl-template-select-wrap' }, [tplSel]), E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-template-upload', click: function () { document.getElementById('cl-template-upload-input').click(); } }, _("Upload YAML Template")) ]) ]); var remotePanel = E('div', { 'class': 'cl-rw-pane cl-rw-pane-remote', style: 'display:none' }, [tplUrlIn]); var tabLocal = E('button', { 'class': 'btn cbi-button cl-btn-sm cl-mode-tab-active', click: function () { rwMode = 'local'; localPanel.style.display = ''; remotePanel.style.display = 'none'; tabLocal.classList.add('cl-mode-tab-active'); tabRemote.classList.remove('cl-mode-tab-active'); outNameIn.value = ''; rwAutoFill(); } }, _("Local Template")); var tabRemote = E('button', { 'class': 'btn cbi-button cl-btn-sm', click: function () { rwMode = 'remote'; localPanel.style.display = 'none'; remotePanel.style.display = ''; tabRemote.classList.add('cl-mode-tab-active'); tabLocal.classList.remove('cl-mode-tab-active'); outNameIn.value = ''; } }, _("Remote Template")); var rwApply = function (setActive) { var tplSrc = rwMode === 'local' ? tplSel.value : tplUrlIn.value.trim(); var subUrl = subUrlIn.value.trim(); var out = outNameIn.value.trim(); if (!tplSrc) { ui.addNotification(null, E('p', rwMode === 'local' ? _("Please select a local template file") : _("Please enter a remote template URL"))); return; } if (!subUrl) { ui.addNotification(null, E('p', _("Please enter a subscription URL"))); return; } if (!out) { ui.addNotification(null, E('p', _("Please enter an output file name"))); return; } L.resolveDefault(callApplyTplUrl(tplSrc, subUrl, out, setActive ? '1' : '0'), {}).then(function (r) { ui.addNotification(null, E('p', r && r.success ? (r.message || _("Generated: ") + r.output_name) : (_("Generate failed: ") + (r && r.message || _("Unknown error"))))); if (r && r.success) location.reload(); }); }; /* ── 其他配置文件(上传 + 自定义/复写输出)── */ var otherEditorTitle = E('span', { 'class': 'cl-editor-hdr' }, _("Select a configuration above to edit it here")); var otherEd = createConfigEditor('yaml'); var otherSaveBtn = E('button', { 'class': 'btn cbi-button-action cl-btn-sm', disabled: '', click: function () { var meta = otherEd.textarea.dataset; if (!meta.name) return; uploadConfigContent(meta.name, otherEd.getValue(), meta.type).then(function (r) { if (r && r.success) ui.addNotification(null, E('p', meta.name + _(" saved"))); else ui.addNotification(null, E('p', _("Save failed: ") + ((r && (r.message || r.error)) || ''))); }).catch(function (err) { ui.addNotification(null, E('p', _("Save failed: ") + (err && err.message ? err.message : err))); }); } }, _("Save")); var otherEditorBox = E('div', { 'class': 'cl-section cl-card cl-sb-editor' }, [ otherEditorTitle, otherEd.el, E('div', { 'class': 'cl-actions cl-sb-row-actions cl-sb-editor-actions' }, [ otherSaveBtn, E('span', { 'class': 'cl-hint' }, _("Click Save after editing; switching configuration automatically restarts the service")) ]) ]); function loadOtherEditor(name, type) { otherEditorTitle.textContent = _("Editing: ") + name; otherSaveBtn.removeAttribute('disabled'); otherEd.textarea.dataset.name = name; otherEd.textarea.dataset.type = type; otherEd.setValue(_("Loading…")); L.resolveDefault(callReadOtherConfig(name, type), {}).then(function (r) { otherEd.setValue(r.content || ''); }); } var makeOtherCards = function (files, type) { return files.map(function (f) { var nameNodes = []; if (f.active) nameNodes.push(E('span', { 'class': 'cl-active-badge' }, _("Active"))); nameNodes.push(E('span', { 'class': 'cl-file-name-text' }, safeText(f.name))); return E('div', { 'class': 'cl-file-item' + (f.active ? ' is-active' : '') }, [ E('div', { 'class': 'cl-file-meta' }, [ E('div', { 'class': 'cl-file-name' }, nameNodes), E('div', { 'class': 'cl-file-size' }, safeText(f.size)) ]), E('div', { 'class': 'cl-file-actions' }, [ E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-edit', click: function () { loadOtherEditor(f.name, type); } }, _("Edit")), E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-switch', click: function () { L.resolveDefault(callSetConfig(f.name), {}).then(function () { location.reload(); }); } }, _("Switch Profile")), E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-delete', click: function () { if (!confirm(_("Delete ") + f.name + '?')) return; L.resolveDefault(callDeleteCfg(f.name, type), {}).then(function () { location.reload(); }); } }, _("Delete")) ]) ]); }); }; var otherFiles = (upFiles || []).map(function(f){ return {f:f, t:'2'}; }) .concat((customFiles || []).map(function(f){ return {f:f, t:'3'}; })); var otherCards = otherFiles.map(function(o) { return makeOtherCards([o.f], o.t)[0]; }); var sections = [ E('div', { 'class': 'cl-section cl-card' }, [ E('h4', {}, _("Subscription URL")), E('div', { 'class': 'cl-form-wrap cl-fixed-600' }, [urlInput, nameInput, uaPicker.wrap, dlBtn, dlStatusEl]) ]), E('div', { 'class': 'cl-section cl-card' }, [ E('h4', {}, _("Downloaded Subscriptions")), buildSubscriptionSchedule(uiData, subscriptionUpdateStatus), subs.length ? E('div', { 'class': 'cl-fixed-600' }, [ E('div', { 'class': 'cl-file-list' }, subCards) ]) : E('p', { style: 'opacity:.5;font-size:13px' }, _("No subscriptions")) ]), E('div', { 'class': 'cl-section cl-card' }, [ E('h4', {}, _("Upload Configuration File")), uploadInput, E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-upload-config', click: function () { document.getElementById('cl-upload-input').click(); } }, _("Select YAML file to upload")) ]) ]; if (otherFiles.length) { sections.push(E('div', { 'class': 'cl-section cl-card' }, [ E('h4', {}, _("Other Configuration Files (upload / overwrite output)")), E('div', { 'class': 'cl-fixed-600' }, [ E('div', { 'class': 'cl-file-list' }, otherCards) ]) ])); sections.push(otherEditorBox); } sections.push( E('div', { 'class': 'cl-section cl-card' }, [ E('h4', {}, _("Overwrite Settings")), E('div', { 'class': 'cl-form-wrap cl-rewrite-wrap cl-fixed-600' }, [ E('div', { 'class': 'cl-rewrite-group cl-rewrite-group-template' }, [ E('div', { 'class': 'cl-rewrite-group-title' }, _("Template Selection")), E('div', { 'class': 'cl-mode-tabs' }, [tabLocal, tabRemote]), localPanel, remotePanel ]), E('div', { 'class': 'cl-rw-divider' }), E('div', { 'class': 'cl-rewrite-group cl-rewrite-group-input' }, [ E('div', { 'class': 'cl-rewrite-group-title' }, _("Information Input")), subUrlIn, outNameIn, E('div', { 'class': 'cl-actions cl-rewrite-actions' }, [ E('button', { 'class': 'btn cbi-button cl-btn-sm', click: function(){ rwApply(false); } }, _("Generate Configuration")), E('button', { 'class': 'btn cbi-button-action cl-btn-sm cl-btn-generate-switch', click: function(){ rwApply(true); } }, _("Apply Configuration")) ]) ]) ]) ]) ); return sections.filter(function (n) { return n !== null && n !== undefined; }); }, _buildProxyForm: function (container) { var m = new form.Map('clashoo', '', ''); var s, o; s = m.section(form.NamedSection, 'config', 'clashoo', _("Transparent Proxy")); s.addremove = false; o = s.option(form.ListValue, 'tcp_mode', _("TCP Mode")); o.value('redirect', 'Redirect'); o.value('tproxy', 'TPROXY'); o.value('tun', 'TUN'); o.value('off', _("Off")); o = s.option(form.ListValue, 'udp_mode', _("UDP Mode")); o.value('tun', 'TUN'); o.value('tproxy', 'TPROXY'); o.value('off', _("Off")); o = s.option(form.ListValue, 'stack', _("TUN Network Stack")); o.value('system', 'System'); o.value('gvisor', 'gVisor'); o.value('mixed', 'Mixed'); o.description = _("TUN stack is used when either TCP or UDP mode is TUN."); o = s.option(form.Flag, 'disable_quic_gso', _("Disable QUIC GSO")); o = s.option(form.Flag, 'block_quic', _("Block QUIC")); o.description = _("Reject proxied UDP 443 so some apps fall back to TCP. Try only when downloads or video stall."); o = s.option(form.Flag, 'ipv4_dns_hijack', _("IPv4 DNS Hijack")); o = s.option(form.Flag, 'ipv6_dns_hijack', _("IPv6 DNS Hijack")); o.description = _("Intercept IPv6 DNS traffic to prevent devices with hard-coded DNS from bypassing the traffic."); o = s.option(form.Flag, 'ipv4_proxy', _("IPv4 Proxy")); o = s.option(form.Flag, 'ipv6_proxy', _("IPv6 Proxy")); o = s.option(form.Flag, 'fake_ip_ping_hijack', _("Virtual IP Ping Hijacking")); o = s.option(form.Flag, 'dns_leak_protect', _("Prevent DNS Leaks")); o.description = _("Regular domains use overseas DNS only and follow proxy rules; DoT/DoQ is blocked. Takes effect after restart.
") + _("Warning: IPv6 resolution is also disabled when IPv6 Proxy is off, to keep the real address from leaking."); o = s.option(form.Flag, 'core_only', _("Core Only (advanced)")); o.description = _("Run only the core with your imported configuration, without taking over firewall / DNS / routing. Compatible with nikki, OpenClash, momo and homeproxy configurations.
") + _("Prerequisite: The configuration must include transparent proxying (TUN auto-route or TProxy inbound). Takes effect after restart."); s = m.section(form.NamedSection, 'config', 'clashoo', _("Port Settings")); s.addremove = false; o = s.option(form.Flag, 'allow_lan', _("Allow LAN Access")); o = s.option(form.Value, 'http_port', _("HTTP Port")); o = s.option(form.Value, 'socks_port', _("SOCKS5 Port")); o = s.option(form.Value, 'mixed_port', _("Mixed Port")); o = s.option(form.Value, 'redir_port', _("Redirect Port")); o = s.option(form.Value, 'tproxy_port', _("TPROXY Port")); s = m.section(form.NamedSection, 'config', 'clashoo', _("Smart Policy Settings")); s.addremove = false; o = s.option(form.Flag, 'smart_auto_switch', _("Smart Policy Auto Switch")); o.description = _("Automatically switch Url-test and Load-balance policy groups to Smart policy groups"); o = s.option(form.Value, 'smart_policy_priority', _("Policy Priority (weight bonus)")); o.default = 'Premium:0.9;SG:1.3'; o.placeholder = 'Premium:0.9;SG:1.3'; o.rmempty = true; o.description = _("Node weight bonus. <1 means lower priority, >1 means higher priority. Modify as needed; leave blank to skip policy-priority injection."); o = s.option(form.Flag, 'smart_prefer_asn', _("ASN Priority")); o.description = _("Force ASN lookup and prefer target ASN information when selecting nodes for a more stable experience"); o = s.option(form.Flag, 'smart_uselightgbm', _("Enable LightGBM Model")); o.description = _("Use LightGBM model to predict weights"); o = s.option(form.Flag, 'smart_collectdata', _("Collect Training Data")); o.description = _("Collect node latency data for LightGBM model training"); o = s.option(form.Value, 'smart_collect_size', _("Training Data Size")); o.datatype = 'uinteger'; o.placeholder = '100'; o.description = _("smart-collector-size, maximum retained training samples, default 100"); o = s.option(form.Value, 'smart_collect_rate', _("Sample Rate")); o.datatype = 'uinteger'; o.placeholder = '1'; o.description = _("sample-rate, 1 = sample every latency test; increase to reduce sampling frequency"); o = s.option(form.Flag, 'smart_lgbm_auto_update', _("Auto Update Model")); o = s.option(form.Value, 'smart_lgbm_update_interval', _("Update interval (hours)")); o.datatype = 'uinteger'; o.placeholder = '72'; o.description = _("Period for automatically fetching a new model; also controls the cron trigger frequency, default 72 hours"); o = s.option(form.Value, 'smart_lgbm_url', _("Model Download URL")); o.placeholder = 'https://github.com/vernesong/mihomo/releases/download/LightGBM-Model/Model.bin'; o.rmempty = true; o.description = _("LightGBM model file download URL; leave blank to use the default official URL"); var sa = m.section(form.TypedSection, 'authentication', _("Proxy Authentication")); sa.anonymous = true; sa.addremove = true; o = sa.option(form.Value, 'username', _("Username")); o = sa.option(form.Value, 'password', _("Password")); var sr = m.section(form.TypedSection, 'addtype', _("Custom Routing Rules"), _("Correct routing by forcing a domain or IP to Direct or Proxy. These rules take priority over subscription rules and apply to both mihomo and sing-box.")); sr.anonymous = true; sr.addremove = true; o = sr.option(form.Value, 'ipaaddr', _("Domain / IP")); /* allow empty so the default placeholder row never blocks saving other settings; empty rule rows are pruned on save (pruneEmptyAddtype) instead of erroring */ o.rmempty = true; o.placeholder = _("example.com or 1.2.3.0/24"); o = sr.option(form.ListValue, 'type', _("Type")); o.value('DOMAIN-SUFFIX', _("Domain suffix (e.g. google.com)")); o.value('DOMAIN', _("Exact domain (e.g. www.google.com)")); o.value('DOMAIN-KEYWORD', _("Domain keyword (e.g. youtube)")); o.value('IP-CIDR', _("IP / CIDR (e.g. 8.8.8.8/32)")); o.default = 'DOMAIN-SUFFIX'; o = sr.option(form.ListValue, 'pgroup', _("Target")); o.value('DIRECT', _("Direct")); o.value('__PROXY__', _("Proxy")); o.default = 'DIRECT'; o = sr.option(form.Flag, 'res', 'no-resolve'); o.depends('type', 'IP-CIDR'); o.description = _("IP rules do not trigger DNS resolution"); m.render().then(function (node) { decorateControlWraps(node); container.appendChild(node); // Smart section action buttons var sections = node.querySelectorAll('.cbi-section'); var smartSec; for (var i = 0; i < sections.length; i++) { var h3 = sections[i].querySelector('h3'); if (h3 && h3.textContent.indexOf('Smart') >= 0) { smartSec = sections[i]; break; } } if (smartSec) { var flushBtn = E('button', { 'class': 'btn cbi-button', 'click': function () { flushBtn.disabled = true; callSmartFlushCache().then(function (res) { flushBtn.disabled = false; ui.addNotification(null, E('p', (res && res.success) ? _("Smart cache cleared") : _("Cleanup failed (mihomo may not be running)"))); }).catch(function () { flushBtn.disabled = false; }); }}, _("Clear")); smartSec.appendChild(E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _("Clear Smart Cache")), E('div', { 'class': 'cbi-value-field' }, [flushBtn]) ])); } makeSectionCollapsible(node, _("Transparent Proxy"), true); makeSectionCollapsible(node, _("Port Settings"), true); makeSectionCollapsible(node, _("Smart Policy Settings"), false); makeSectionCollapsible(node, _("Proxy Authentication"), false); makeSectionCollapsible(node, _("Custom Routing Rules"), false); container.appendChild(E('div', { 'class': 'cl-save-bar' }, [ E('button', { 'class': 'btn cbi-button', click: function () { m.save(null, true).then(function () { return clashoo.commitConfig(); }) .then(function () { return clearClashooDirty(); }) .then(function () { location.reload(); }) .catch(function (e) { ui.addNotification(null, E('p', _("Save failed: ") + (e.message || e))); }); }}, _("Save Configuration")), E('button', { 'class': 'btn cbi-button-action', click: function () { // 保存前探测主代理组,供自定义分流规则的「代理」(__PROXY__) 解析(失败忽略,注入器回退 GLOBAL) callDetectPrimaryGroup().catch(function () {}).then(function () { return saveCommitApplyMaybeReload(m, _("Proxy configuration saved and service hot-reloaded"), _("Proxy configuration saved, service is not running")); }).catch(function (e) { ui.addNotification(null, E('p', _("Operation failed: ") + (e.message || e))); }); }}, _("Apply Configuration")) ])); }); }, _buildDnsForm: function (container) { var m = new form.Map('clashoo', '', ''); var s, o; s = m.section(form.NamedSection, 'config', 'clashoo', _("Basic DNS")); s.addremove = false; o = s.option(form.DummyValue, '_dns_auto_setup', _("DNS Auto Setup")); o.cfgvalue = function () { var statusEl = E('div', { 'class': 'cl-dns-auto-status' }, []); var last = readDnsAutoResult(); if (last && last.success) setDnsAutoStatus(statusEl, dnsAutoSummaryNode(last)); else statusEl.textContent = _("Automatically choose available DNS and update only upstream servers; split policies and advanced settings remain unchanged."); if (last && last.success) window.setTimeout(function () { clearDnsAutoResult(); statusEl.textContent = _("Automatically choose available DNS and update only upstream servers; split policies and advanced settings remain unchanged."); }, 6000); var btn = E('button', { 'class': 'btn cbi-button-action', click: function (ev) { ev.preventDefault(); btn.disabled = true; setDnsAutoStatus(statusEl, _("Testing latency and writing upstream DNS…")); clashoo.dnsAutoSetup() .then(function (res) { btn.disabled = false; if (!res || !res.success) { setDnsAutoStatus(statusEl, (res && res.message) || _("DNS auto setup failed")); ui.addNotification(null, E('p', (res && res.message) || _("DNS auto setup failed"))); return; } storeDnsAutoResult(res); setDnsAutoStatus(statusEl, dnsAutoSummaryNode(res)); ui.addNotification(null, E('p', dnsAutoResultMessage(res))); window.setTimeout(function () { clearDnsAutoResult(); statusEl.textContent = _("Auto setup completed."); }, 6000); }) .catch(function (e) { btn.disabled = false; setDnsAutoStatus(statusEl, _("DNS auto setup failed: ") + (e.message || e)); ui.addNotification(null, E('p', _("DNS auto setup failed: ") + (e.message || e))); }); } }, _("Test and Apply")); return E('div', { 'class': 'cl-dns-auto' }, [ E('div', { 'class': 'cl-dns-auto-actions' }, [btn]), statusEl ]); }; o.write = function () {}; o = s.option(form.Flag, 'enable_dns', _("Enable DNS Module")); o = s.option(form.Value, 'listen_port', _("DNS Listen Port")); o.datatype = 'port'; o = s.option(form.ListValue, 'enhanced_mode', _("Enhanced Mode")); o.value('fake-ip', 'Fake-IP'); o.value('redir-host', 'Redir-Host'); o.default = 'fake-ip'; o.description = 'Fake-IP · ' + _("Recommended") + ' ' + _("Fast resolution and accurate routing; China routing is handled by the core.") + '
' + 'Redir-Host ' + _("China traffic bypasses the core at firewall level; DNS behavior is slightly weaker. Choose as needed."); o = s.option(form.Value, 'fake_ip_range', _("Fake-IP Range")); o.default = '198.18.0.1/16'; o.placeholder = '198.18.0.1/16'; o.depends('enhanced_mode', 'fake-ip'); o.remove = function () {}; o = s.option(form.Flag, 'enable_ipv6', 'IPv6 DNS'); s = m.section(form.NamedSection, 'config', 'clashoo', _("Advanced DNS")); s.addremove = false; o = s.option(form.Flag, 'dnsforwader', _("Force DNS Forwarding")); o.depends('enable_dns', '1'); o = s.option(form.ListValue, 'fake_ip_filter_mode', _("Fake-IP Filter Mode")); o.value('blacklist', _("Blocklist (listed items use real IP, default)")); o.value('whitelist', _("Allowlist (only listed items use fake-IP)")); o.value('rule', _("Rule mode (same syntax as rules)")); o.default = 'blacklist'; o.depends('enhanced_mode', 'fake-ip'); o = s.option(form.DynamicList, 'fake_ip_filter', _("Fake-IP Filter Domains")); o.placeholder = '+.lan / geosite:cn / RULE-SET,cn_domain,real-ip'; o.description = _("Blocklist mode: enter domains or shorthand like geosite:cn. Rule mode: entries are converted to rule syntax automatically, such as GEOSITE,cn,real-ip / RULE-SET,xxx,real-ip; usually add MATCH,fake-ip at the end as fallback. geosite:cn automatically uses built-in cn.mrs acceleration and avoids loading the 10MB geosite.dat. Allowlist mode drops the shipped defaults (they mean the opposite there), so enter your own domains."); o.depends('enhanced_mode', 'fake-ip'); o.remove = function () {}; o = s.option(form.DynamicList, 'default_nameserver', 'Bootstrap DNS'); o.placeholder = '223.5.5.5'; o.description = _("Used to resolve DoH/DoT/DoQ server domains; plain IP DNS is recommended."); o = s.option(form.Flag, 'dns_respect_rules', _("DNS Respect Rules")); o.default = '1'; o.description = _("DNS queries follow the routing rules, so overseas domains resolve through the proxy instead of a polluted result. Requires proxy-server-nameserver."); o = s.option(form.Flag, 'dns_loopback_compat', _("OpenClash/Nikki Loopback DNS Compatibility")); o.default = '0'; o.description = _("Optional compatibility for normal managed mode. At runtime, replace only proxy node resolvers that point back to the imported configuration's own DNS listener. Core Only mode stays verbatim; the source configuration and unrelated local DNS services remain unchanged."); o.depends('enable_dns', '1'); o = s.option(form.DynamicList, 'dns_loopback_compat_resolver', _("Loopback DNS Replacement")); o.placeholder = 'udp://223.5.5.5:53'; o.description = _("Used only when the compatibility switch detects a stale self-reference. When empty, udp://223.5.5.5:53 and udp://119.29.29.29:53 are used. Plain IP DNS is recommended to avoid bootstrap loops."); o.depends('dns_loopback_compat', '1'); o = s.option(form.Value, 'dns_ecs', _("ECS Client Subnet")); o.placeholder = _("Recommended blank"); o.description = _("mihomo writes the ecs parameter to DNS URLs; sing-box writes dns.client_subnet. Leave empty to skip."); o.rmempty = true; o = s.option(form.Flag, 'dns_ecs_override', _("Force ECS Override")); o.default = '0'; o = s.option(form.Flag, 'fallback_filter_geoip', _("Fallback GeoIP Filter")); o.default = '0'; o.description = _("Requires MMDB. If enabled while the local GeoIP database is missing, the core may fail to start. Usually not needed."); o = s.option(form.DynamicList, 'fallback_filter_ipcidr', 'Fallback IP CIDR'); o.placeholder = '240.0.0.0/4'; o = s.option(form.Flag, 'singbox_independent_cache', _("sing-box Independent DNS Cache")); o.default = '0'; o.description = _("When enabled, direct / proxy / fallback roles each use an independent DNS cache. Useful when the same domain needs different resolver chains by routing. Usually not needed."); s = m.section(form.TypedSection, 'dnsservers', _("Upstream DNS")); s.addremove = true; s.anonymous = true; o = s.option(form.Flag, 'enabled', _("Enable")); o.default = '1'; o = s.option(form.ListValue, 'ser_type', _("Role")); o.value('nameserver', _("Domestic Upstream DNS")); o.value('direct-nameserver', _("Direct Domain Resolver")); o.value('proxy-server-nameserver', _("Proxy Node Domain Resolver")); o.value('fallback', _("Overseas Encrypted DNS (anti-pollution)")); o.default = 'nameserver'; o = s.option(form.Value, 'ser_address', _("DNS Address")); o.placeholder = 'https://dns.alidns.com/dns-query / https://doh.pub/dns-query'; o = s.option(form.ListValue, 'protocol', _("Protocol")); o.value('none', _("Full URL / no protocol prefix")); o.value('udp://', 'UDP'); o.value('tcp://', 'TCP'); o.value('tls://', 'DoT / TLS'); o.value('https://', 'DoH / HTTPS'); o.value('quic://', 'DoQ / QUIC'); o.default = 'none'; o = s.option(form.Value, 'ser_port', _("Port")); o.placeholder = '853 / 784'; o.rmempty = true; s = m.section(form.TypedSection, 'dns_policy', _("DNS Policy")); s.addremove = true; s.anonymous = true; o = s.option(form.Flag, 'enabled', _("Enable")); o.default = '1'; o = s.option(form.ListValue, 'policy_type', _("Policy Type")); o.value('nameserver-policy', _("Domain Split DNS")); o.value('proxy-server-nameserver-policy', _("Proxy Domain Split DNS")); o.default = 'nameserver-policy'; o = s.option(form.Value, 'matcher', _("Match Rule")); o.placeholder = 'geosite:cn / domain:example.com / domain-suffix:google.com'; o = s.option(form.DynamicList, 'nameserver', _("Use DNS")); o.placeholder = 'udp://223.5.5.5'; o.description = _("Example: geosite:cn uses https://dns.alidns.com/dns-query; geosite:geolocation-!cn uses https://cloudflare-dns.com/dns-query."); m.render().then(function (node) { decorateControlWraps(node); makeSectionCollapsible(node, _("Basic DNS"), true); makeSectionCollapsible(node, _("Advanced DNS"), false); makeSectionCollapsible(node, _("Upstream DNS"), false); makeSectionCollapsible(node, _("DNS Policy"), false); container.appendChild(node); container.appendChild(E('div', { 'class': 'cl-save-bar' }, [ E('button', { 'class': 'btn cbi-button', click: function () { m.save(null, true).then(function () { return clashoo.commitConfig(); }) .then(function () { return clearClashooDirty(); }) .then(function () { location.reload(); }) .catch(function (e) { ui.addNotification(null, E('p', _("Save failed: ") + (e.message || e))); }); }}, _("Save Configuration")), E('button', { 'class': 'btn cbi-button-action', click: function () { saveCommitApplyMaybeReload(m, _("DNS configuration saved and service hot-reloaded"), _("DNS configuration saved, service is not running")) .catch(function (e) { ui.addNotification(null, E('p', _("Operation failed: ") + (e.message || e))); }); }}, _("Apply Configuration")) ])); }); }, /* ── sing-box UI ── */ _renderSingbox: function (sbData, uiData, subscriptionUpdateStatus) { var self = this; var profiles = sbData.profiles || []; var tabEls = {}, panelEls = {}; var tabs = [ { id: 'profiles', label: _("Configuration Files") }, { id: 'wizard', label: _("Quick Wizard") } ]; var allowedTabs = tabs.map(function (t) { return t.id; }); this._sbTab = readSavedTab('clashoo.config.singbox.tab', this._sbTab || 'profiles', allowedTabs); rememberTab('clashoo.config.singbox.tab', this._sbTab); var tabBar = E('div', { 'class': 'cl-tabs' }, tabs.map(function (t) { var el = E('div', { 'class': 'cl-tab' + (self._sbTab === t.id ? ' active' : ''), click: function () { Object.keys(tabEls).forEach(function (k) { tabEls[k].className = 'cl-tab' + (k === t.id ? ' active' : ''); panelEls[k].className = 'cl-panel' + (k === t.id ? ' active' : ''); }); self._sbTab = t.id; rememberTab('clashoo.config.singbox.tab', t.id); } }, t.label); tabEls[t.id] = el; return el; }) ); var profilesPanel = E('div', { 'class': 'cl-panel' + (this._sbTab === 'profiles' ? ' active' : ''), id: 'cl-panel-profiles' }, self._buildSbProfilesPanel(profiles, sbData.active || '', uiData, subscriptionUpdateStatus)); panelEls['profiles'] = profilesPanel; var wizardPanel = E('div', { 'class': 'cl-panel' + (this._sbTab === 'wizard' ? ' active' : ''), id: 'cl-panel-wizard' }, self._buildSbWizardPanel()); panelEls['wizard'] = wizardPanel; return E('div', { 'class': 'cl-wrap clashoo-container cl-config-page cl-form-page ' + getThemeClass() }, [tabBar, profilesPanel, wizardPanel]); }, _buildSbProfilesPanel: function (profiles, activeProfile, uiData, subscriptionUpdateStatus) { var self = this; var safeText = function (v) { return (v == null || v === 'null') ? '' : String(v); }; var formatJsonForEditor = function (content) { try { return JSON.stringify(JSON.parse(content || '{}'), null, 2) + '\n'; } catch (e) { return content || ''; } }; /* ── JSON editor (initially hidden) ── */ var editorTitle = E('span', { 'class': 'cl-editor-hdr' }, _("Select a configuration above to edit it here")); var ed = createConfigEditor({ name: 'javascript', json: true }); var saveBtn = E('button', { 'class': 'btn cbi-button-action cl-btn-sm', disabled: '', click: function () { var name = ed.textarea.dataset.name; if (!name) return; clashoo.saveSingboxProfile(name, formatJsonForEditor(ed.getValue())).then(function (r) { if (r.success) ui.addNotification(null, E('p', name + _(" saved"))); else ui.addNotification(null, E('p', _("Save failed: ") + (r.message || r.error || ''))); }); } }, _("Save")); var migrateBtn = E('button', { 'class': 'btn cbi-button cl-btn-sm', disabled: '', click: function () { var name = ed.textarea.dataset.name; if (!name) return; L.resolveDefault(callMigrateSbProfile(name), {}).then(function (r) { if (r && r.success) { var msg = r.changes && r.changes.length ? _("Fixed deprecated fields: ") + r.changes.join(', ') : _("Configuration is up to date; no fixes needed"); ui.addNotification(null, E('p', msg)); /* 重新加载编辑器内容 */ clashoo.getSingboxProfile(name).then(function (gr) { ed.setValue(formatJsonForEditor(gr.content || '')); }); } else { ui.addNotification(null, E('p', _("Fix failed: ") + ((r && r.message) || ''))); } }); } }, _("Fix Deprecated Fields")); var editorBox = E('div', { 'class': 'cl-section cl-card cl-sb-card cl-sb-editor' }, [ editorTitle, ed.el, E('div', { 'class': 'cl-actions cl-sb-row-actions cl-sb-editor-actions' }, [ saveBtn, migrateBtn, E('span', { 'class': 'cl-hint' }, _("Click Save after editing; switching configuration automatically restarts the service")) ]) ]); function loadEditor(name) { editorTitle.textContent = _("Editing: ") + name; saveBtn.removeAttribute('disabled'); migrateBtn.removeAttribute('disabled'); ed.textarea.dataset.name = name; ed.setValue(_("Loading…")); clashoo.getSingboxProfile(name).then(function (r) { ed.setValue(formatJsonForEditor(r.content || '')); }); } /* ── Profile table ── */ var rows = profiles.length ? profiles.map(function (p) { var nameCell = [ E('div', { 'class': 'cl-sb-file-name' }, [ p.active ? E('span', { 'class': 'cl-active-badge' }, _("Active")) : '', E('span', { 'class': 'cl-file-name-text' }, safeText(p.name)) ]) ]; var trafficEl = renderTrafficBar(p.sub_used, p.sub_total); var expireEl = renderExpire(p.sub_expire); if (trafficEl) nameCell.push(trafficEl); if (expireEl) nameCell.push(expireEl); return E('tr', {}, [ E('td', {}, nameCell), E('td', { 'class': 'cl-sb-size' }, safeText(p.size) || '—'), E('td', {}, [ E('div', { 'class': 'cl-sb-row-actions' }, [ p.source !== 'native' ? E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-sb-action cl-btn-sb-edit', click: function () { loadEditor(p.name); } }, _("Edit")) : '', E('button', { 'class': 'btn cbi-button-action cl-btn-sm cl-btn-sb-action cl-btn-sb-switch', click: function () { clashoo.setSingboxProfile(p.name).then(function (r) { ui.addNotification(null, E('p', r.success ? _("Switched to ") + p.name : (_("Switch failed: ") + (r.message || '')))); if (r.success) location.reload(); }); } }, _("Switch Profile")), p.source === 'native' && p.sub_url ? E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-sb-action', click: function (ev) { var btn = ev.currentTarget; btn.disabled = true; btn.textContent = _("Updating…"); clashoo.updateSingboxNative(p.name).then(function (r) { btn.disabled = false; btn.textContent = _("Update"); ui.addNotification(null, E('p', r.success ? (r.message || p.name + _(" updated")) : (_("Update failed: ") + (r.message || '')))); if (r.success) location.reload(); }); } }, _("Update")) : '', E('button', { 'class': 'btn cbi-button-negative cl-btn-sm cl-btn-sb-action cl-btn-sb-delete', click: function () { if (!confirm(_("Delete ") + p.name + '?')) return; clashoo.deleteSingboxProfile(p.name).then(function () { location.reload(); }); } }, _("Delete")) ]) ]) ]); }) : [E('tr', {}, [E('td', { 'class': 'cl-sb-empty', colspan: '3' }, _("No configuration files yet. Use Quick Wizard to generate one or upload a JSON file."))])]; /* ── Upload ── */ var uploadInput = E('input', { type: 'file', accept: '.json', style: 'display:none', id: 'sb-upload' }); uploadInput.addEventListener('change', function (ev) { var file = ev.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function (e) { clashoo.saveSingboxProfile(file.name, e.target.result).then(function (r) { if (r.success) { ui.addNotification(null, E('p', _("Upload succeeded: ") + r.name)); location.reload(); } else ui.addNotification(null, E('p', _("Upload failed: ") + (r.message || r.error || ''))); }); }; reader.readAsText(file); }); return [ E('div', { 'class': 'cl-section cl-card cl-sb-card' }, [ E('h4', {}, _("Subscription Update")), buildSubscriptionSchedule(uiData || {}, subscriptionUpdateStatus || {}) ]), E('div', { 'class': 'cl-section cl-card cl-sb-card' }, [ E('h4', {}, _("sing-box Configuration Files")), E('table', { 'class': 'cl-sub-list cl-sb-list' }, [ E('thead', {}, E('tr', {}, [E('th', {}, _("File Name")), E('th', {}, _("Size")), E('th', {}, _("Actions"))])), E('tbody', {}, rows) ]), uploadInput, E('div', { 'class': 'cl-actions cl-sb-top-actions' }, [ E('button', { 'class': 'btn cbi-button-add cl-btn-sm cl-btn-sb-upload', click: function () { document.getElementById('sb-upload').click(); } }, _("Upload JSON Configuration")) ]) ]), editorBox ]; }, _buildSbWizardPanel: function () { var urlInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("Paste subscription URL (supports vmess / vless / trojan, etc.)") }); var nameInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("Configuration file name (optional, blank auto-generates singbox.json)"), style: 'margin-top:0' }); var savedUa = ''; try { savedUa = uci.get('clashoo', 'config', 'sub_ua') || ''; } catch (e) {} // YAML 订阅转换走 mihomo 订阅流程(拉 yaml → yaml2singbox),UA 与 mihomo 共用 sub_ua var convertUaPicker = buildUaPicker(savedUa); var genBtn, applyBtn; function setBusy(busy, activeBtn) { [genBtn, applyBtn].forEach(function (b) { if (!b) return; b.disabled = busy ? '' : null; if (busy) { if (!b.dataset.label) b.dataset.label = b.textContent; if (b === activeBtn) b.textContent = b._clBusyLabel || _("Processing…"); } else if (b.dataset.label) { b.textContent = b.dataset.label; } }); } function doCreate(setActive) { var url = urlInput.value.trim(); if (!url) { ui.addNotification(null, E('p', _("Please enter a subscription URL"))); return; } setBusy(true, setActive ? applyBtn : genBtn); // 保存 UA 到 UCI,后端 create_singbox_config 会读这个字段 var uaPromise = L.resolveDefault(uci.load('clashoo'), null).then(function () { uci.set('clashoo', 'config', 'sub_ua', convertUaPicker.getValue()); return uci.save() .then(function () { return clashoo.commitConfig(); }) .then(function () { return clearClashooDirty(); }); }).catch(function () {}); uaPromise.then(function () { return clashoo.createSingboxConfig(url, nameInput.value.trim()); }) .then(function (r) { /* RPC 超时被 resolveDefault 兜底成 {},r.success 是 undefined。 * 此时后端可能仍在跑(yaml2singbox 转 90 节点等),让用户直接刷新页 * 看实际产物,而不是误报"失败"。 */ if (!r || typeof r.success === 'undefined') { ui.addNotification(null, E('p', _("Generation is taking longer, refreshing to show results…"))); setBusy(false); setTimeout(function () { location.reload(); }, 1500); return; } if (!r.success) { setBusy(false); ui.addNotification(null, E('p', _("Generate failed: ") + (r.message || ''))); return; } if (setActive) { return clashoo.setSingboxProfile(r.name).then(function () { ui.addNotification(null, E('p', r.message + _(", switched to active configuration"))); location.reload(); }); } ui.addNotification(null, E('p', r.message)); location.reload(); }).catch(function (e) { setBusy(false); ui.addNotification(null, E('p', _("Generate error: ") + (e && e.message || e))); }); } genBtn = E('button', { 'class': 'btn cbi-button cl-btn-sm', click: function () { doCreate(false); } }, _("Generate Configuration")); applyBtn = E('button', { 'class': 'btn cbi-button-action cl-btn-sm', click: function () { doCreate(true); } }, _("Apply Configuration")); genBtn._clBusyLabel = _("Generating…"); applyBtn._clBusyLabel = _("Applying…"); /* ── native sing-box subscription card ── */ // 注:原生 sing-box JSON 订阅不需要 UA 选项(机场按链接参数/路径区分格式) var nativeUrlInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("Paste native sing-box subscription URL (direct JSON response)") }); var nativeNameInput = E('input', { 'class': 'cl-sub-url', type: 'text', placeholder: _("File name (optional, blank for automatic name)"), style: 'margin-top:0' }); var fetchBtn, fetchApplyBtn; var fetchStatusEl = E('div', { 'class': 'cl-update-status', style: 'margin-top:6px;font-size:12px;min-height:18px;line-height:1.4' }); var setFetchStatus = function (text, tone) { fetchStatusEl.textContent = text || ''; fetchStatusEl.style.color = tone === 'success' ? 'var(--success-color, #2e7d32)' : tone === 'error' ? 'var(--error-color, #d32f2f)' : tone === 'progress'? 'var(--tip-color, #1976d2)' : ''; }; function setNativeBusy(busy) { [fetchBtn, fetchApplyBtn].forEach(function (b) { if (!b) return; b.disabled = busy ? '' : null; if (busy) { if (!b.dataset.label) b.dataset.label = b.textContent; b.textContent = _("Fetching…"); } else if (b.dataset.label) { b.textContent = b.dataset.label; } }); } function doFetchNative(setActive) { var url = nativeUrlInput.value.trim(); if (!url) { setFetchStatus(_("✗ Please enter a subscription URL"), 'error'); setTimeout(function () { setFetchStatus(''); }, 4000); return; } setNativeBusy(true); setFetchStatus(_("⏳ Fetching configuration..."), 'progress'); clashoo.fetchSingboxNative(url, nativeNameInput.value.trim()) .then(function (r) { setNativeBusy(false); if (!r || typeof r.success === 'undefined') { setFetchStatus(_("⏳ Fetch timed out, refreshing to show results..."), 'progress'); setTimeout(function () { location.reload(); }, 1500); return; } if (!r.success) { setFetchStatus(_("✗ Fetch failed: ") + (r.message || ''), 'error'); return; } if (setActive) { setFetchStatus('⏳ ' + r.message + _(", switching to active configuration..."), 'progress'); return clashoo.setSingboxProfile(r.name).then(function () { setFetchStatus('✓ ' + r.message + _(", switched to active configuration"), 'success'); setTimeout(function () { location.reload(); }, 1200); }); } setFetchStatus('✓ ' + r.message, 'success'); setTimeout(function () { location.reload(); }, 1200); }).catch(function (e) { setNativeBusy(false); setFetchStatus(_("✗ Fetch error: ") + (e && e.message || e), 'error'); }); } fetchBtn = E('button', { 'class': 'btn cbi-button cl-btn-sm', click: function () { doFetchNative(false); } }, _("Fetch Configuration")); fetchApplyBtn = E('button', { 'class': 'btn cbi-button-action cl-btn-sm', click: function () { doFetchNative(true); } }, _("Fetch and Apply")); return [ E('div', { 'class': 'cl-section cl-card cl-sb-card' }, [ E('h4', {}, _("Node Subscription")), E('div', { 'class': 'cl-form-wrap cl-fixed-600 cl-sb-form' }, [ nativeUrlInput, nativeNameInput, E('div', { 'class': 'cl-actions cl-sb-top-actions' }, [fetchBtn, fetchApplyBtn]), fetchStatusEl ]), E('p', { 'class': 'cl-sb-note' }, _("For providers that directly offer sing-box JSON subscriptions, or links already converted by external tools. After fetching, click Update on the corresponding item in the Configuration Files tab to fetch the latest configuration again.") ) ]), E('div', { 'class': 'cl-section cl-card cl-sb-card' }, [ E('h4', {}, _("YAML Subscription Conversion")), E('div', { 'class': 'cl-form-wrap cl-fixed-600 cl-sb-form' }, [ urlInput, nameInput, convertUaPicker.wrap, E('div', { 'class': 'cl-actions cl-sb-top-actions' }, [ genBtn, applyBtn ]) ]), E('p', { 'class': 'cl-sb-note' }, _("Convert YAML subscriptions to sing-box JSON, automatically injecting TUN transparent proxy and China direct rules. Files with the same name are overwritten; use the same name when updating.") ) ]) ]; }, handleSaveApply: null, handleSave: null, handleReset: null });