'use strict'; 'require view'; 'require fs'; 'require ui'; /* * LuCI 配置页:策略映射 / 多线路 DNS / 域名分流 * * 约定: * · 所有文件解析与改写仍由 `isp-split conf` 在 shell 侧完成(带白名单校验、 * 回读校验、失败回滚),前端只负责收集值; * · 前端做一遍同样的校验,错误直接标在出错的输入框下面(红字), * 不必等后端返回; * · `conf preview` 只把改动写进临时副本再跑 plan,"保存前预检"不会碰 * /etc/isp-split.conf。 */ var CMD = '/usr/sbin/isp-split'; /* ---------------------------------------------------------------- 图标(内联 SVG) */ var ICONS = { refresh: '', check: '', plus: '', up: '', down: '', trash: '', plug: '', save: '' }; function svgIcon(name, size) { var span = document.createElement('span'); span.className = 'isp-ico'; span.innerHTML = ''; return span; } /* ---------------------------------------------------------------- 工具 */ function run(args) { return fs.exec(CMD, args).then(function(res) { // fs.exec 不管退出码都会 resolve;非 0 必须变成 reject, // 否则校验失败也会被 UI 当成"已保存"。 if (res && res.code) { var out = ((res.stdout || '') + (res.stderr || '')).trim(); throw new Error('isp-split ' + args.join(' ') + ' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : '')); } return res; }); } /* 行内错误提示:同一个 td 里紧跟输入框的一行红字 */ function errBox() { return E('div', { 'class': 'isp-err' }); } function setErr(box, msg) { if (!box) return; if (msg) { box.textContent = msg; box.classList.add('is-on'); } else { box.textContent = ''; box.classList.remove('is-on'); } } function isIPv4(s) { if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return false; var p = s.split('.'); for (var i = 0; i < p.length; i++) { if (+p[i] > 255) return false; } return true; } function isDomain(s) { if (!/^[A-Za-z0-9._-]+$/.test(s)) return false; if (s.charAt(0) === '.' || s.charAt(s.length - 1) === '.') return false; return s.indexOf('..') < 0; } function isSrc(s) { return /^[A-Za-z0-9._-]+$/.test(s); } function isSetName(s) { return /^[A-Za-z0-9_]{1,15}$/.test(s); } /* ---------------------------------------------------------------- 视图 */ return view.extend({ load: function() { /* 配置字段走 conf get;数据源健康度走 health --json。 health 失败不能拖垮整个配置页:捕获后渲染成“读取失败”。 */ return Promise.all([ run(['conf', 'get']), fs.exec(CMD, ['health', '--json']).catch(function() { return null; }) ]); }, render: function(res) { var self = this; var out = (res && res[0] && res[0].stdout) || ''; var health = null; try { health = JSON.parse(((res[1] && res[1].stdout) || '').trim() || 'null'); } catch (e) { health = null; } var model = { mode: 'bind', policies: [], isp: [], dns: [], domains: [] }; out.split('\n').forEach(function(line) { if (!line) return; /* 注释行不是数据行(B-1)。conf get 已在 shell 侧过滤, 这里再挡一次,避免旧版脚本或手工拼接的输出被渲染成红行。 */ if (/^\s*#/.test(line)) return; var f = line.split('\t'); if (f[0] === 'dns_mode') model.mode = (f[1] || 'bind').trim(); else if (f[0] === 'policies') model.policies = (f[1] || '').trim().split(/\s+/).filter(Boolean); else if (f[0] === 'isp' && f.length >= 5) model.isp.push({ src: f[1], set: f[2], policy: f[3], enabled: f[4] === '1' }); else if (f[0] === 'dns' && f.length >= 3) model.dns.push({ policy: f[1], dns: f[2], test: null }); else if (f[0] === 'domain' && f.length >= 3) model.domains.push({ domain: f[1], policy: f[2] }); }); if (!model.isp.length) { return E('div', { 'class': 'isp-app cbi-map' }, [ E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }), E('h2', {}, [ _('运营商分流 · 配置') ]), E('div', { 'class': 'cbi-map-descr' }, [ _('读不到 /etc/isp-split.conf 的 ISP_TABLE,请先确认已安装并执行过 isp-split fetch。') ]) ]); } var result = E('pre', { 'id': 'cfg-out', 'class': 'isp-out is-empty', 'hidden': '' }, ['']); /* 没有输出时保持 hidden:主题的 pre{display:block} 会盖掉 hidden 属性, CSS 里配了 .isp-out[hidden]{display:none!important},这里只管切换属性。 */ function showResult(text) { if (text) { result.textContent = text; result.removeAttribute('hidden'); result.classList.remove('is-empty'); } else { result.textContent = ''; result.setAttribute('hidden', ''); result.classList.add('is-empty'); } result.scrollTop = 0; } /* ============================================================ 一、策略映射 */ var ispTbody = E('tbody', {}); function renderIsp() { while (ispTbody.firstChild) ispTbody.removeChild(ispTbody.firstChild); model.isp.forEach(function(row, idx) { row._err = row._err || {}; var srcInp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': row.src, 'placeholder': '源名,如 chinatelecom' }); srcInp.addEventListener('input', function() { row.src = srcInp.value.trim(); validateIspRow(row); }); var setInp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': row.set, 'placeholder': '集合名,<=15 字符' }); setInp.addEventListener('input', function() { row.set = setInp.value.trim(); validateIspRow(row); }); var polSel = E('select', { 'class': 'cbi-input-select' }, model.policies.map(function(p) { return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]); })); if (!model.policies.length) polSel.appendChild(E('option', { 'value': '' }, [ _('(mwan3 里没有策略)') ])); polSel.addEventListener('change', function() { row.policy = polSel.value; validateIspRow(row); }); var chk = E('input', { 'type': 'checkbox', 'checked': row.enabled ? '' : null }); chk.addEventListener('change', function() { row.enabled = chk.checked; }); var up = E('button', { 'class': 'isp-icon-btn', 'title': _('上移(提高优先级)'), 'disabled': idx === 0 ? '' : null, 'click': function() { moveIsp(idx, -1); } }, [ svgIcon('up', 15) ]); var down = E('button', { 'class': 'isp-icon-btn', 'title': _('下移(降低优先级)'), 'disabled': idx === model.isp.length - 1 ? '' : null, 'click': function() { moveIsp(idx, 1); } }, [ svgIcon('down', 15) ]); var tr = E('tr', {}, [ E('td', { 'data-th': _('运营商') }, [ srcInp, row._err.src = errBox() ]), E('td', { 'data-th': _('集合') }, [ setInp, row._err.set = errBox() ]), E('td', { 'data-th': _('策略') }, [ polSel, row._err.policy = errBox() ]), E('td', { 'data-th': _('启用') }, [ E('label', { 'class': 'isp-switch' }, [ chk, E('span') ]) ]), E('td', { 'data-th': _('顺序') }, [ E('div', { 'class': 'isp-row-actions' }, [ up, down ]) ]) ]); ispTbody.appendChild(tr); validateIspRow(row); }); } function moveIsp(idx, delta) { var to = idx + delta; if (to < 0 || to >= model.isp.length) return; var row = model.isp.splice(idx, 1)[0]; model.isp.splice(to, 0, row); renderIsp(); } function validateIspRow(row) { var err = row._err || {}; var ok = true; if (!isSrc(row.src || '')) { setErr(err.src, _('只能包含字母、数字、点、下划线、连字符')); ok = false; } else { setErr(err.src, ''); } if (!isSetName(row.set || '')) { setErr(err.set, _('1-15 个字符,只能是字母/数字/下划线')); ok = false; } else { var dup = model.isp.filter(function(r) { return r !== row && r.set === row.set; }).length; if (dup) { setErr(err.set, _('集合名重复')); ok = false; } else { setErr(err.set, ''); } } if (!row.policy || (model.policies.length && model.policies.indexOf(row.policy) < 0)) { setErr(err.policy, _('mwan3 里没有这个策略')); ok = false; } else { setErr(err.policy, ''); } row._valid = ok; return ok; } renderIsp(); var addIspBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': function() { model.isp.push({ src: '', set: '', policy: model.policies[0] || '', enabled: true }); renderIsp(); } }, [ svgIcon('plus'), E('span', {}, [_('添加运营商')]) ]); /* ============================================================ 二、多线路 DNS */ var dnsTbody = E('tbody', {}); /* DNS 行 = mwan3 策略 ∪ DNS_TABLE 里已有但 mwan3 没列出的策略 */ var dnsPolicies = model.policies.slice(); model.dns.forEach(function(r) { if (dnsPolicies.indexOf(r.policy) < 0) dnsPolicies.push(r.policy); }); dnsPolicies.forEach(function(p) { var exists = null; model.dns.forEach(function(r) { if (r.policy === p) exists = r; }); if (!exists) model.dns.push({ policy: p, dns: '', test: null }); }); function renderDns() { while (dnsTbody.firstChild) dnsTbody.removeChild(dnsTbody.firstChild); model.dns.forEach(function(row) { row._err = errBox(); var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': _('该线路使用的 DNS,如 223.5.5.5'), 'value': row.dns }); inp.addEventListener('input', function() { row.dns = inp.value.trim(); validateDnsRow(row); }); var resSpan = E('div', { 'class': 'isp-test-result' }, [ row.test ? row.test.text : '' ]); if (row.test) resSpan.className = 'isp-test-result ' + (row.test.ok ? 'is-ok' : 'is-bad'); var testBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'title': _('向该线路的 DNS 发一次查询'), 'click': function() { resSpan.className = 'isp-test-result'; resSpan.textContent = _('测试中…'); /* 这里不能用 run():DNS 不可用时命令返回 1,但 JSON 仍然有效 */ return fs.exec(CMD, ['dnstest', row.policy, '--json']).then(function(r) { var d = null; try { d = JSON.parse((r.stdout || '').trim() || 'null'); } catch (e) { d = null; } if (d && d.ok) { resSpan.className = 'isp-test-result is-ok'; resSpan.textContent = _('可用') + '(' + (d.device || '?') + ' → ' + (d.answer || '?') + ')'; row.test = { ok: true, text: resSpan.textContent }; } else { resSpan.className = 'isp-test-result is-bad'; resSpan.textContent = _('不可用(DNS 无响应)'); row.test = { ok: false, text: resSpan.textContent }; } }).catch(function(e) { resSpan.className = 'isp-test-result is-bad'; resSpan.textContent = _('测线失败:') + String(e).replace(/^.*失败([^)]*):/, ''); }); } }, [ svgIcon('plug'), E('span', {}, [_('测线')]) ]); dnsTbody.appendChild(E('tr', {}, [ E('td', { 'data-th': _('线路(策略)') }, [ E('span', { 'class': 'isp-line-name' }, [ row.policy ]), row._err ]), E('td', { 'data-th': _('DNS') }, [ inp ]), E('td', { 'data-th': _('测线') }, [ E('div', { 'class': 'isp-row-actions' }, [ testBtn ]), resSpan ]) ])); validateDnsRow(row); }); } function validateDnsRow(row) { if (!row.dns) { setErr(row._err, ''); row._valid = true; return true; } if (!isIPv4(row.dns)) { setErr(row._err, _('必须是 IPv4 地址,如 223.5.5.5')); row._valid = false; return false; } setErr(row._err, ''); row._valid = true; return true; } renderDns(); var modeSel = E('select', { 'class': 'cbi-input-select' }, [ E('option', { 'value': 'bind', 'selected': model.mode === 'bind' ? '' : null }, [_('bind · dnsmasq 绑定出接口')]), E('option', { 'value': 'mark', 'selected': model.mode === 'mark' ? '' : null }, [_('mark · nft 按目标 DNS 打标')]) ]); modeSel.addEventListener('change', function() { model.mode = modeSel.value; }); /* ============================================================ 三、域名分流 */ var domTbody = E('tbody', {}); function renderDomains() { while (domTbody.firstChild) domTbody.removeChild(domTbody.firstChild); if (!model.domains.length) { domTbody.appendChild(E('tr', {}, [ E('td', { 'colspan': 3, 'data-th': '' }, [ E('div', { 'class': 'isp-empty' }, [ _('尚未配置。大多数域名不需要在这里出现——只有 CDN 调度不准的站点才需要钉线。') ]) ]) ])); return; } model.domains.forEach(function(row, idx) { var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': _('域名后缀,如 bilivideo.com'), 'value': row.domain }); inp.addEventListener('input', function() { row.domain = inp.value.trim(); validateDomainRow(row); }); var sel = E('select', { 'class': 'cbi-input-select' }, model.policies.map(function(p) { return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]); })); sel.addEventListener('change', function() { row.policy = sel.value; validateDomainRow(row); }); var del = E('button', { 'class': 'isp-icon-btn is-danger', 'title': _('删除'), 'click': function() { ui.showModal(_('确认删除'), [ E('p', {}, [ _('把 ') + (row.domain || _('这一行')) + _(' 从域名分流里删掉?') ]), E('div', { 'class': 'right' }, [ E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]), ' ', E('button', { 'class': 'cbi-button cbi-button-negative', 'click': function() { ui.hideModal(); model.domains.splice(idx, 1); renderDomains(); } }, [_('删除')]) ]) ]); } }, [ svgIcon('trash', 15) ]); var err = errBox(); row._err = err; domTbody.appendChild(E('tr', {}, [ E('td', { 'data-th': _('域名后缀') }, [ inp, err ]), E('td', { 'data-th': _('钉到线路') }, [ sel ]), E('td', { 'data-th': _('操作') }, [ E('div', { 'class': 'isp-row-actions' }, [ del ]) ]) ])); validateDomainRow(row); }); } function validateDomainRow(row) { if (!row.domain) { setErr(row._err, ''); row._valid = true; return true; } if (!isDomain(row.domain)) { setErr(row._err, _('只能包含字母/数字/点/下划线/连字符,且不能以点开头或结尾')); row._valid = false; return false; } var dup = model.domains.filter(function(r) { return r !== row && r.domain === row.domain; }).length; if (dup) { setErr(row._err, _('域名重复')); row._valid = false; return false; } setErr(row._err, ''); row._valid = true; return true; } renderDomains(); var addDomBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { model.domains.push({ domain: '', policy: model.policies[0] || '' }); renderDomains(); } }, [ svgIcon('plus'), E('span', {}, [_('添加域名')]) ]); /* ============================================================ 四、数据源(只读) */ /* 数据源地址是 root 路径的下载入口,配置页不开放编辑: 一个非法 URL 就能让 root 去拉任意内容。这里只展示现状, 修改方式指向 CONFIGURATION.md 的「多数据源」一节。 */ var srcTbody = E('tbody', {}); var srcRows = (health && health.sources) || []; var verifyEnabled = !!(health && health.verify_enabled); function srcRoleText(role) { if (role === 'primary') return _('主源'); if (role === 'verify') return _('校验源'); if (role === 'audit') return _('审计源'); return role || '—'; } function renderSources() { while (srcTbody.firstChild) srcTbody.removeChild(srcTbody.firstChild); if (!srcRows.length) { srcTbody.appendChild(E('tr', {}, [ E('td', { 'colspan': 6, 'data-th': '' }, [ E('div', { 'class': 'isp-empty' }, [ health ? _('没有数据源信息。') : _('读取数据源状态失败,请检查 isp-split health --json。') ]) ]) ])); return; } srcRows.forEach(function(s) { var tag = 'isp-tag is-ok'; var text = _('正常'); if (s.status === 'disabled') { tag = 'isp-tag'; text = _('未启用校验'); } else if (s.max_fail && s.fail_days >= s.max_fail) { tag = 'isp-tag is-bad'; text = _('拒绝更新') + ' ' + s.fail_days + '/' + s.max_fail; } else if (s.fail_days > 0) { tag = 'isp-tag is-warn'; text = _('降级') + ' ' + s.fail_days + '/' + s.max_fail; } srcTbody.appendChild(E('tr', {}, [ E('td', { 'data-th': _('源') }, [ s.label || s.name ]), E('td', { 'data-th': _('角色') }, [ E('span', { 'class': 'isp-tag' }, [ srcRoleText(s.role) ]) ]), E('td', { 'data-th': _('状态') }, [ E('span', { 'class': tag }, [ text ]) ]), E('td', { 'data-th': _('最近 Jaccard') }, [ s.jaccard != null ? (s.jaccard + '%') : '—' ]), E('td', { 'data-th': _('最后成功') }, [ s.last_ok_text || '—' ]), E('td', { 'data-th': _('连续失败') }, [ String(s.fail_days || 0) ]) ])); }); } renderSources(); /* ============================================================ 保存 / 预检 */ function values() { var ispVal = model.isp.map(function(r) { return r.src + ':' + r.set + ':' + r.policy + ':' + (r.enabled ? '1' : '0'); }).join('|'); var dnsVal = model.dns.filter(function(r) { return r.dns; }) .map(function(r) { return r.policy + ':' + r.dns; }).join('|'); var domVal = model.domains.filter(function(r) { return r.domain; }) .map(function(r) { return r.domain + ':' + r.policy; }).join('|'); return { isp: ispVal, dns: dnsVal, dom: domVal, mode: modeSel.value }; } /* 返回 true = 全部合法;出错的行已经标红 */ function validateAll() { var ok = true; model.isp.forEach(function(r) { ok = validateIspRow(r) && ok; }); model.dns.forEach(function(r) { ok = validateDnsRow(r) && ok; }); model.domains.forEach(function(r) { ok = validateDomainRow(r) && ok; }); if (!model.isp.length) { ui.addNotification(null, E('p', {}, [ _('至少保留一行运营商映射') ]), 'danger'); ok = false; } return ok; } function listErrors() { var msgs = []; model.isp.forEach(function(r) { if (r._valid === false) msgs.push(_('运营商行 ') + (r.set || r.src || '?') + _(' 有错误')); }); model.dns.forEach(function(r) { if (r._valid === false) msgs.push(_('DNS 行 ') + r.policy + _(' 有错误')); }); model.domains.forEach(function(r) { if (r._valid === false) msgs.push(_('域名行 ') + (r.domain || '?') + _(' 有错误')); }); return msgs; } function step(args, log) { return run(args).then(function(r) { var t = ((r.stdout || '') + (r.stderr || '')).trim(); log.push('$ isp-split ' + args.join(' ') + '\n' + (t || ' (无输出)')); return r; }); } function preflight() { if (!validateAll()) { showResult(_('预检未通过:') + '\n - ' + listErrors().join('\n - ')); ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger'); return; } var v = values(); showResult(_('正在预检(只写临时副本,不会改动配置)…')); run(['conf', 'preview', 'isp_table', v.isp, 'dns_table', v.dns, 'domain_split', v.dom, 'dns_mode', v.mode ]).then(function(r) { showResult(((r.stdout || '') + (r.stderr || '')) || '(无输出)'); ui.addNotification(null, E('p', {}, [_('预检完成,配置未被修改')]), 'info'); }).catch(function(e) { showResult(_('预检失败:') + String(e)); ui.addNotification(null, E('p', {}, [_('预检失败:') + String(e)]), 'danger'); }); } function save(apply) { if (!validateAll()) { showResult(_('保存已取消:') + '\n - ' + listErrors().join('\n - ')); ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger'); return; } var v = values(); var log = []; step(['conf', 'set', 'isp_table', v.isp], log) .then(function() { return step(['conf', 'set', 'dns_table', v.dns], log); }) .then(function() { return step(['conf', 'set', 'domain_split', v.dom], log); }) .then(function() { return step(['conf', 'set', 'dns_mode', v.mode], log); }) .then(function() { if (!apply) return null; return step(['wire'], log) .then(function() { return step(['apply'], log); }) .then(function() { return step(['dns'], log); }); }) .then(function() { showResult(log.join('\n\n')); ui.addNotification(null, E('p', {}, [ apply ? _('已保存并生效') : _('已保存。改动需执行「保存并生效」或手动 wire/apply 后才生效') ]), 'info'); }) .catch(function(e) { showResult(log.join('\n\n') + '\n\n错误: ' + String(e)); ui.addNotification(null, E('p', {}, [ _('操作失败:') + String(e) ]), 'danger'); }); } /* ============================================================ 页面 */ return E('div', { 'class': 'isp-app cbi-map' }, [ E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }), E('div', { 'class': 'isp-hero' }, [ E('h2', {}, [ _('运营商分流 · 配置') ]), E('div', { 'class': 'isp-sub' }, [ _('改动写入 /etc/isp-split.conf:写入前校验、写入后回读,语法不合法自动回滚。') ]) ]), /* ---- 策略映射 ---- */ E('section', { 'class': 'isp-section' }, [ E('h3', {}, [ _('策略映射') ]), E('p', { 'class': 'isp-desc' }, [ _('顺序即优先级:排在前面的运营商优先占有重叠地址。集合名同时是 mwan3 规则名(<=15 字符)。') ]), E('table', { 'class': 'isp-form-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, [_('运营商(源名)')]), E('th', {}, [_('集合')]), E('th', {}, [_('策略')]), E('th', {}, [_('启用')]), E('th', {}, [_('顺序')]) ]) ]), ispTbody ]), E('div', { 'class': 'isp-btn-row' }, [ addIspBtn ]) ]), /* ---- 多线路 DNS ---- */ E('section', { 'class': 'isp-section' }, [ E('h3', {}, [ _('多线路 DNS') ]), E('p', { 'class': 'isp-desc' }, [ _('让每条线用自己运营商的 DNS,CDN 才会把本运营商节点返回给你。留空表示不配置。') ]), E('table', { 'class': 'isp-form-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, [_('线路(策略)')]), E('th', {}, [_('DNS 服务器')]), E('th', {}, [_('测线')]) ]) ]), dnsTbody ]), E('div', { 'class': 'isp-btn-row' }, [ E('span', { 'class': 'isp-line-name' }, [_('出线方式')]), modeSel ]), /* 这行解释原来挤在 .isp-tag 胶囊里,窄屏换成两行后首尾字符被圆角压掉, 看着像被截断。改成普通说明段落:整段换行显示,title 再兜一次全文。 */ E('div', { 'class': 'isp-note', 'title': _('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳') }, [ _('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳') ]) ]), /* ---- 域名分流 ---- */ E('section', { 'class': 'isp-section' }, [ E('h3', {}, [ _('域名分流') ]), E('p', { 'class': 'isp-desc' }, [ _('未列出的域名走系统默认上游(通常就是 PPPoE 下发的运营商 DNS,无需配置)。'), _('只有个别站点 CDN 调度不准时,才需要把它的域名钉到指定线路。') ]), E('table', { 'class': 'isp-form-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, [_('域名后缀')]), E('th', {}, [_('钉到线路')]), E('th', {}, [_('操作')]) ]) ]), domTbody ]), E('div', { 'class': 'isp-btn-row' }, [ addDomBtn ]) ]), /* ---- 数据源(只读) ---- */ E('section', { 'class': 'isp-section' }, [ E('h3', {}, [ _('数据源'), E('span', { 'class': 'isp-tag' }, [ _('只读') ]) ]), E('p', { 'class': 'isp-desc' }, [ _('主源出数据,校验源只跑日 diff;分歧超阈值会拒绝更新并保留旧数据。'), _('为避免把非法 URL 写进 root 路径,本页只展示、不开放编辑。') ]), E('div', { 'class': 'isp-btn-row' }, [ E('span', { 'class': 'isp-tag' + (verifyEnabled ? ' is-ok' : '') }, [ verifyEnabled ? _('校验已启用') : _('校验未启用') ]), E('span', { 'class': 'isp-tag' }, [ _('源声明来自 ISP_SOURCES') ]) ]), E('table', { 'class': 'isp-form-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, [_('源')]), E('th', {}, [_('角色')]), E('th', {}, [_('状态')]), E('th', {}, [_('最近 Jaccard')]), E('th', {}, [_('最后成功')]), E('th', {}, [_('连续失败')]) ]) ]), srcTbody ]), E('div', { 'class': 'isp-note' }, [ _('修改数据源请编辑 /etc/isp-split.conf 的 ISP_SOURCES / ISP_SOURCE_FILES,'), E('a', { 'href': 'https://github.com/jihebube/openwrt-isp-split/blob/main/docs/CONFIGURATION.md', 'target': '_blank', 'rel': 'noopener' }, [ _('配置文档') ]), _(' 的「多数据源」一节有完整字段说明与降级行为表。') ]) ]), E('div', { 'class': 'isp-footer-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': preflight }, [ svgIcon('refresh'), E('span', {}, [_('保存前预检')]) ]), E('button', { 'class': 'cbi-button', 'click': function() { save(false); } }, [ svgIcon('save'), E('span', {}, [_('仅保存')]) ]), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { save(true); } }, [ svgIcon('check'), E('span', {}, [_('保存并生效')]) ]) ]), result ]); }, handleSave: null, handleSaveApply: null, handleReset: null });