update 2026-11-07 22:36:55

This commit is contained in:
action committed 2026-11-07 22:36:55 +08:00
1 parent dad27f3210
commit ec19855d48
25 files changed
+1388 -445

No files matched your search

@@ -382,7 +382,6 @@ return view.extend({
const restoreCfgBtn = E('button', { 'class': 'cbi-button cbi-button-negative', 'type': 'button' }, _('Restore config'));
/* 忘记密码用:只清管理员记录,不动订阅和规则 */
const resetAdminBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Reset administrator'));
const fileInput = E('input', { 'type': 'file', 'accept': '.tar.gz,.gz,application/gzip', 'style': 'display:none' });
let backupBusy = false;
writeControls.push(exportBtn, importBtn, restoreCfgBtn, resetAdminBtn);
@@ -419,18 +418,12 @@ return view.extend({
}).finally(function() { setBackupBusy(false); });
});
/* 只让用户选一次文件:上传对话框自己会弹选择框。 */
importBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (backupBusy) return;
fileInput.click();
});
fileInput.addEventListener('change', function(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
if (!confirm(_('Stop Honk before restoring. Restoring replaces Honk user data. Continue?'))) {
fileInput.value = '';
if (!confirm(_('Stop Honk before restoring. Restoring replaces Honk user data. Continue?')))
return;
}
setBackupBusy(true);
backupMessage.textContent = _('Restoring…');
honk.restorePrepare().then(honk.ensureOk).then(function() {
@@ -449,7 +442,6 @@ return view.extend({
backupMessage.textContent = honk.errorMessage(error, _('Restore failed.'));
}).finally(function() {
setBackupBusy(false);
fileInput.value = '';
});
});
@@ -493,7 +485,6 @@ return view.extend({
backupSection.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Config backup')));
backupSection.appendChild(E('p', { 'class': 'honk-note' }, _('Export a backup archive, import one from your computer, or restore Honk to its default configuration.')));
backupSection.appendChild(E('div', { 'class': 'honk-actions' }, [ exportBtn, importBtn, restoreCfgBtn, resetAdminBtn ]));
backupSection.appendChild(fileInput);
backupSection.appendChild(backupMessage);
/* --- System check and repair card --- */
@@ -31,13 +31,14 @@ function metaItem(label, value) {
]);
}
function field(label, input, help) {
function field(label, control, help) {
/* 说明文字放进控件列下方,两栏布局时才不会挤到右边去 */
const cell = E('div', { 'class': 'cbi-value-field' }, [ input ]);
const controlId = control.id || (control.querySelector && control.querySelector('input, select, textarea') || {}).id;
const cell = E('div', { 'class': 'cbi-value-field' }, [ control ]);
if (help)
cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help));
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
E('label', { 'class': 'cbi-value-title', 'for': controlId || null }, label),
cell
]);
}
@@ -68,6 +69,9 @@ function restoreFields(root, values) {
input.value = values[key].value;
input.checked = values[key].checked;
/* 自绘开关听的是 change,回填不会自己触发。 */
if (input.classList.contains('honk-switch-input'))
input.dispatchEvent(new Event('change'));
if (values[key].focused) {
input.focus();
if (values[key].selectionStart !== null && typeof input.setSelectionRange === 'function')
@@ -212,7 +216,8 @@ return view.extend({
const form = E('form');
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashSwitch = honk.switchControl(clashEnabledInput);
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
@@ -227,7 +232,7 @@ return view.extend({
if (status.lan_network)
networkSelect.value = status.lan_network;
portInput.value = status.listen_port ? String(status.listen_port) : '9527';
clashEnabledInput.checked = status.clash_api_enabled === true;
clashEnabledInput.checked = status.clash_api_enabled === true;
clashPortInput.value = status.clash_api_port ? String(status.clash_api_port) : '9090';
card.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Service settings')));
@@ -238,12 +243,13 @@ return view.extend({
input.disabled = true;
});
save.disabled = true;
clashSwitch.syncSwitch();
}
if (!(data.networks || []).length)
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
form.appendChild(field(_('LAN network'), networkSelect));
form.appendChild(field(_('Listen port'), portInput));
form.appendChild(field(_('Enable Clash API'), clashEnabledInput));
form.appendChild(field(_('Enable Clash API'), clashSwitch));
form.appendChild(field(_('Clash API port'), clashPortInput));
form.appendChild(field(_('Clash API secret'), clashSecretInput, status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it.') : _('Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
form.appendChild(E('p', { 'class': 'honk-note' }, _('Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting.')));
@@ -92,6 +92,73 @@ const callUpdateCheck = rpc.declare({ object: 'honk', method: 'update_check', ex
const callUpdateApply = rpc.declare({ object: 'honk', method: 'update_apply', expect: { '': {} } });
let pageStylesInstalled = false;
let switchStylesInstalled = false;
/* 开关样式随 JS 注入:CSS 与 JS 分开缓存,主题又各改各的 checkbox/button。 */
const SWITCH_CSS = [
'.honk-page .honk-switch-wrap{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;vertical-align:middle;line-height:1}',
'.honk-page .honk-switch-label{font-size:11px;font-weight:600;letter-spacing:.3px;opacity:.62}',
'.honk-page .honk-switch{position:relative!important;flex:0 0 auto;width:42px!important;height:22px!important;min-width:0!important;min-height:0!important;margin:0!important;padding:0!important;border:0!important;border-radius:999px!important;background:rgba(128,128,128,.28)!important;background-image:none!important;box-shadow:none!important;line-height:1!important;text-transform:none!important;transform:none!important;overflow:hidden;cursor:pointer;transition:background .18s ease,opacity .18s ease}',
'.honk-page .honk-switch .honk-switch-knob{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.96);box-shadow:0 1px 4px rgba(0,0,0,.2);transition:left .18s ease}',
'.honk-page .honk-switch.is-on{background:rgba(74,160,101,.65)!important}',
'.honk-page .honk-switch:disabled{opacity:.45;cursor:not-allowed}',
'.honk-page .honk-switch:focus-visible{outline:2px solid rgba(74,160,101,.75);outline-offset:2px}',
/* 0,3,1 才压得过 honk.css 里那颗 46px !important 的 checkbox。 */
'.honk-page .honk-switch-wrap>input.honk-switch-input{position:absolute!important;width:1px!important;height:1px!important;margin:0!important;padding:0!important;border:0!important;opacity:0!important;pointer-events:none!important;appearance:none!important;-webkit-appearance:none!important;box-shadow:none!important;background:none!important}'
].join('\n');
function installSwitchStyles() {
if (switchStylesInstalled || document.getElementById('honk-switch-styles'))
return;
const style = document.createElement('style');
style.id = 'honk-switch-styles';
style.appendChild(document.createTextNode(SWITCH_CSS));
document.head.appendChild(style);
switchStylesInstalled = true;
}
/* 主题对 input[type=checkbox] 的处理各不相同(Argon 会放大成错位的胶囊,
Bootstrap 干脆是原生小方框),所以开关自己画:真 input 留在表单里保证
取值/回填逻辑不变,外面套一层可点的胶囊和 ON/OFF 文字。 */
function switchControl(input) {
installSwitchStyles();
input.classList.add('honk-switch-input');
input.setAttribute('tabindex', '-1');
const caption = E('span', { 'class': 'honk-switch-label' }, '');
const knob = E('span', { 'class': 'honk-switch-knob' });
const toggle = E('button', {
'class': 'honk-switch',
'type': 'button',
'role': 'switch',
'aria-checked': input.checked ? 'true' : 'false'
}, knob);
const sync = function() {
toggle.classList.toggle('is-on', !!input.checked);
toggle.setAttribute('aria-checked', input.checked ? 'true' : 'false');
toggle.disabled = !!input.disabled;
caption.textContent = input.checked ? 'ON' : 'OFF';
knob.style.left = input.checked ? '23px' : '3px';
};
toggle.addEventListener('click', function(ev) {
ev.preventDefault();
if (input.disabled)
return;
input.checked = !input.checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
sync();
});
input.addEventListener('change', sync);
/* 表单回填(restoreFields)直接改 input.checked,不会触发 change。 */
const wrapper = E('span', { 'class': 'honk-switch-wrap' }, [ input, caption, toggle ]);
wrapper.syncSwitch = sync;
sync();
return wrapper;
}
function installStyles() {
if (pageStylesInstalled || document.getElementById('honk-page-styles'))
@@ -100,7 +167,7 @@ function installStyles() {
const stylesheet = document.createElement('link');
stylesheet.id = 'honk-page-styles';
stylesheet.rel = 'stylesheet';
stylesheet.href = L.resource('view/honk/honk.css');
stylesheet.href = L.resource('view/honk/honk.css') + (L.env.resource_version ? '?v=' + L.env.resource_version : '');
document.head.appendChild(stylesheet);
pageStylesInstalled = true;
}
@@ -205,6 +272,16 @@ function tokenMessage(token) {
data_reset: _('Honk data reset. The service is stopped.'),
worker_lost: _('The maintenance worker stopped unexpectedly; check system recovery status before retrying.'),
operation_failed: _('The maintenance operation failed.'),
maintenance_no_result: _('The maintenance script stopped without reporting a result. Check Maintenance → Logs → Maintenance for the reason.'),
honk_tree_missing: _('The /etc/honk directory is missing or is a symlink.'),
honk_tree_has_symlink: _('There is a symlink under /etc/honk. The backup cannot carry it; remove it and try again.'),
honk_tree_has_special_file: _('There is a non-regular file (device, socket, fifo) under /etc/honk. Remove it and try again.'),
honk_tree_unreadable: _('A file under /etc/honk could not be read. Check the filesystem and permissions.'),
honk_backup_file_too_large: _('A single file under /etc/honk is larger than 64 MB. The service log names it.'),
honk_backup_tree_too_large: _('The backup would exceed 512 files or 127 MB. The service log names the entry that crossed the limit.'),
honk_config_incomplete: _('config.dae or system.dae is missing under /etc/honk. Reinstall or repair Honk before backing up.'),
unsupported_honk_tree_entry: _('The backup found an entry under /etc/honk it cannot archive (a symlink, a special file, or too many files).'),
unsafe_honk_file_name: _('A file under /etc/honk has a name the backup cannot carry (spaces, wildcards or "../"). The service log names it; rename it and try again.'),
job_timeout: _('The maintenance operation timed out.'),
maintenance_recovery_required: _('Recovery is required. Run system recovery before starting another maintenance operation.'),
update_recovery_required: _('The signed update did not finish. Keep Honk stopped and reinstall a complete verified package set before retrying.'),
@@ -502,5 +579,6 @@ return baseclass.extend({
resultMessage: resultMessage,
errorMessage: errorMessage,
jobPhaseMessage: jobPhaseMessage,
switchControl: switchControl,
installStyles: installStyles
});
@@ -21,10 +21,14 @@ function getNetworkNames(networks) {
}).sort();
}
function field(label, input) {
function field(label, control, help) {
const controlId = control.id || (control.querySelector && control.querySelector('input, select, textarea') || {}).id;
const cell = E('div', { 'class': 'cbi-value-field' }, [ control ]);
if (help)
cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help));
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
E('div', { 'class': 'cbi-value-field' }, input)
E('label', { 'class': 'cbi-value-title', 'for': controlId || null }, label),
cell
]);
}
@@ -48,7 +52,8 @@ return view.extend({
const section = E('form', { 'class': 'honk-card' });
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashSwitch = honk.switchControl(clashEnabledInput);
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
@@ -64,7 +69,8 @@ return view.extend({
if (data.status && data.status.lan_network)
networkSelect.value = data.status.lan_network;
portInput.value = data.status && data.status.listen_port ? String(data.status.listen_port) : '9527';
clashEnabledInput.checked = !!(data.status && data.status.clash_api_enabled === true);
clashEnabledInput.checked = !!(data.status && data.status.clash_api_enabled === true);
clashSwitch.syncSwitch();
clashPortInput.value = data.status && data.status.clash_api_port ? String(data.status.clash_api_port) : '9090';
section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Honk settings')));
@@ -76,6 +82,7 @@ return view.extend({
input.disabled = true;
});
save.disabled = true;
clashSwitch.syncSwitch();
}
if (data.statusError)
section.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read current settings: %s').format(data.statusError)));
@@ -85,7 +92,7 @@ return view.extend({
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
section.appendChild(field(_('LAN network'), networkSelect));
section.appendChild(field(_('Listen port'), portInput));
section.appendChild(field(_('Enable Clash API'), clashEnabledInput));
section.appendChild(field(_('Enable Clash API'), clashSwitch));
section.appendChild(field(_('Clash API port'), clashPortInput));
section.appendChild(field(_('Clash API secret'), clashSecretInput));
section.appendChild(E('p', { 'class': 'honk-note' }, data.status && data.status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access.') : _('A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens.')));