mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-12 03:14:48 +08:00
1723 lines
59 KiB
JavaScript
1723 lines
59 KiB
JavaScript
/* SPDX-License-Identifier: Apache-2.0
|
||
*
|
||
* luci-app-sysctl - LuCI view for kernel sysctl parameters (OpenWrt 24.10)
|
||
* v1.4.0: modal-free architecture - all views, editors, confirmations and
|
||
* results render inline on the page (robust against theme quirks on
|
||
* third-party firmwares where ui.showModal may be invisible).
|
||
*
|
||
* Section 1: custom parameters (/etc/sysctl.d/99-luci-sysctl.conf)
|
||
* Section 2: online preset (/etc/sysctl.d/98-online-preset.conf)
|
||
* Section 3: browse/search live kernel parameters from /proc/sys
|
||
*/
|
||
|
||
'use strict';
|
||
'require view';
|
||
'require rpc';
|
||
'require ui';
|
||
'require dom';
|
||
|
||
var callList = rpc.declare({ object: 'luci.sysctl', method: 'list' });
|
||
var callStatus = rpc.declare({ object: 'luci.sysctl', method: 'status' });
|
||
var callBrowse = rpc.declare({ object: 'luci.sysctl', method: 'browse', params: [ 'prefix' ] });
|
||
var callSearch = rpc.declare({ object: 'luci.sysctl', method: 'search', params: [ 'query', 'limit' ] });
|
||
var callSet = rpc.declare({ object: 'luci.sysctl', method: 'set', params: [ 'key', 'value', 'disabled', 'apply' ] });
|
||
var callRemove = rpc.declare({ object: 'luci.sysctl', method: 'remove', params: [ 'key' ] });
|
||
var callApply = rpc.declare({ object: 'luci.sysctl', method: 'apply' });
|
||
var callPresetStatus = rpc.declare({ object: 'luci.sysctl', method: 'preset_status' });
|
||
var callPresetFetch = rpc.declare({ object: 'luci.sysctl', method: 'preset_fetch', params: [ 'url', 'prefix' ] });
|
||
var callPresetImport = rpc.declare({ object: 'luci.sysctl', method: 'preset_import', params: [ 'url', 'prefix' ] });
|
||
var callPresetCheck = rpc.declare({ object: 'luci.sysctl', method: 'preset_check' });
|
||
var callPresetRemove = rpc.declare({
|
||
object: 'luci.sysctl',
|
||
method: 'preset_remove'
|
||
});
|
||
|
||
var callPresetList = rpc.declare({
|
||
object: 'luci.sysctl',
|
||
method: 'preset_list',
|
||
params: [ 'url', 'prefix' ]
|
||
});
|
||
var callFileView = rpc.declare({ object: 'luci.sysctl', method: 'file_view', params: [ 'path' ] });
|
||
var callFileSet = rpc.declare({ object: 'luci.sysctl', method: 'file_set', params: [ 'path', 'key', 'value', 'disabled' ] });
|
||
var callFileDelete = rpc.declare({ object: 'luci.sysctl', method: 'file_delete', params: [ 'path', 'key' ] });
|
||
var callDupCheck = rpc.declare({ object: 'luci.sysctl', method: 'dup_check', params: [ 'key' ] });
|
||
|
||
var KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*$/;
|
||
|
||
function badge(text, kind) {
|
||
return E('span', { 'class': 'lsc-badge lsc-badge-' + kind }, text);
|
||
}
|
||
|
||
var LSC_CSS = [
|
||
'.lsc-root { font-size: 15px; }',
|
||
'.lsc-root .lsc-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0 2px; }',
|
||
'.lsc-root .lsc-chip { appearance: none; border: 1px solid #d0d7de; background: #fff; color: #24292f; border-radius: 999px; padding: 4px 12px; font-size: 12.5px; line-height: 1.5; cursor: pointer; text-transform: none; font-weight: 500; transition: background .15s, color .15s, border-color .15s, box-shadow .15s; box-shadow: 0 1px 2px rgba(16,24,40,.04); }',
|
||
'.lsc-root .lsc-chip:hover { border-color: #0969da; color: #0969da; background: #f3f8ff; }',
|
||
'.lsc-root .lsc-chip.lsc-chip-active { background: #0969da; border-color: #0969da; color: #fff; }',
|
||
'.lsc-root .lsc-tablewrap { border: 1px solid #e4e7ec; border-radius: 8px; overflow: auto; background: #fff; margin: 8px 0; }',
|
||
'.lsc-root table.lsc-table { width: 100%; border-collapse: collapse; font-size: 15px; margin: 0; }',
|
||
'.lsc-root .lsc-table thead th { background: #f6f8fa; text-align: center !important; font-weight: 600; color: #57606a; padding: 10px 12px; border-bottom: 1px solid #e4e7ec; white-space: nowrap; font-size: 15px; }',
|
||
'.lsc-root .lsc-table tbody td { padding: 10px 12px; border-bottom: 1px solid #f0f2f4; vertical-align: middle; text-align: center !important; }',
|
||
'.lsc-root .lsc-table tbody td .cbi-button { margin: 2px 3px; }',
|
||
'.lsc-root .lsc-table tbody tr:last-child td { border-bottom: none; }',
|
||
'.lsc-root .lsc-table tbody tr:nth-child(even) td { background: #fafbfc; }',
|
||
'.lsc-root .lsc-table tbody tr:hover td { background: #f0f6ff; }',
|
||
'.lsc-root .lsc-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 13px; font-weight: 500; line-height: 1.6; white-space: nowrap; }',
|
||
'.lsc-root .lsc-badge-ok { background: #e6f6ec; color: #18794e; }',
|
||
'.lsc-root .lsc-badge-warn { background: #fff3e0; color: #a35a00; }',
|
||
'.lsc-root .lsc-badge-err { background: #fdecec; color: #b3261e; }',
|
||
'.lsc-root .lsc-badge-off { background: #eef0f2; color: #6e7781; }',
|
||
'.lsc-root .lsc-badge-main { background: #eef4fb; color: #34618e; }',
|
||
'.lsc-root .lsc-card { border: 1px solid #e4e7ec; border-radius: 10px; padding: 14px 16px; background: #fff; box-shadow: 0 1px 3px rgba(16,24,40,.06); margin: 10px 0; }',
|
||
'.lsc-root .lsc-card h3 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }',
|
||
'.lsc-root .lsc-card h4 { margin: 10px 0 4px; font-size: 13.5px; color: #57606a; }',
|
||
'.lsc-root code { background: #f6f8fa; border: 1px solid #eef0f2; border-radius: 4px; padding: 1px 6px; font-size: 1em; word-break: break-all; }',
|
||
'.lsc-root .cbi-button { border-radius: 6px; transition: filter .15s, box-shadow .15s, background .15s; text-transform: none; }',
|
||
'.lsc-root .cbi-button:hover { filter: brightness(1.06); }',
|
||
'.lsc-root h2, .lsc-root h3, .lsc-root h4, .lsc-root th, .lsc-root .lsc-badge, .lsc-root code, .lsc-root .lsc-muted, .lsc-root .cbi-section-descr, .lsc-root .lsc-card * { text-transform: none !important; }',
|
||
'.lsc-root .lsc-muted { color: #777; }',
|
||
'.lsc-root .cbi-page-actions { float: none !important; text-align: center !important; display: flex !important; flex-wrap: wrap; justify-content: center !important; align-items: center; gap: .5rem; }',
|
||
'.lsc-root .cbi-page-actions > br { display: none; }'
|
||
].join('\n');
|
||
|
||
return view.extend({
|
||
ensureStyle: function() {
|
||
if (document.getElementById('luci-app-sysctl-style') != null)
|
||
return;
|
||
|
||
document.head.appendChild(E('style', { 'id': 'luci-app-sysctl-style' }, [ LSC_CSS ]));
|
||
},
|
||
|
||
load: function() {
|
||
return Promise.all([ callList(), callStatus(), callPresetStatus() ]);
|
||
},
|
||
|
||
render: function(data) {
|
||
this.ensureStyle();
|
||
|
||
var list = (data[0] != null) ? data[0] : {};
|
||
var status = (data[1] != null) ? data[1] : {};
|
||
var preset = (data[2] != null) ? data[2] : {};
|
||
|
||
this.customEntries = list.custom || [];
|
||
this.mainEntries = list.main || [];
|
||
this.statusData = status;
|
||
this.presetData = preset;
|
||
this.browsePrefix = '';
|
||
this.searchTimer = null;
|
||
this.editing = null;
|
||
this.fileViewPath = null;
|
||
this.fileEditing = null;
|
||
this._armTimers = {};
|
||
|
||
var root = E('div', { 'class': 'cbi-map lsc-root' }, [
|
||
E('h2', {}, _('内核参数(Sysctl)管理')),
|
||
E('div', { 'class': 'cbi-map-descr' }, _(
|
||
'自定义参数保存在 %s,开机时由系统自动加载,也可随时点击“应用配置”立即生效。').format('/etc/sysctl.d/99-luci-sysctl.conf')),
|
||
this.renderCustomSection(),
|
||
this.renderPresetSection(),
|
||
this.renderBrowseSection()
|
||
]);
|
||
|
||
this.refreshCustomTable();
|
||
this.refreshSourceChips();
|
||
this.loadBrowse('');
|
||
|
||
return root;
|
||
},
|
||
|
||
/* ---------- shared helpers ---------- */
|
||
|
||
errorBox: function(msg, hint) {
|
||
var children = [ E('p', { 'style': 'margin:2px 0' }, msg) ];
|
||
|
||
if (hint)
|
||
children.push(E('p', { 'style': 'margin:2px 0;color:#666' }, hint));
|
||
|
||
return E('div', { 'class': 'alert-message error', 'style': 'margin:6px 0' }, children);
|
||
},
|
||
|
||
backendErrorHint: function(msg) {
|
||
if (/not found/i.test(msg || ''))
|
||
return _('后端缺少该方法,通常是 rpcd 尚未重新加载插件所致。请在 SSH 执行:%s 后刷新页面。').format('/etc/init.d/rpcd restart');
|
||
|
||
return _('可在 SSH 用 %s 验证后端。').format('ubus call luci.sysctl status');
|
||
},
|
||
|
||
/* Two-step inline confirmation: first click arms the button,
|
||
* second click (within 4s) runs the action. */
|
||
armButton: function(btn, confirmText, action) {
|
||
if (btn.getAttribute('data-armed') == '1') {
|
||
btn.setAttribute('data-armed', '0');
|
||
btn.textContent = btn.getAttribute('data-orig');
|
||
action();
|
||
return;
|
||
}
|
||
|
||
btn.setAttribute('data-armed', '1');
|
||
btn.setAttribute('data-orig', btn.textContent);
|
||
btn.textContent = confirmText;
|
||
|
||
this._armTimers[btn.getAttribute('data-arm-id')] = window.setTimeout(function() {
|
||
if (btn.getAttribute('data-armed') == '1') {
|
||
btn.setAttribute('data-armed', '0');
|
||
btn.textContent = btn.getAttribute('data-orig');
|
||
}
|
||
}, 4000);
|
||
},
|
||
|
||
nextArmId: function() {
|
||
this._armSeq = (this._armSeq || 0) + 1;
|
||
return String(this._armSeq);
|
||
},
|
||
|
||
/* ---------- section 1: custom parameters ---------- */
|
||
|
||
renderCustomSection: function() {
|
||
this.customTableBody = E('tbody', {});
|
||
this.tableWrapBox = E('div', { 'class': 'lsc-tablewrap' });
|
||
this.customEmptyBox = E('div', {
|
||
'style': 'display:none;text-align:center;color:#777;padding:10px 0'
|
||
}, _('暂无自定义参数,点击下方“添加参数”开始。'));
|
||
this.editFormBox = E('div', { 'style': 'display:none' });
|
||
this.fileViewBox = E('div', { 'style': 'display:none' });
|
||
this.dupBox = E('div', { 'style': 'display:none' });
|
||
this.applyResultBox = E('div', { 'style': 'display:none' });
|
||
this.sourceChipsBox = E('div', { 'class': 'lsc-chips' });
|
||
|
||
var descr = [
|
||
E('p', { 'style': 'margin:4px 0' }, [
|
||
_('配置源(点击文件名可在下方查看/编辑,按顺序应用,后面的文件覆盖前面的同名参数):'),
|
||
this.sourceChipsBox
|
||
])
|
||
];
|
||
|
||
var table = E('table', { 'class': 'lsc-table' }, [
|
||
E('thead', {}, E('tr', {}, [
|
||
E('th', {}, _('参数名')),
|
||
E('th', {}, _('配置值')),
|
||
E('th', {}, _('当前值')),
|
||
E('th', {}, _('状态')),
|
||
E('th', {}, _('操作'))
|
||
])),
|
||
this.customTableBody
|
||
]);
|
||
|
||
this.tableWrapBox.appendChild(table);
|
||
|
||
return E('div', { 'class': 'cbi-section' }, [
|
||
E('h3', {}, _('自定义参数')),
|
||
E('div', { 'class': 'cbi-section-descr' }, descr),
|
||
this.applyResultBox,
|
||
this.editFormBox,
|
||
this.tableWrapBox,
|
||
this.customEmptyBox,
|
||
this.fileViewBox,
|
||
this.dupBox,
|
||
E('div', { 'class': 'cbi-page-actions' }, [
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-add',
|
||
'click': ui.createHandlerFn(this, 'showEditForm', null)
|
||
}, _('添加参数')),
|
||
' ',
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-apply',
|
||
'click': ui.createHandlerFn(this, 'applyConfig')
|
||
}, _('应用配置')),
|
||
' ',
|
||
E('button', {
|
||
'class': 'cbi-button',
|
||
'click': ui.createHandlerFn(this, 'showDupCheck')
|
||
}, _('检测重复')),
|
||
' ',
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-negative',
|
||
'click': ui.createHandlerFn(this, 'reloadList')
|
||
}, _('刷新'))
|
||
])
|
||
]);
|
||
},
|
||
|
||
refreshCustomTable: function() {
|
||
var rows = [];
|
||
var i, e;
|
||
|
||
/* Main-config entries (/etc/sysctl.conf) are intentionally NOT listed
|
||
* here anymore: they are reachable through the source chips above,
|
||
* showing them twice was redundant. */
|
||
|
||
if (this.customEntries.length == 0) {
|
||
/* hide the whole (empty) table incl. header, show a one-liner */
|
||
this.tableWrapBox.style.display = 'none';
|
||
this.customEmptyBox.style.display = '';
|
||
dom.content(this.customTableBody, rows);
|
||
return;
|
||
}
|
||
|
||
this.tableWrapBox.style.display = '';
|
||
this.customEmptyBox.style.display = 'none';
|
||
|
||
for (i = 0; i < this.customEntries.length; i++) {
|
||
e = this.customEntries[i];
|
||
|
||
var badgeEl;
|
||
|
||
if (e.disabled)
|
||
badgeEl = badge(_('已禁用'), 'off');
|
||
else if (!e.exists)
|
||
badgeEl = badge(_('无效参数'), 'err');
|
||
else if (e.match)
|
||
badgeEl = badge(_('已生效'), 'ok');
|
||
else
|
||
badgeEl = badge(_('未应用'), 'warn');
|
||
|
||
var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('删除'));
|
||
|
||
rows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, e.key) ]),
|
||
E('td', {}, [ E('code', {}, e.value) ]),
|
||
E('td', {}, [ E('code', {}, (e.current == null) ? '—' : e.current) ]),
|
||
E('td', {}, badgeEl),
|
||
E('td', { 'style': 'white-space:nowrap' }, [
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-edit',
|
||
'click': ui.createHandlerFn(this, 'showEditForm', { isNew: false, entry: e })
|
||
}, _('编辑')),
|
||
' ',
|
||
e.disabled
|
||
? E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(this, 'toggleEntry', e, false) }, _('启用'))
|
||
: E('button', { 'class': 'cbi-button cbi-button-remove', 'click': ui.createHandlerFn(this, 'toggleEntry', e, true) }, _('禁用')),
|
||
' ',
|
||
delBtn
|
||
])
|
||
]));
|
||
|
||
(function(view, entry, btn) {
|
||
btn.addEventListener('click', function() {
|
||
view.armButton(btn, _('确认删除?'), function() { view.removeEntry(entry); });
|
||
});
|
||
})(this, e, delBtn);
|
||
}
|
||
|
||
dom.content(this.customTableBody, rows);
|
||
},
|
||
|
||
showEditForm: function(entry) {
|
||
/* "添加参数" button calls this with null -> use a blank new-entry
|
||
* state, otherwise renderEditForm would bail out and the form would
|
||
* never open (latent bug since v1.0, other entries always passed an
|
||
* object so it never surfaced until now). */
|
||
this.editing = (entry != null) ? entry : { isNew: true };
|
||
this.editFromBrowse = (entry != null && entry.fromBrowse === true);
|
||
this.renderEditForm();
|
||
|
||
if (this.editing != null && this.editFormBox.scrollIntoView)
|
||
this.editFormBox.scrollIntoView({ block: 'nearest' });
|
||
},
|
||
|
||
renderEditForm: function() {
|
||
var self = this;
|
||
var st = this.editing;
|
||
|
||
if (st == null) {
|
||
this.editFormBox.style.display = 'none';
|
||
dom.content(this.editFormBox, []);
|
||
return;
|
||
}
|
||
|
||
this.editFormBox.style.display = '';
|
||
|
||
var isNew = (st.isNew === true);
|
||
var key = (st.entry != null) ? st.entry.key : (st.key || '');
|
||
|
||
var keyInput = E('input', {
|
||
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%',
|
||
'placeholder': 'net.ipv4.tcp_fastopen', 'value': key
|
||
});
|
||
|
||
var valInput = E('input', {
|
||
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%',
|
||
'placeholder': '3', 'value': (st.entry != null) ? st.entry.value : (st.value || '')
|
||
});
|
||
|
||
var applyChk = E('input', { 'type': 'checkbox' });
|
||
applyChk.checked = true;
|
||
|
||
var disabledChk = E('input', { 'type': 'checkbox' });
|
||
disabledChk.checked = (!isNew && st.entry.disabled == true);
|
||
|
||
var errBox = this.errorBox('', '');
|
||
errBox.style.display = 'none';
|
||
|
||
var title = isNew
|
||
? (st.fromMain ? _('自定义覆盖主配置:%s').format(key) : _('添加内核参数'))
|
||
: _('编辑内核参数:%s').format(key);
|
||
|
||
var note = isNew
|
||
? (st.fromMain
|
||
? _('将把该参数写入自定义配置以覆盖主配置取值,主配置文件本身保持不变。')
|
||
: _('若参数在当前内核中不存在(例如模块未加载),仍可保存,但会标记为无效。'))
|
||
: _('修改参数名保存后将替换原条目(先写入新参数名,再删除旧参数名)。');
|
||
|
||
var saveBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('保存'));
|
||
|
||
saveBtn.addEventListener('click', function() { self.saveEditForm(keyInput, valInput, applyChk, disabledChk, errBox); });
|
||
|
||
var editDupBox = E('div', { 'style': 'display:none' });
|
||
this.editDupBox = editDupBox;
|
||
|
||
keyInput.addEventListener('input', function() {
|
||
if (self.editDupTimer != null)
|
||
window.clearTimeout(self.editDupTimer);
|
||
|
||
var v = keyInput.value;
|
||
|
||
self.editDupTimer = window.setTimeout(function() {
|
||
self.refreshEditDup((v || '').trim());
|
||
}, 400);
|
||
});
|
||
|
||
dom.content(this.editFormBox, [
|
||
E('div', { 'class': 'lsc-card' }, [
|
||
E('strong', {}, title),
|
||
E('div', { 'class': 'cbi-value', 'style': 'margin-top:8px' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('参数名')),
|
||
E('div', { 'class': 'cbi-value-field' }, keyInput)
|
||
]),
|
||
E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('值')),
|
||
E('div', { 'class': 'cbi-value-field' }, valInput)
|
||
]),
|
||
E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, ' '),
|
||
E('div', { 'class': 'cbi-value-field' }, [
|
||
E('label', { 'style': 'display:inline-flex;align-items:center;gap:6px' }, [ applyChk, _('立即写入内核(运行时生效)') ]),
|
||
E('label', { 'style': 'display:inline-flex;align-items:center;gap:6px;margin-left:16px' }, [ disabledChk, _('禁用该条目(写入配置但注释掉)') ])
|
||
])
|
||
]),
|
||
E('div', { 'style': 'color:#666;margin-top:4px' }, note),
|
||
editDupBox,
|
||
errBox,
|
||
E('div', { 'style': 'margin-top:8px' }, [
|
||
saveBtn, ' ',
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideEditForm') }, _('取消'))
|
||
])
|
||
])
|
||
]);
|
||
|
||
if (key != '')
|
||
this.refreshEditDup(key);
|
||
},
|
||
|
||
hideEditForm: function() {
|
||
this.editing = null;
|
||
|
||
if (this.editDupTimer != null) {
|
||
window.clearTimeout(this.editDupTimer);
|
||
this.editDupTimer = null;
|
||
}
|
||
|
||
this.renderEditForm();
|
||
|
||
/* edit was started from the browse section: scroll back there so the
|
||
* user does not get lost at the top of the page */
|
||
if (this.editFromBrowse) {
|
||
this.editFromBrowse = false;
|
||
|
||
if (this.browseSectionBox != null && this.browseSectionBox.scrollIntoView)
|
||
this.browseSectionBox.scrollIntoView({ block: 'start' });
|
||
}
|
||
},
|
||
|
||
/* Summarize where a key is defined besides the custom config, and whether
|
||
* any of those files load AFTER the custom config (=> would override it). */
|
||
summarizeDup: function(d) {
|
||
var customPath = '/etc/sysctl.d/99-luci-sysctl.conf';
|
||
var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : [];
|
||
var paths = [];
|
||
var i;
|
||
|
||
for (i = 0; i < files.length; i++)
|
||
paths.push(files[i].path);
|
||
|
||
var customIdx = paths.indexOf(customPath);
|
||
var others = [];
|
||
var shadowed = false;
|
||
|
||
for (i = 0; i < d.entries.length; i++) {
|
||
var e = d.entries[i];
|
||
|
||
if (e.path == customPath)
|
||
continue;
|
||
|
||
others.push(e.path.replace('/etc/', '') + ' (' + e.value + ')');
|
||
|
||
if (!e.disabled && customIdx >= 0 && paths.indexOf(e.path) > customIdx)
|
||
shadowed = true;
|
||
}
|
||
|
||
return { others: others, shadowed: shadowed };
|
||
},
|
||
|
||
/* Inline warning inside the add/edit form: warn BEFORE saving that the
|
||
* key is also defined in other config files (and whether those files
|
||
* would override the custom entry). */
|
||
refreshEditDup: function(key) {
|
||
var self = this;
|
||
var box = this.editDupBox;
|
||
|
||
if (box == null)
|
||
return;
|
||
|
||
if (key == null || key == '' || !KEY_RE.test(key)) {
|
||
box.style.display = 'none';
|
||
dom.content(box, []);
|
||
return;
|
||
}
|
||
|
||
callDupCheck(key).then(function(res) {
|
||
var dups = (res != null && res.dups != null) ? res.dups : [];
|
||
|
||
if (dups.length == 0 || self.editing == null) {
|
||
box.style.display = 'none';
|
||
dom.content(box, []);
|
||
return;
|
||
}
|
||
|
||
var sum = self.summarizeDup(dups[0]);
|
||
|
||
if (sum.others.length == 0) {
|
||
box.style.display = 'none';
|
||
dom.content(box, []);
|
||
return;
|
||
}
|
||
|
||
var msg = sum.shadowed
|
||
? _('注意:该参数在自定义配置之后加载的文件中也有定义(%s),保存后会被覆盖、不会生效。请直接编辑对应文件或删除其同名条目。').format(sum.others.join('、'))
|
||
: _('该参数在其他文件中也有定义(%s)。自定义配置加载顺序靠后,保存后以这里的值为准。').format(sum.others.join('、'));
|
||
|
||
box.style.display = '';
|
||
dom.content(box, [
|
||
E('div', { 'style': (sum.shadowed ? 'color:#b3261e' : 'color:#a35a00') + ';margin-top:4px' }, msg)
|
||
]);
|
||
}).catch(function() {
|
||
box.style.display = 'none';
|
||
dom.content(box, []);
|
||
});
|
||
},
|
||
|
||
saveEditForm: function(keyInput, valInput, applyChk, disabledChk, errBox) {
|
||
var self = this;
|
||
var st = this.editing;
|
||
|
||
if (st == null)
|
||
return;
|
||
|
||
var isNew = (st.isNew === true);
|
||
var origKey = (st.entry != null) ? st.entry.key : null;
|
||
var key = (keyInput.value || '').trim();
|
||
var val = (valInput.value || '').trim();
|
||
var disabled = disabledChk.checked;
|
||
var applyNow = applyChk.checked && !disabled;
|
||
|
||
errBox.style.display = 'none';
|
||
|
||
if (!KEY_RE.test(key)) {
|
||
errBox.textContent = '';
|
||
errBox.appendChild(E('p', {}, _('参数名格式无效,仅允许字母、数字、点、下划线和连字符。')));
|
||
errBox.style.display = '';
|
||
return;
|
||
}
|
||
|
||
if (val == '') {
|
||
errBox.textContent = '';
|
||
errBox.appendChild(E('p', {}, _('值不能为空。')));
|
||
errBox.style.display = '';
|
||
return;
|
||
}
|
||
|
||
var p = callSet(key, val, disabled, applyNow);
|
||
|
||
if (!isNew && key != origKey) {
|
||
p = p.then(function(res) {
|
||
if (res == null || res.code != 0)
|
||
return res;
|
||
|
||
return callRemove(origKey).then(function() {
|
||
return res;
|
||
}).catch(function() {
|
||
res.rename_leftover = origKey;
|
||
return res;
|
||
});
|
||
});
|
||
}
|
||
|
||
p.then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
errBox.textContent = '';
|
||
errBox.appendChild(self.errorBox(_('保存失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))));
|
||
errBox.style.display = '';
|
||
return;
|
||
}
|
||
|
||
var notes = [];
|
||
|
||
if (res.rename_leftover)
|
||
notes.push(E('p', { 'style': 'color:#a80' }, _('旧参数 %s 删除失败,请在本页手动删除该条目。').format(res.rename_leftover)));
|
||
|
||
if (!res.exists)
|
||
notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 在当前内核中不存在(可能模块未加载),配置已保存但不会生效。').format(key)));
|
||
else if (applyNow && res.applied === 'readonly')
|
||
notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 为只读,无法在运行时修改(配置已保存,重启后尝试应用)。').format(key)));
|
||
else if (applyNow && res.applied === false)
|
||
notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 已写入配置,但运行时校验不一致,请检查取值格式。').format(key)));
|
||
|
||
var hasWarn = (res.rename_leftover != null) || (!res.exists) || (applyNow && (res.applied === 'readonly' || res.applied === false));
|
||
var saveAutoHide = hasWarn ? 8000 : 4000;
|
||
|
||
if (notes.length == 0)
|
||
notes.push(E('p', {}, (st.fromMain && isNew) ? _('已创建覆盖条目:%s。') : _('参数 %s 已保存。').format(key)));
|
||
|
||
self.hideEditForm();
|
||
self.reloadList();
|
||
|
||
self.showApplyResult([ E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, notes) ], saveAutoHide);
|
||
|
||
/* post-save heads-up: same key also defined in other files? */
|
||
callDupCheck(key).then(function(dres) {
|
||
var dups = (dres != null && dres.dups != null) ? dres.dups : [];
|
||
|
||
if (dups.length == 0)
|
||
return;
|
||
|
||
var sum = self.summarizeDup(dups[0]);
|
||
|
||
if (sum.others.length == 0)
|
||
return;
|
||
|
||
var msg = sum.shadowed
|
||
? _('注意:该参数在自定义配置之后加载的文件中也有定义(%s),你的取值会被覆盖。').format(sum.others.join('、'))
|
||
: _('该参数在其他文件中也有定义(%s),自定义配置加载靠后,以你的值为准。').format(sum.others.join('、'));
|
||
|
||
notes.push(E('p', { 'style': 'color:' + (sum.shadowed ? '#b3261e' : '#a35a00') }, msg));
|
||
self.showApplyResult([ E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, notes) ], 8000);
|
||
}).catch(function() {});
|
||
}).catch(function(e) {
|
||
errBox.textContent = '';
|
||
errBox.appendChild(self.errorBox(_('保存失败:%s').format(e.message), self.backendErrorHint(e.message)));
|
||
errBox.style.display = '';
|
||
});
|
||
},
|
||
|
||
reloadList: function() {
|
||
var self = this;
|
||
|
||
return Promise.all([ callList(), callStatus(), callPresetStatus() ]).then(function(data) {
|
||
var list = (data[0] != null) ? data[0] : {};
|
||
|
||
self.customEntries = list.custom || [];
|
||
self.mainEntries = list.main || [];
|
||
self.statusData = (data[1] != null) ? data[1] : {};
|
||
self.presetData = (data[2] != null) ? data[2] : {};
|
||
self.refreshCustomTable();
|
||
self.refreshPresetStatus();
|
||
self.refreshSourceChips();
|
||
|
||
/* re-render an open file panel so its "当前值" column reflects
|
||
* the kernel state AFTER an apply/refresh (values were stale
|
||
* otherwise, making applied settings look ineffective) */
|
||
if (self.fileViewPath != null)
|
||
self.renderFilePanel();
|
||
}).catch(function(e) {
|
||
ui.addNotification(null, E('p', {}, _('加载失败:%s').format(e.message)), 'error');
|
||
});
|
||
},
|
||
|
||
refreshSourceChips: function() {
|
||
var self = this;
|
||
var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : [];
|
||
|
||
if (this.sourceChipsBox == null)
|
||
return;
|
||
|
||
var chips = [];
|
||
|
||
/* Plugin-managed files (98-online-preset.conf, 99-luci-sysctl.conf)
|
||
* are managed through their dedicated sections; opening them via the
|
||
* file panel is refused by the backend, so hide their chips. */
|
||
var pluginManaged = {
|
||
'/etc/sysctl.d/99-luci-sysctl.conf': true,
|
||
'/etc/sysctl.d/98-online-preset.conf': true
|
||
};
|
||
|
||
for (var i = 0; i < files.length; i++) {
|
||
var f = files[i];
|
||
|
||
if (pluginManaged[f.path])
|
||
continue;
|
||
|
||
chips.push(E('button', {
|
||
'class': 'lsc-chip' + (this.fileViewPath == f.path ? ' lsc-chip-active' : ''),
|
||
'title': _('点击查看并可编辑该文件内的参数'),
|
||
'click': ui.createHandlerFn(self, 'showFileView', f.path)
|
||
}, '%s (%d)'.format(f.path.replace('/etc/', ''), f.count)));
|
||
}
|
||
|
||
dom.content(this.sourceChipsBox, chips);
|
||
},
|
||
|
||
toggleEntry: function(entry, disabled) {
|
||
var self = this;
|
||
|
||
return callSet(entry.key, entry.value, disabled, false).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('操作失败:%s').format((res != null && res.error) ? res.error : 'unknown error')),
|
||
'error');
|
||
}
|
||
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
ui.addNotification(null, E('p', {}, _('操作失败:%s').format(e.message)), 'error');
|
||
});
|
||
},
|
||
|
||
removeEntry: function(entry) {
|
||
var self = this;
|
||
|
||
return callRemove(entry.key).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : 'unknown error')),
|
||
'error');
|
||
}
|
||
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
ui.addNotification(null, E('p', {}, _('删除失败:%s').format(e.message)), 'error');
|
||
});
|
||
},
|
||
|
||
/* Unified result-bar display: shows nodes, optional auto-hide after
|
||
* autoHideMs (0 = stay until replaced). Re-calling resets the timer. */
|
||
showApplyResult: function(nodes, autoHideMs) {
|
||
if (this.applyResultTimer != null) {
|
||
window.clearTimeout(this.applyResultTimer);
|
||
this.applyResultTimer = null;
|
||
}
|
||
|
||
dom.content(this.applyResultBox, nodes);
|
||
this.applyResultBox.style.display = '';
|
||
|
||
if (autoHideMs > 0) {
|
||
var box = this.applyResultBox;
|
||
|
||
this.applyResultTimer = window.setTimeout(function() {
|
||
box.style.display = 'none';
|
||
dom.content(box, []);
|
||
}, autoHideMs);
|
||
}
|
||
},
|
||
|
||
hideApplyResult: function() {
|
||
if (this.applyResultTimer != null) {
|
||
window.clearTimeout(this.applyResultTimer);
|
||
this.applyResultTimer = null;
|
||
}
|
||
|
||
this.applyResultBox.style.display = 'none';
|
||
dom.content(this.applyResultBox, []);
|
||
},
|
||
|
||
applyConfig: function() {
|
||
var self = this;
|
||
|
||
this.showApplyResult([ E('p', { 'style': 'color:#777;padding:6px' }, _('正在应用全部配置,请稍候…')) ], 0);
|
||
|
||
if (this.applyResultBox.scrollIntoView)
|
||
this.applyResultBox.scrollIntoView({ block: 'nearest' });
|
||
|
||
return callApply().then(function(res) {
|
||
var errors = (res != null && res.errors != null) ? res.errors : [];
|
||
var content;
|
||
|
||
if (res == null || res.code != 0) {
|
||
content = self.errorBox(_('应用失败:%s').format((res != null && res.error) ? res.error : 'unknown error'));
|
||
}
|
||
else if (errors.length == 0) {
|
||
content = E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [
|
||
E('p', {}, _('全部配置已成功应用。')),
|
||
E('p', { 'style': 'color:#777;font-size:12px;margin:2px 0 0' }, _('(本提示将在数秒后自动消失)'))
|
||
]);
|
||
}
|
||
else {
|
||
var items = [];
|
||
|
||
for (var i = 0; i < errors.length; i++) {
|
||
var er = errors[i];
|
||
var parts = [ E('strong', {}, er.file) ];
|
||
|
||
/* real command errors (permission denied etc.) */
|
||
if (er.output)
|
||
parts.push(E('pre', { 'style': 'white-space:pre-wrap;margin:4px 0' }, er.output));
|
||
|
||
/* kernel did not accept these values (echo was silent,
|
||
* but read-back differs) */
|
||
if (er.not_applied != null && er.not_applied.length > 0) {
|
||
var naItems = [];
|
||
|
||
for (var j = 0; j < er.not_applied.length; j++) {
|
||
var na = er.not_applied[j];
|
||
|
||
naItems.push(E('li', {}, _('%s:写入 %s,内核当前 %s').format(
|
||
na.key, na.value, na.current)));
|
||
}
|
||
|
||
parts.push(E('p', { 'style': 'margin:4px 0' },
|
||
_('以下参数内核未接受(可能被限制、随后被覆盖或需要重启生效):')));
|
||
parts.push(E('ul', { 'style': 'margin:2px 0' }, naItems));
|
||
}
|
||
|
||
items.push(E('li', {}, parts));
|
||
}
|
||
|
||
content = E('div', { 'class': 'alert-message warning', 'style': 'margin:6px 0' }, [
|
||
E('p', {}, _('应用完成,以下内容需要留意(常见原因:参数不存在、只读、被内核拒绝或被其他文件覆盖):')),
|
||
E('ul', {}, items)
|
||
]);
|
||
}
|
||
|
||
self.showApplyResult([ content ], (res != null && res.code == 0 && errors.length == 0) ? 4000 : 0);
|
||
|
||
if (self.applyResultBox.scrollIntoView)
|
||
self.applyResultBox.scrollIntoView({ block: 'nearest' });
|
||
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
self.showApplyResult([ self.errorBox(_('应用失败:%s').format(e.message), self.backendErrorHint(e.message)) ], 0);
|
||
});
|
||
},
|
||
|
||
/* ---------- per-file viewing / editing (inline) ---------- */
|
||
|
||
showFileView: function(path) {
|
||
this.fileViewPath = (this.fileViewPath == path) ? null : path;
|
||
this.fileEditing = null;
|
||
this.refreshSourceChips();
|
||
this.renderFilePanel();
|
||
},
|
||
|
||
renderFilePanel: function() {
|
||
var self = this;
|
||
var path = this.fileViewPath;
|
||
var box = this.fileViewBox;
|
||
|
||
if (path == null) {
|
||
box.style.display = 'none';
|
||
dom.content(box, []);
|
||
return;
|
||
}
|
||
|
||
box.style.display = '';
|
||
dom.content(box, [
|
||
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)),
|
||
E('p', { 'style': 'color:#777;text-align:center' }, _('加载中…'))
|
||
]);
|
||
|
||
callFileView(path).then(function(res) {
|
||
if (self.fileViewPath != path)
|
||
return;
|
||
|
||
if (res == null || res.code != 0) {
|
||
dom.content(box, [
|
||
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)),
|
||
self.errorBox((res != null && res.error) ? res.error : _('未知错误'), self.backendErrorHint(res != null ? res.error : ''))
|
||
]);
|
||
return;
|
||
}
|
||
|
||
var rows = [];
|
||
|
||
for (var i = 0; i < (res.entries || []).length; i++) {
|
||
var e = res.entries[i];
|
||
var editing = (self.fileEditing != null && self.fileEditing.key == e.key);
|
||
|
||
if (editing) {
|
||
rows.push(self.renderFileEditRow(res.path, e));
|
||
continue;
|
||
}
|
||
|
||
var ops;
|
||
|
||
if (res.editable) {
|
||
var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('删除'));
|
||
|
||
ops = [
|
||
E('button', { 'class': 'cbi-button cbi-button-edit', 'click': ui.createHandlerFn(self, 'editFileEntry', path, e) }, _('编辑')),
|
||
' ',
|
||
e.disabled
|
||
? E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(self, 'fileToggle', path, e, false) }, _('启用'))
|
||
: E('button', { 'class': 'cbi-button cbi-button-remove', 'click': ui.createHandlerFn(self, 'fileToggle', path, e, true) }, _('禁用')),
|
||
' ',
|
||
delBtn
|
||
];
|
||
|
||
(function(view, p, entry, btn) {
|
||
btn.addEventListener('click', function() {
|
||
view.armButton(btn, _('确认删除?'), function() { view.fileDelete(p, entry); });
|
||
});
|
||
})(self, res.path, e, delBtn);
|
||
}
|
||
else {
|
||
ops = [ E('button', {
|
||
'class': 'cbi-button cbi-button-add',
|
||
'click': ui.createHandlerFn(self, 'showEditForm', { isNew: true, key: e.key, value: e.value, fromMain: true })
|
||
}, _('自定义')) ];
|
||
}
|
||
|
||
rows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, (e.disabled ? '# ' : '') + e.key) ]),
|
||
E('td', {}, [ E('code', {}, e.value) ]),
|
||
E('td', {}, [ E('code', {
|
||
/* value differs from kernel state: likely overridden by a
|
||
* later config file (or write failed) -> highlight it */
|
||
'style': (!e.disabled && e.current != null && e.current != e.value) ? 'color:#a35a00;font-weight:600' : ''
|
||
}, (e.disabled || e.current == null) ? '—' : e.current) ]),
|
||
E('td', { 'style': 'white-space:nowrap' }, ops)
|
||
]));
|
||
}
|
||
|
||
if (rows.length == 0)
|
||
rows.push(E('tr', {}, E('td', { 'colspan': 4, 'style': 'text-align:center;color:#777;padding:12px' },
|
||
_('(空文件)'))));
|
||
|
||
var note = res.editable
|
||
? _('此文件中的参数可直接编辑/禁用/删除,保存即写入 %s;点击"应用配置"后对内核生效。').format(res.path)
|
||
: _('此文件由插件管理,不支持直接编辑:在线预设请在下方"在线预设"区更新,自定义参数请在上方表格中管理。');
|
||
|
||
dom.content(box, [
|
||
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(res.path)),
|
||
E('p', { 'style': 'margin:4px 0;color:#666;text-align:center' }, note),
|
||
E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
|
||
E('thead', {}, E('tr', {}, [
|
||
E('th', {}, _('参数')),
|
||
E('th', {}, _('配置值')),
|
||
E('th', {}, _('当前值')),
|
||
E('th', {}, _('操作'))
|
||
])),
|
||
E('tbody', rows)
|
||
])),
|
||
E('div', { 'style': 'margin-top:8px;text-align:center' }, [
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideFileView') }, _('收起'))
|
||
])
|
||
]);
|
||
}).catch(function(e) {
|
||
if (self.fileViewPath != path)
|
||
return;
|
||
|
||
dom.content(box, [
|
||
E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)),
|
||
self.errorBox(_('读取失败:%s').format(e.message), self.backendErrorHint(e.message))
|
||
]);
|
||
});
|
||
},
|
||
|
||
renderFileEditRow: function(path, entry) {
|
||
var self = this;
|
||
|
||
var valInput = E('input', {
|
||
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:95%', 'value': entry.value
|
||
});
|
||
|
||
var disabledChk = E('input', { 'type': 'checkbox' });
|
||
disabledChk.checked = (entry.disabled == true);
|
||
|
||
var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive important' }, _('保存'));
|
||
|
||
saveBtn.addEventListener('click', function() {
|
||
var val = (valInput.value || '').trim();
|
||
|
||
if (val == '') {
|
||
alert(_('值不能为空。'));
|
||
return;
|
||
}
|
||
|
||
callFileSet(path, entry.key, val, disabledChk.checked).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
alert(_('保存失败:%s').format((res != null && res.error) ? res.error : _('未知错误')));
|
||
return;
|
||
}
|
||
|
||
self.fileEditing = null;
|
||
self.renderFilePanel();
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
alert(_('保存失败:%s').format(e.message));
|
||
});
|
||
});
|
||
|
||
return E('tr', { 'style': 'background:#fafafa' }, [
|
||
E('td', {}, [ E('code', {}, entry.key) ]),
|
||
E('td', { 'colspan': 2 }, [
|
||
valInput, ' ',
|
||
E('label', { 'style': 'display:inline-flex;align-items:center;gap:4px' }, [ disabledChk, _('禁用') ])
|
||
]),
|
||
E('td', { 'style': 'white-space:nowrap' }, [
|
||
saveBtn, ' ',
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'cancelFileEdit') }, _('取消'))
|
||
])
|
||
]);
|
||
},
|
||
|
||
editFileEntry: function(path, entry) {
|
||
this.fileEditing = entry;
|
||
this.renderFilePanel();
|
||
},
|
||
|
||
cancelFileEdit: function() {
|
||
this.fileEditing = null;
|
||
this.renderFilePanel();
|
||
},
|
||
|
||
fileToggle: function(path, entry, disabled) {
|
||
var self = this;
|
||
|
||
return callFileSet(path, entry.key, entry.value, disabled).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('操作失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))),
|
||
'error');
|
||
}
|
||
|
||
self.renderFilePanel();
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
ui.addNotification(null, E('p', {}, _('操作失败:%s').format(e.message)), 'error');
|
||
});
|
||
},
|
||
|
||
fileDelete: function(path, entry) {
|
||
var self = this;
|
||
|
||
return callFileDelete(path, entry.key).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))),
|
||
'error');
|
||
}
|
||
|
||
self.renderFilePanel();
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
ui.addNotification(null, E('p', {}, _('删除失败:%s').format(e.message)), 'error');
|
||
});
|
||
},
|
||
|
||
hideFileView: function() {
|
||
this.fileViewPath = null;
|
||
this.fileEditing = null;
|
||
this.refreshSourceChips();
|
||
this.renderFilePanel();
|
||
},
|
||
|
||
/* ---------- duplicate-key detection & cleanup ---------- */
|
||
|
||
showDupCheck: function() {
|
||
var self = this;
|
||
|
||
this.dupBox.style.display = '';
|
||
dom.content(this.dupBox, [
|
||
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
|
||
E('p', { 'style': 'color:#777;text-align:center' }, _('扫描中…'))
|
||
]);
|
||
|
||
if (this.dupBox.scrollIntoView)
|
||
this.dupBox.scrollIntoView({ block: 'start' });
|
||
|
||
callDupCheck('').then(function(res) {
|
||
self.renderDupResults((res != null && res.dups != null) ? res.dups : []);
|
||
}).catch(function(e) {
|
||
dom.content(self.dupBox, [
|
||
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
|
||
self.errorBox(_('扫描失败:%s').format(e.message), self.backendErrorHint(e.message))
|
||
]);
|
||
});
|
||
},
|
||
|
||
hideDupCheck: function() {
|
||
this.dupBox.style.display = 'none';
|
||
dom.content(this.dupBox, []);
|
||
},
|
||
|
||
renderDupResults: function(dups) {
|
||
var self = this;
|
||
var rows = [];
|
||
var customPath = '/etc/sysctl.d/99-luci-sysctl.conf';
|
||
var collapseBtn = E('div', { 'style': 'text-align:center;margin-top:8px' }, [
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideDupCheck') }, _('收起'))
|
||
]);
|
||
|
||
if (dups.length == 0) {
|
||
dom.content(this.dupBox, [
|
||
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
|
||
E('p', { 'style': 'text-align:center;color:#777;padding:8px' },
|
||
_('未发现在多个文件中重复定义的参数。')),
|
||
collapseBtn
|
||
]);
|
||
return;
|
||
}
|
||
|
||
for (var i = 0; i < dups.length; i++) {
|
||
var d = dups[i];
|
||
var entRows = [];
|
||
var finalEntry = null;
|
||
|
||
for (var j = 0; j < d.entries.length; j++) {
|
||
var e = d.entries[j];
|
||
|
||
if (!e.disabled)
|
||
finalEntry = e;
|
||
|
||
entRows.push(E('div', {
|
||
'style': 'display:flex;align-items:center;justify-content:center;gap:8px;margin:3px 0'
|
||
}, [
|
||
E('code', {}, e.path.replace('/etc/', '')),
|
||
E('span', { 'style': 'color:#999' }, '='),
|
||
E('code', {}, e.value),
|
||
e.disabled
|
||
? badge(_('已禁用'), 'off')
|
||
: E('span', { 'style': 'color:#18794e;font-size:12px' }, _('生效来源')),
|
||
(e.path != customPath)
|
||
? E('button', {
|
||
'class': 'cbi-button cbi-button-remove',
|
||
'title': _('从该文件删除此条目'),
|
||
'click': ui.createHandlerFn(self, 'dupDeleteEntry', e, d.key)
|
||
}, _('删除'))
|
||
: E('span', { 'style': 'color:#34618e;font-size:12px' }, _('自定义'))
|
||
]));
|
||
}
|
||
|
||
/* files order == application order; compare against custom conf */
|
||
var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : [];
|
||
var paths = [];
|
||
|
||
for (var k = 0; k < files.length; k++)
|
||
paths.push(files[k].path);
|
||
|
||
var customIdx = paths.indexOf(customPath);
|
||
var shadowed = (finalEntry != null && customIdx >= 0 && paths.indexOf(finalEntry.path) > customIdx);
|
||
var warnText = (shadowed && finalEntry != null)
|
||
? _('生效来源(%s)加载在自定义配置之后:“添加参数”里设置该参数不会生效,请直接编辑该文件或删除其同名条目。').format(finalEntry.path.replace('/etc/', ''))
|
||
: _('自定义配置加载顺序靠后,“添加参数”设置该参数时会覆盖以上取值。');
|
||
|
||
rows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, d.key) ]),
|
||
E('td', {}, E('div', {}, entRows)),
|
||
E('td', { 'style': 'max-width:300px' }, E('div', {
|
||
'style': 'color:' + (shadowed ? '#b3261e' : '#666')
|
||
}, warnText))
|
||
]));
|
||
}
|
||
|
||
dom.content(this.dupBox, [
|
||
E('h3', { 'style': 'text-align:center' }, _('同名参数检测')),
|
||
E('p', { 'style': 'color:#666;margin:4px 0;text-align:center' },
|
||
_('以下参数在多个配置文件中重复定义,按应用顺序以最后一个为准:')),
|
||
E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
|
||
E('thead', {}, E('tr', {}, [
|
||
E('th', {}, _('参数名')),
|
||
E('th', {}, _('各文件定义(按应用顺序)')),
|
||
E('th', {}, _('说明'))
|
||
])),
|
||
E('tbody', rows)
|
||
])),
|
||
collapseBtn
|
||
]);
|
||
},
|
||
|
||
dupDeleteEntry: function(e, key) {
|
||
var self = this;
|
||
|
||
return callFileDelete(e.path, key).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))),
|
||
'error');
|
||
return;
|
||
}
|
||
|
||
self.reloadList();
|
||
self.showDupCheck();
|
||
}).catch(function(err) {
|
||
ui.addNotification(null, E('p', {}, _('删除失败:%s').format(err.message)), 'error');
|
||
});
|
||
},
|
||
|
||
/* ---------- section 2: online preset ---------- */
|
||
|
||
renderPresetSection: function() {
|
||
var self = this;
|
||
|
||
this.presetStatusLine = E('div', { 'style': 'margin:6px 0;padding:6px 10px;background:#f5f5f5;border-radius:3px' });
|
||
this.presetResultBox = E('div', { 'style': 'display:none' });
|
||
|
||
this.presetUrlInput = E('input', {
|
||
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%',
|
||
'placeholder': 'https://github.com/user/repo/blob/main/sysctl.conf(也支持 raw 地址)'
|
||
});
|
||
|
||
this.presetPrefixInput = E('input', {
|
||
'type': 'text', 'class': 'cbi-input-text', 'style': 'width:260px',
|
||
'placeholder': 'https://gh-proxy.org/'
|
||
});
|
||
|
||
var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('移除预设'));
|
||
|
||
delBtn.addEventListener('click', function() {
|
||
self.armButton(delBtn, _('确认移除?'), function() { self.presetRemove(); });
|
||
});
|
||
|
||
return E('div', { 'class': 'cbi-section' }, [
|
||
E('h3', {}, _('在线预设')),
|
||
E('div', { 'class': 'cbi-section-descr' }, [
|
||
E('p', { 'style': 'margin:4px 0' }, _(
|
||
'从在线源(GitHub 等)获取现成的 sysctl 优化配置,预览确认后一键导入。')),
|
||
E('p', { 'style': 'margin:4px 0' }, _(
|
||
'预设保存于 %s,由本插件整体管理(更新时全量替换)。如需覆盖个别条目,请在上方"自定义参数"中添加,其加载顺序更靠后、优先级更高。').format('/etc/sysctl.d/98-online-preset.conf'))
|
||
]),
|
||
this.presetStatusLine,
|
||
E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('配置源 URL')),
|
||
E('div', { 'class': 'cbi-value-field' }, this.presetUrlInput)
|
||
]),
|
||
E('div', { 'class': 'cbi-value' }, [
|
||
E('label', { 'class': 'cbi-value-title' }, _('镜像前缀(可选)')),
|
||
E('div', { 'class': 'cbi-value-field' }, [
|
||
this.presetPrefixInput,
|
||
E('div', { 'style': 'color:#777;margin-top:2px' },
|
||
_('直连 GitHub 不可用时填写镜像前缀,最终地址为 前缀 + 原始 URL。'))
|
||
])
|
||
]),
|
||
E('div', { 'class': 'cbi-page-actions' }, [
|
||
E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(this, 'presetFetchPreview') }, _('获取并预览')),
|
||
' ',
|
||
E('button', { 'class': 'cbi-button cbi-button-find', 'click': ui.createHandlerFn(this, 'presetCheckUpdates') }, _('检查更新')),
|
||
' ',
|
||
delBtn
|
||
]),
|
||
this.presetResultBox
|
||
]);
|
||
},
|
||
|
||
refreshPresetStatus: function() {
|
||
var p = this.presetData || {};
|
||
var line;
|
||
|
||
if (p.present) {
|
||
line = [ E('strong', {}, _('已导入') + ':'), _('%d 条参数').format(p.count || 0) ];
|
||
|
||
if (p.source) {
|
||
line.push(E('span', { 'style': 'color:#777' }, ' · ' + _('来源') + ' '));
|
||
line.push(E('span', { 'style': 'word-break:break-all' }, p.source));
|
||
}
|
||
|
||
if (p.fetched) {
|
||
line.push(E('span', { 'style': 'color:#777' }, ' · ' + _('更新于') + ' '));
|
||
line.push(this.formatEpoch(p.fetched));
|
||
}
|
||
}
|
||
else {
|
||
line = [ E('span', { 'style': 'color:#777' }, _('尚未导入在线预设。')) ];
|
||
}
|
||
|
||
dom.content(this.presetStatusLine, line);
|
||
},
|
||
|
||
formatEpoch: function(s) {
|
||
var n = Number(s);
|
||
|
||
if (s == null || s === '' || isNaN(n) || n <= 0)
|
||
return '—';
|
||
|
||
try {
|
||
return new Date(n * 1000).toLocaleString();
|
||
} catch (e) {
|
||
return String(s);
|
||
}
|
||
},
|
||
|
||
presetFetchPreview: function() {
|
||
var self = this;
|
||
var url = (this.presetUrlInput.value || '').trim();
|
||
|
||
if (url == '') {
|
||
this.showPresetResult(this.errorBox(_('请先填写配置源 URL。')));
|
||
return;
|
||
}
|
||
|
||
this.showPresetResult(E('p', { 'style': 'color:#777;padding:6px' }, _('正在获取配置源信息,请稍候…')));
|
||
|
||
return callPresetList(url, (this.presetPrefixInput.value || '').trim()).then(function(lres) {
|
||
if (lres != null && lres.is_dir) {
|
||
self.renderPresetDirList(lres);
|
||
return;
|
||
}
|
||
|
||
return self.fetchAndPreviewPresetFile(url);
|
||
}).catch(function(e) {
|
||
self.showPresetResult(self.errorBox(_('获取失败:%s').format(e.message), self.backendErrorHint(e.message)));
|
||
});
|
||
},
|
||
|
||
showPresetResult: function(content) {
|
||
this.presetResultBox.style.display = '';
|
||
dom.content(this.presetResultBox, [ content ]);
|
||
},
|
||
|
||
fetchAndPreviewPresetFile: function(url) {
|
||
var self = this;
|
||
var prefix = (this.presetPrefixInput.value || '').trim();
|
||
|
||
this.showPresetResult(E('p', { 'style': 'color:#777;padding:6px' }, _('正在下载并解析预设,请稍候…')));
|
||
|
||
return callPresetFetch(url, prefix).then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
self.showPresetResult(self.errorBox(
|
||
(res != null && res.error) ? res.error : _('未知错误'),
|
||
self.backendErrorHint(res != null ? res.error : '')));
|
||
return;
|
||
}
|
||
|
||
self.renderPresetPreview(res);
|
||
}).catch(function(e) {
|
||
self.showPresetResult(self.errorBox(_('获取失败:%s').format(e.message), self.backendErrorHint(e.message)));
|
||
});
|
||
},
|
||
|
||
renderPresetDirList: function(res) {
|
||
var self = this;
|
||
var rows = [];
|
||
|
||
for (var i = 0; i < (res.files || []).length; i++) {
|
||
(function(f) {
|
||
var btn = E('button', {
|
||
'class': 'lsc-chip',
|
||
'style': 'font-size:13px;padding:5px 14px'
|
||
}, f.name + (f.size > 0 ? ' · ' + (f.size / 1024).toFixed(1) + ' KB' : ''));
|
||
|
||
btn.addEventListener('click', function() { self.fetchAndPreviewPresetFile(f.url); });
|
||
rows.push(E('li', { 'style': 'margin:2px 0' }, btn));
|
||
})(res.files[i]);
|
||
}
|
||
|
||
this.presetResultBox.style.display = '';
|
||
dom.content(this.presetResultBox, [
|
||
E('h3', {}, _('预设目录')),
|
||
E('p', { 'style': 'margin:4px 0;color:#666' },
|
||
_('该地址是一个目录,发现 %d 个预设文件。点击要导入的文件(下载时同样使用镜像前缀):').format(res.files.length)),
|
||
E('ul', { 'style': 'margin:8px 0;padding-left:4px;list-style:none' }, rows),
|
||
E('div', { 'style': 'margin-top:10px' }, [
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('收起'))
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderPresetPreview: function(res) {
|
||
var self = this;
|
||
var rows = [];
|
||
var notes = [];
|
||
var i;
|
||
|
||
var cmap = {};
|
||
|
||
for (i = 0; i < (res.conflicts || []).length; i++)
|
||
cmap[res.conflicts[i].key] = res.conflicts[i].file;
|
||
|
||
for (i = 0; i < (res.params || []).length; i++) {
|
||
var it = res.params[i];
|
||
var conflictFile = cmap[it.key] || '';
|
||
|
||
rows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, it.key) ]),
|
||
E('td', {}, [ E('code', {}, it.value) ]),
|
||
E('td', { 'style': 'color:#a80' }, conflictFile ? _('覆盖本地 %s').format(conflictFile.replace('/etc/', '')) : '')
|
||
]));
|
||
}
|
||
|
||
notes.push(E('p', {}, _('共解析出 %d 条参数,来源:%s。').format(res.params.length, res.url)));
|
||
|
||
if ((res.conflicts || []).length > 0)
|
||
notes.push(E('p', { 'class': 'alert-message warning', 'style': 'margin:6px 0' },
|
||
_('%d 条参数与本地现有配置同名,导入后将覆盖其取值。').format(res.conflicts.length)));
|
||
|
||
if ((res.invalid || []).length > 0)
|
||
notes.push(E('p', { 'class': 'alert-message warning', 'style': 'margin:6px 0' },
|
||
_('%d 行无法解析已忽略(常见为说明文字或格式错误的行)。').format(res.invalid.length)));
|
||
|
||
var importBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('导入到路由器'));
|
||
|
||
importBtn.addEventListener('click', function() { self.presetImport(res.url); });
|
||
|
||
dom.content(this.presetResultBox, [
|
||
E('h3', {}, _('预设预览')),
|
||
E('div', {}, notes),
|
||
E('div', { 'style': 'max-height:320px;overflow:auto' }, E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
|
||
E('thead', {}, E('tr', {}, [ E('th', {}, _('参数')), E('th', {}, _('值')), E('th', {}, _('冲突')) ])),
|
||
E('tbody', rows)
|
||
]))),
|
||
E('div', { 'style': 'margin-top:10px' }, [
|
||
importBtn, ' ',
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('取消'))
|
||
])
|
||
]);
|
||
},
|
||
|
||
presetImport: function(url) {
|
||
var self = this;
|
||
|
||
return callPresetImport(url, '').then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
dom.content(self.presetResultBox, [ self.errorBox(_('导入失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))) ]);
|
||
return;
|
||
}
|
||
|
||
dom.content(self.presetResultBox, [
|
||
E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [
|
||
E('p', {}, _('已导入 %d 条预设参数到 %s。点击"应用配置"可立即生效。').format(res.count, res.path))
|
||
])
|
||
]);
|
||
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
dom.content(self.presetResultBox, [ self.errorBox(_('导入失败:%s').format(e.message), self.backendErrorHint(e.message)) ]);
|
||
});
|
||
},
|
||
|
||
presetCheckUpdates: function() {
|
||
var self = this;
|
||
|
||
dom.content(this.presetResultBox, [ E('p', { 'style': 'color:#777;padding:6px' }, _('正在对比在线版本与本地预设,请稍候…')) ]);
|
||
this.presetResultBox.style.display = '';
|
||
|
||
return callPresetCheck().then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
dom.content(self.presetResultBox, [ self.errorBox(
|
||
(res != null && res.error) ? res.error : _('未知错误'),
|
||
self.backendErrorHint(res != null ? res.error : '')) ]);
|
||
return;
|
||
}
|
||
|
||
if ((res.added || []).length == 0 && (res.changed || []).length == 0 && (res.removed || []).length == 0) {
|
||
dom.content(self.presetResultBox, [
|
||
E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [
|
||
E('p', {}, _('在线预设已是最新,共 %d 条参数。').format(res.remote_count))
|
||
])
|
||
]);
|
||
return;
|
||
}
|
||
|
||
self.renderPresetDiff(res);
|
||
}).catch(function(e) {
|
||
dom.content(self.presetResultBox, [ self.errorBox(_('检查更新失败:%s').format(e.message), self.backendErrorHint(e.message)) ]);
|
||
});
|
||
},
|
||
|
||
renderPresetDiff: function(res) {
|
||
var self = this;
|
||
var blocks = [];
|
||
var i;
|
||
|
||
var mkTable = function(headers, rows) {
|
||
return E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [
|
||
E('thead', {}, E('tr', {}, headers.map(function(h) { return E('th', {}, h); }))),
|
||
E('tbody', rows)
|
||
]));
|
||
};
|
||
|
||
if ((res.added || []).length > 0) {
|
||
var addRows = [];
|
||
|
||
for (i = 0; i < res.added.length; i++)
|
||
addRows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, res.added[i].key) ]),
|
||
E('td', {}, [ E('code', {}, res.added[i].value) ])
|
||
]));
|
||
|
||
blocks.push(E('div', {}, [
|
||
E('h4', {}, _('新增 %d 条').format(res.added.length)),
|
||
mkTable([ _('参数'), _('值') ], addRows)
|
||
]));
|
||
}
|
||
|
||
if ((res.changed || []).length > 0) {
|
||
var chgRows = [];
|
||
|
||
for (i = 0; i < res.changed.length; i++)
|
||
chgRows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, res.changed[i].key) ]),
|
||
E('td', {}, [ E('code', {}, res.changed[i].from) ]),
|
||
E('td', {}, [ E('code', {}, res.changed[i].to) ])
|
||
]));
|
||
|
||
blocks.push(E('div', {}, [
|
||
E('h4', {}, _('变更 %d 条').format(res.changed.length)),
|
||
mkTable([ _('参数'), _('当前值'), _('新值') ], chgRows)
|
||
]));
|
||
}
|
||
|
||
if ((res.removed || []).length > 0) {
|
||
var rmRows = [];
|
||
|
||
for (i = 0; i < res.removed.length; i++)
|
||
rmRows.push(E('tr', {}, [
|
||
E('td', {}, [ E('code', {}, res.removed[i].key) ]),
|
||
E('td', {}, [ E('code', {}, res.removed[i].old) ])
|
||
]));
|
||
|
||
blocks.push(E('div', {}, [
|
||
E('h4', {}, _('移除 %d 条').format(res.removed.length)),
|
||
mkTable([ _('参数'), _('当前值') ], rmRows)
|
||
]));
|
||
}
|
||
|
||
var applyBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('应用更新'));
|
||
|
||
applyBtn.addEventListener('click', function() { self.presetImport(res.url); });
|
||
|
||
dom.content(this.presetResultBox, [
|
||
E('h3', {}, _('发现在线预设更新')),
|
||
E('p', {}, _('在线版本共 %d 条,本地 %d 条。更新会整体替换 %s。').format(res.remote_count, res.local_count, '/etc/sysctl.d/98-online-preset.conf')),
|
||
E('div', { 'style': 'max-height:380px;overflow:auto' }, blocks),
|
||
E('div', { 'style': 'margin-top:10px' }, [
|
||
applyBtn, ' ',
|
||
E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('取消'))
|
||
])
|
||
]);
|
||
},
|
||
|
||
hidePresetResult: function() {
|
||
this.presetResultBox.style.display = 'none';
|
||
dom.content(this.presetResultBox, []);
|
||
},
|
||
|
||
presetRemove: function() {
|
||
var self = this;
|
||
|
||
return callPresetRemove().then(function(res) {
|
||
if (res == null || res.code != 0) {
|
||
ui.addNotification(null,
|
||
E('p', {}, _('移除失败:%s').format((res != null && res.error) ? res.error : 'unknown error')),
|
||
'error');
|
||
}
|
||
|
||
self.reloadList();
|
||
}).catch(function(e) {
|
||
ui.addNotification(null, E('p', {}, _('移除失败:%s').format(e.message)), 'error');
|
||
});
|
||
},
|
||
|
||
/* ---------- section 3: browse / search ---------- */
|
||
|
||
renderBrowseSection: function() {
|
||
var self = this;
|
||
|
||
this.browseTableBody = E('tbody', {});
|
||
this.crumbs = E('div', { 'style': 'margin:6px 0' });
|
||
this.searchInput = E('input', {
|
||
'type': 'text',
|
||
'class': 'cbi-input-text',
|
||
'style': 'width:280px;display:inline-block',
|
||
'placeholder': _('搜索参数名或值(至少 2 个字符)')
|
||
});
|
||
|
||
this.searchInput.addEventListener('input', function() { self.onSearchInput(); });
|
||
|
||
var searchBtn = E('button', { 'class': 'cbi-button cbi-button-find', 'click': ui.createHandlerFn(this, 'doSearch') }, _('搜索'));
|
||
|
||
var table = E('table', { 'class': 'lsc-table' }, [
|
||
E('thead', {}, E('tr', {}, [
|
||
E('th', {}, _('参数')),
|
||
E('th', {}, _('当前值')),
|
||
E('th', {}, _('操作'))
|
||
])),
|
||
this.browseTableBody
|
||
]);
|
||
|
||
var box = E('div', { 'class': 'cbi-section' }, [
|
||
E('h3', {}, _('浏览内核参数')),
|
||
E('div', { 'class': 'cbi-section-descr' },
|
||
_('浏览 /proc/sys 下的实时参数。点击目录进入子级,也可直接搜索;点击“自定义”可将参数加入上方自定义列表。')),
|
||
E('div', { 'style': 'margin:8px 0' }, [ this.searchInput, ' ', searchBtn ]),
|
||
this.crumbs,
|
||
E('div', { 'class': 'lsc-tablewrap' }, table)
|
||
]);
|
||
|
||
this.browseSectionBox = box;
|
||
|
||
return box;
|
||
},
|
||
|
||
onSearchInput: function() {
|
||
var self = this;
|
||
|
||
if (this.searchTimer != null)
|
||
window.clearTimeout(this.searchTimer);
|
||
|
||
this.searchTimer = window.setTimeout(function() { self.doSearch(); }, 500);
|
||
},
|
||
|
||
doSearch: function() {
|
||
var self = this;
|
||
var q = (this.searchInput.value || '').trim();
|
||
|
||
if (this.searchTimer != null) {
|
||
window.clearTimeout(this.searchTimer);
|
||
this.searchTimer = null;
|
||
}
|
||
|
||
if (q.length < 2) {
|
||
this.browsePrefix = '';
|
||
this.loadBrowse('');
|
||
return;
|
||
}
|
||
|
||
this.browsePrefix = null;
|
||
this.crumbsUpdate();
|
||
|
||
dom.content(this.browseTableBody, E('tr', {},
|
||
E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, _('搜索中…'))));
|
||
|
||
callSearch(q, 500).then(function(res) {
|
||
var items = (res != null && res.items != null) ? res.items : [];
|
||
|
||
self.renderSearchResults(items, (res != null && res.truncated == true), q);
|
||
}).catch(function(e) {
|
||
dom.content(self.browseTableBody, E('tr', {},
|
||
E('td', { 'colspan': 3, 'class': 'alert-message error' }, _('搜索失败:%s').format(e.message))));
|
||
});
|
||
},
|
||
|
||
renderSearchResults: function(items, truncated, q) {
|
||
var rows = [];
|
||
|
||
items.sort(function(a, b) { return (a.key < b.key) ? -1 : (a.key > b.key) ? 1 : 0; });
|
||
|
||
for (var i = 0; i < items.length; i++)
|
||
rows.push(this.renderParamRow(items[i].key, items[i].value));
|
||
|
||
if (rows.length == 0)
|
||
rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' },
|
||
_('没有匹配“%s”的参数。').format(q))));
|
||
|
||
if (truncated)
|
||
rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'color:#e80' },
|
||
_('结果过多已截断,请输入更精确的关键词。'))));
|
||
|
||
dom.content(this.browseTableBody, rows);
|
||
},
|
||
|
||
renderParamRow: function(key, value) {
|
||
var self = this;
|
||
|
||
return E('tr', { 'class': 'cbi-section-table-row' }, [
|
||
E('td', {}, [ E('code', {}, key) ]),
|
||
E('td', {}, [ E('code', {}, value) ]),
|
||
E('td', {}, E('button', {
|
||
'class': 'cbi-button cbi-button-add',
|
||
'click': ui.createHandlerFn(self, 'showEditForm', { isNew: true, key: key, value: value, fromBrowse: true })
|
||
}, _('自定义')))
|
||
]);
|
||
},
|
||
|
||
loadBrowse: function(prefix) {
|
||
var self = this;
|
||
|
||
dom.content(this.browseTableBody, E('tr', {},
|
||
E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, _('加载中…'))));
|
||
|
||
this.crumbsUpdate();
|
||
|
||
callBrowse(prefix).then(function(res) {
|
||
var items = (res != null && res.items != null) ? res.items : [];
|
||
var rows = [];
|
||
|
||
for (var i = 0; i < items.length; i++) {
|
||
var it = items[i];
|
||
|
||
if (it.type == 'group') {
|
||
rows.push(E('tr', { 'class': 'cbi-section-table-row' }, [
|
||
E('td', {}, E('a', {
|
||
'href': '#',
|
||
'click': ui.createHandlerFn(self, 'browseInto', it.prefix)
|
||
}, [ E('strong', {}, it.name + '/') ])),
|
||
E('td', {}, E('span', { 'style': 'color:#999' }, _('目录'))),
|
||
E('td', {}, E('a', {
|
||
'href': '#',
|
||
'style': 'text-decoration:none',
|
||
'click': ui.createHandlerFn(self, 'browseInto', it.prefix)
|
||
}, _('进入')))
|
||
]));
|
||
}
|
||
else {
|
||
rows.push(self.renderParamRow(it.key, it.value));
|
||
}
|
||
}
|
||
|
||
if (rows.length == 0)
|
||
rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' },
|
||
_('(空)'))));
|
||
|
||
dom.content(self.browseTableBody, rows);
|
||
}).catch(function(e) {
|
||
dom.content(self.browseTableBody, E('tr', {},
|
||
E('td', { 'colspan': 3, 'class': 'alert-message error' }, _('加载失败:%s').format(e.message))));
|
||
});
|
||
},
|
||
|
||
browseInto: function(prefix) {
|
||
this.browsePrefix = prefix;
|
||
this.loadBrowse(prefix);
|
||
},
|
||
|
||
crumbsUpdate: function() {
|
||
var self = this;
|
||
var parts = [ E('a', {
|
||
'href': '#',
|
||
'click': ui.createHandlerFn(this, 'browseInto', '')
|
||
}, _('根目录')) ];
|
||
|
||
if (this.browsePrefix == null) {
|
||
parts.push(E('span', { 'style': 'color:#777' }, ' › ' + _('搜索') + ': ' + this.searchInput.value));
|
||
}
|
||
else if (this.browsePrefix != '') {
|
||
var segs = this.browsePrefix.replace(/\.$/, '').split('.');
|
||
var acc = '';
|
||
|
||
for (var i = 0; i < segs.length; i++) {
|
||
acc += segs[i] + '.';
|
||
|
||
parts.push(E('span', { 'style': 'color:#777' }, ' › '));
|
||
parts.push(E('a', {
|
||
'href': '#',
|
||
'click': ui.createHandlerFn(this, 'browseInto', acc)
|
||
}, segs[i]));
|
||
}
|
||
|
||
/* one-click step back to the parent directory */
|
||
var parentPrefix = (segs.length > 1)
|
||
? segs.slice(0, segs.length - 1).join('.') + '.'
|
||
: '';
|
||
|
||
parts.push(E('span', { 'style': 'color:#777' }, ' '));
|
||
parts.push(E('a', {
|
||
'href': '#',
|
||
'style': 'margin-left:12px',
|
||
'click': ui.createHandlerFn(this, 'browseInto', parentPrefix)
|
||
}, _('← 返回上一级')));
|
||
}
|
||
|
||
dom.content(this.crumbs, parts);
|
||
}
|
||
});
|
||
|