feat: add auto-update system with release checking, notification UI, and install-type-aware apply
- Abstract ReleaseProvider interface (Gitea impl, swappable for GitHub/GitLab) - Background UpdateService with periodic checks, debounce, dismissed version persistence - Install type detection (installer/portable/docker/dev) with platform-aware asset matching - Download with progress events, silent NSIS reinstall, portable ZIP/tarball swap scripts - Version badge pulse animation, dismissible banner with icon buttons, Settings > Updates tab - Single source of truth: pyproject.toml version via importlib.metadata, CI stamps tag with sed - API: GET/POST status, check, dismiss, apply, GET/PUT settings - i18n: en, ru, zh (27+ keys each)
This commit is contained in:
@@ -109,6 +109,58 @@ h2 {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
#server-version.has-update {
|
||||
background: var(--warning-color);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
animation: updatePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes updatePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); }
|
||||
}
|
||||
|
||||
/* ── Update banner ── */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
animation: bannerSlideDown 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.update-banner-text {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.update-banner-action {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.update-banner-action:hover {
|
||||
color: var(--primary-color);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@keyframes bannerSlideDown {
|
||||
from { transform: translateY(-100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
|
||||
@@ -199,6 +199,11 @@ import {
|
||||
loadLogLevel, setLogLevel,
|
||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
} from './features/settings.ts';
|
||||
import {
|
||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||
loadUpdateSettings, saveUpdateSettings, dismissUpdate,
|
||||
initUpdateSettingsPanel, applyUpdate,
|
||||
} from './features/update.ts';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
|
||||
@@ -560,6 +565,14 @@ Object.assign(window, {
|
||||
saveExternalUrl,
|
||||
getBaseOrigin,
|
||||
|
||||
// update
|
||||
checkForUpdates,
|
||||
loadUpdateSettings,
|
||||
saveUpdateSettings,
|
||||
dismissUpdate,
|
||||
initUpdateSettingsPanel,
|
||||
applyUpdate,
|
||||
|
||||
// appearance
|
||||
applyStylePreset,
|
||||
applyBgEffect,
|
||||
@@ -703,6 +716,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
startEntityEventListeners();
|
||||
startAutoRefresh();
|
||||
|
||||
// Initialize update checker (banner + WS listener)
|
||||
initUpdateListener();
|
||||
loadUpdateStatus();
|
||||
|
||||
// Show getting-started tutorial on first visit
|
||||
if (!localStorage.getItem('tour_completed')) {
|
||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||
|
||||
@@ -81,3 +81,5 @@ export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2
|
||||
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
|
||||
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
|
||||
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
|
||||
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
|
||||
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
|
||||
|
||||
@@ -183,3 +183,5 @@ export const ICON_HEADPHONES = _svg(P.headphones);
|
||||
export const ICON_TRASH = _svg(P.trash2);
|
||||
export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
||||
export const ICON_X = _svg(P.xIcon);
|
||||
|
||||
@@ -76,6 +76,11 @@ export function switchSettingsTab(tabId: string): void {
|
||||
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
|
||||
window.renderAppearanceTab();
|
||||
}
|
||||
// Lazy-load update settings
|
||||
if (tabId === 'updates' && typeof (window as any).loadUpdateSettings === 'function') {
|
||||
(window as any).initUpdateSettingsPanel();
|
||||
(window as any).loadUpdateSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Auto-update — check for new releases, show banner, manage settings.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────
|
||||
|
||||
interface UpdateRelease {
|
||||
version: string;
|
||||
tag: string;
|
||||
name: string;
|
||||
body: string;
|
||||
prerelease: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface UpdateStatus {
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
checking: boolean;
|
||||
last_check: number | null;
|
||||
last_error: string | null;
|
||||
releases_url: string;
|
||||
install_type: string;
|
||||
can_auto_update: boolean;
|
||||
downloading: boolean;
|
||||
download_progress: number;
|
||||
applying: boolean;
|
||||
release: UpdateRelease | null;
|
||||
dismissed_version: string;
|
||||
}
|
||||
|
||||
let _lastStatus: UpdateStatus | null = null;
|
||||
|
||||
// ─── Version badge highlight ────────────────────────────────
|
||||
|
||||
function _setVersionBadgeUpdate(hasUpdate: boolean): void {
|
||||
const badge = document.getElementById('server-version');
|
||||
if (!badge) return;
|
||||
badge.classList.toggle('has-update', hasUpdate);
|
||||
|
||||
if (hasUpdate) {
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.title = t('update.badge_tooltip');
|
||||
badge.onclick = () => switchSettingsTabToUpdate();
|
||||
} else {
|
||||
badge.style.cursor = '';
|
||||
badge.title = '';
|
||||
badge.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
function switchSettingsTabToUpdate(): void {
|
||||
if (typeof (window as any).openSettingsModal === 'function') {
|
||||
(window as any).openSettingsModal();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (typeof (window as any).switchSettingsTab === 'function') {
|
||||
(window as any).switchSettingsTab('updates');
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// ─── Update banner ──────────────────────────────────────────
|
||||
|
||||
function _showBanner(status: UpdateStatus): void {
|
||||
const release = status.release;
|
||||
if (!release) return;
|
||||
|
||||
const dismissed = localStorage.getItem('update-dismissed-version');
|
||||
if (dismissed === release.version) return;
|
||||
|
||||
const banner = document.getElementById('update-banner');
|
||||
if (!banner) return;
|
||||
|
||||
const versionLabel = release.prerelease
|
||||
? `${release.version} (${t('update.prerelease')})`
|
||||
: release.version;
|
||||
|
||||
let actions = '';
|
||||
|
||||
// "Update Now" button if auto-update is supported
|
||||
if (status.can_auto_update) {
|
||||
actions += `<button class="btn btn-icon update-banner-action update-banner-apply" onclick="applyUpdate()" title="${t('update.apply_now')}">
|
||||
${ICON_DOWNLOAD}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
actions += `<a href="${status.releases_url}" target="_blank" rel="noopener" class="btn btn-icon update-banner-action" title="${t('update.view_release')}">
|
||||
${ICON_EXTERNAL_LINK}
|
||||
</a>`;
|
||||
|
||||
actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
|
||||
${ICON_X}
|
||||
</button>`;
|
||||
|
||||
banner.innerHTML = `
|
||||
<span class="update-banner-text">
|
||||
${t('update.available').replace('{version}', versionLabel)}
|
||||
</span>
|
||||
${actions}
|
||||
`;
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
|
||||
function _hideBanner(): void {
|
||||
const banner = document.getElementById('update-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
}
|
||||
|
||||
export function dismissUpdate(): void {
|
||||
if (!_lastStatus?.release) return;
|
||||
const version = _lastStatus.release.version;
|
||||
localStorage.setItem('update-dismissed-version', version);
|
||||
_hideBanner();
|
||||
_setVersionBadgeUpdate(false);
|
||||
|
||||
fetchWithAuth('/system/update/dismiss', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── Apply update ───────────────────────────────────────────
|
||||
|
||||
export async function applyUpdate(): Promise<void> {
|
||||
if (!_lastStatus?.release) return;
|
||||
|
||||
const version = _lastStatus.release.version;
|
||||
const confirmed = await showConfirm(
|
||||
t('update.apply_confirm').replace('{version}', version)
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
// Disable the apply button
|
||||
const btns = document.querySelectorAll('.update-banner-apply, #update-apply-btn');
|
||||
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/apply', {
|
||||
method: 'POST',
|
||||
timeout: 600000, // 10 min for download + apply
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
// Server will shut down — the frontend reconnect overlay handles the rest
|
||||
showToast(t('update.applying'), 'info');
|
||||
} catch (err) {
|
||||
showToast(t('update.apply_error') + ': ' + (err as Error).message, 'error');
|
||||
btns.forEach(b => (b as HTMLButtonElement).disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status fetch (called on page load + WS event) ─────────
|
||||
|
||||
export async function loadUpdateStatus(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/status');
|
||||
if (!resp.ok) return;
|
||||
const status: UpdateStatus = await resp.json();
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
} catch {
|
||||
// silent — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
function _applyStatus(status: UpdateStatus): void {
|
||||
const dismissed = localStorage.getItem('update-dismissed-version');
|
||||
const hasVisibleUpdate = status.has_update
|
||||
&& status.release != null
|
||||
&& status.release.version !== dismissed;
|
||||
|
||||
_setVersionBadgeUpdate(hasVisibleUpdate);
|
||||
|
||||
if (hasVisibleUpdate) {
|
||||
_showBanner(status);
|
||||
} else {
|
||||
_hideBanner();
|
||||
}
|
||||
|
||||
_renderUpdatePanel(status);
|
||||
}
|
||||
|
||||
// ─── WS event handlers ─────────────────────────────────────
|
||||
|
||||
export function initUpdateListener(): void {
|
||||
document.addEventListener('server:update_available', ((e: CustomEvent) => {
|
||||
const data = e.detail;
|
||||
if (data && data.version && !data.dismissed) {
|
||||
loadUpdateStatus();
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
// Download progress
|
||||
document.addEventListener('server:update_download_progress', ((e: CustomEvent) => {
|
||||
const progress = e.detail?.progress;
|
||||
if (typeof progress === 'number') {
|
||||
_updateProgressBar(progress);
|
||||
}
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
function _updateProgressBar(progress: number): void {
|
||||
const bar = document.getElementById('update-progress-bar');
|
||||
if (bar) {
|
||||
bar.style.width = `${Math.round(progress * 100)}%`;
|
||||
bar.parentElement!.style.display = progress > 0 && progress < 1 ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual check ──────────────────────────────────────────
|
||||
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
const btn = document.getElementById('update-check-btn') as HTMLButtonElement | null;
|
||||
const spinner = document.getElementById('update-check-spinner');
|
||||
if (btn) btn.disabled = true;
|
||||
if (spinner) spinner.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const status: UpdateStatus = await resp.json();
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
|
||||
if (status.has_update && status.release) {
|
||||
showToast(t('update.available').replace('{version}', status.release.version), 'info');
|
||||
} else {
|
||||
showToast(t('update.up_to_date'), 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t('update.check_error') + ': ' + (err as Error).message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings panel ────────────────────────────────────────
|
||||
|
||||
let _channelIconSelect: IconSelect | null = null;
|
||||
|
||||
function _getChannelItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||
return [
|
||||
{
|
||||
value: 'false',
|
||||
icon: '<span style="color:#4CAF50;font-weight:700">S</span>',
|
||||
label: t('update.channel.stable'),
|
||||
desc: t('update.channel.stable_desc'),
|
||||
},
|
||||
{
|
||||
value: 'true',
|
||||
icon: '<span style="color:#ff9800;font-weight:700">P</span>',
|
||||
label: t('update.channel.prerelease'),
|
||||
desc: t('update.channel.prerelease_desc'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function initUpdateSettingsPanel(): void {
|
||||
if (!_channelIconSelect) {
|
||||
const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
|
||||
if (sel) {
|
||||
_channelIconSelect = new IconSelect({
|
||||
target: sel,
|
||||
items: _getChannelItems(),
|
||||
columns: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUpdateSettings(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
||||
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
|
||||
const channelEl = document.getElementById('update-channel') as HTMLSelectElement | null;
|
||||
|
||||
if (enabledEl) enabledEl.checked = data.enabled;
|
||||
if (intervalEl) intervalEl.value = String(data.check_interval_hours);
|
||||
if (_channelIconSelect) {
|
||||
_channelIconSelect.setValue(String(data.include_prerelease));
|
||||
} else if (channelEl) {
|
||||
channelEl.value = String(data.include_prerelease);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load update settings:', err);
|
||||
}
|
||||
|
||||
await loadUpdateStatus();
|
||||
}
|
||||
|
||||
export async function saveUpdateSettings(): Promise<void> {
|
||||
const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true;
|
||||
const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24';
|
||||
const check_interval_hours = parseFloat(intervalStr);
|
||||
const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false';
|
||||
const include_prerelease = channelVal === 'true';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('update.settings_saved'), 'success');
|
||||
} catch (err) {
|
||||
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _renderUpdatePanel(status: UpdateStatus): void {
|
||||
const currentEl = document.getElementById('update-current-version');
|
||||
if (currentEl) currentEl.textContent = `v${status.current_version}`;
|
||||
|
||||
const statusEl = document.getElementById('update-status-text');
|
||||
if (statusEl) {
|
||||
if (status.has_update && status.release) {
|
||||
statusEl.textContent = t('update.available').replace('{version}', status.release.version);
|
||||
statusEl.style.color = 'var(--warning-color)';
|
||||
} else if (status.last_error) {
|
||||
statusEl.textContent = t('update.check_error') + ': ' + status.last_error;
|
||||
statusEl.style.color = 'var(--danger-color)';
|
||||
} else {
|
||||
statusEl.textContent = t('update.up_to_date');
|
||||
statusEl.style.color = 'var(--primary-color)';
|
||||
}
|
||||
}
|
||||
|
||||
const lastCheckEl = document.getElementById('update-last-check');
|
||||
if (lastCheckEl) {
|
||||
if (status.last_check) {
|
||||
lastCheckEl.textContent = t('update.last_check') + ': ' + new Date(status.last_check * 1000).toLocaleString();
|
||||
} else {
|
||||
lastCheckEl.textContent = t('update.last_check') + ': ' + t('update.never');
|
||||
}
|
||||
}
|
||||
|
||||
// Install type info
|
||||
const installEl = document.getElementById('update-install-type');
|
||||
if (installEl) {
|
||||
installEl.textContent = t(`update.install_type.${status.install_type}`);
|
||||
}
|
||||
|
||||
// "Update Now" button in settings panel
|
||||
const applyBtn = document.getElementById('update-apply-btn') as HTMLButtonElement | null;
|
||||
if (applyBtn) {
|
||||
const show = status.has_update && status.can_auto_update;
|
||||
applyBtn.style.display = show ? '' : 'none';
|
||||
applyBtn.disabled = status.downloading || status.applying;
|
||||
if (status.downloading) {
|
||||
applyBtn.textContent = `${t('update.downloading')} ${Math.round(status.download_progress * 100)}%`;
|
||||
} else if (status.applying) {
|
||||
applyBtn.textContent = t('update.applying');
|
||||
} else {
|
||||
applyBtn.textContent = t('update.apply_now');
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
const progressBar = document.getElementById('update-progress-bar');
|
||||
if (progressBar) {
|
||||
const show = status.downloading && status.download_progress > 0 && status.download_progress < 1;
|
||||
progressBar.style.width = `${Math.round(status.download_progress * 100)}%`;
|
||||
progressBar.parentElement!.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
// Release notes preview
|
||||
const notesEl = document.getElementById('update-release-notes');
|
||||
if (notesEl) {
|
||||
if (status.has_update && status.release && status.release.body) {
|
||||
const truncated = status.release.body.length > 500
|
||||
? status.release.body.slice(0, 500) + '...'
|
||||
: status.release.body;
|
||||
notesEl.textContent = truncated;
|
||||
notesEl.parentElement!.style.display = '';
|
||||
} else {
|
||||
notesEl.textContent = '';
|
||||
notesEl.parentElement!.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1917,6 +1917,43 @@
|
||||
"appearance.bg.scanlines": "Scanlines",
|
||||
"appearance.bg.applied": "Background effect applied",
|
||||
|
||||
"settings.tab.updates": "Updates",
|
||||
"update.status_label": "Update Status",
|
||||
"update.current_version": "Current version:",
|
||||
"update.badge_tooltip": "New version available — click for details",
|
||||
"update.available": "Version {version} is available",
|
||||
"update.up_to_date": "You are running the latest version",
|
||||
"update.prerelease": "pre-release",
|
||||
"update.view_release": "View Release",
|
||||
"update.dismiss": "Dismiss",
|
||||
"update.check_now": "Check for Updates",
|
||||
"update.check_error": "Update check failed",
|
||||
"update.last_check": "Last check",
|
||||
"update.never": "never",
|
||||
"update.release_notes": "Release Notes",
|
||||
"update.auto_check_label": "Auto-Check Settings",
|
||||
"update.auto_check_hint": "Periodically check for new releases in the background.",
|
||||
"update.enable": "Enable auto-check",
|
||||
"update.interval_label": "Check interval",
|
||||
"update.channel_label": "Channel",
|
||||
"update.channel.stable": "Stable",
|
||||
"update.channel.stable_desc": "Stable releases only",
|
||||
"update.channel.prerelease": "Pre-release",
|
||||
"update.channel.prerelease_desc": "Include alpha, beta, and RC builds",
|
||||
"update.save_settings": "Save Settings",
|
||||
"update.settings_saved": "Update settings saved",
|
||||
"update.settings_save_error": "Failed to save update settings",
|
||||
"update.apply_now": "Update Now",
|
||||
"update.apply_confirm": "Download and install version {version}? The server will restart automatically.",
|
||||
"update.apply_error": "Update failed",
|
||||
"update.applying": "Applying update…",
|
||||
"update.downloading": "Downloading…",
|
||||
"update.install_type_label": "Install type:",
|
||||
"update.install_type.installer": "Windows installer",
|
||||
"update.install_type.portable": "Portable",
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "Development",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Search notification apps…"
|
||||
|
||||
@@ -1846,6 +1846,43 @@
|
||||
"appearance.bg.scanlines": "Развёртка",
|
||||
"appearance.bg.applied": "Фоновый эффект применён",
|
||||
|
||||
"settings.tab.updates": "Обновления",
|
||||
"update.status_label": "Статус обновления",
|
||||
"update.current_version": "Текущая версия:",
|
||||
"update.badge_tooltip": "Доступна новая версия — нажмите для подробностей",
|
||||
"update.available": "Доступна версия {version}",
|
||||
"update.up_to_date": "Установлена последняя версия",
|
||||
"update.prerelease": "пре-релиз",
|
||||
"update.view_release": "Подробнее",
|
||||
"update.dismiss": "Скрыть",
|
||||
"update.check_now": "Проверить обновления",
|
||||
"update.check_error": "Ошибка проверки обновлений",
|
||||
"update.last_check": "Последняя проверка",
|
||||
"update.never": "никогда",
|
||||
"update.release_notes": "Примечания к релизу",
|
||||
"update.auto_check_label": "Автоматическая проверка",
|
||||
"update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.",
|
||||
"update.enable": "Включить автопроверку",
|
||||
"update.interval_label": "Интервал проверки",
|
||||
"update.channel_label": "Канал",
|
||||
"update.channel.stable": "Стабильный",
|
||||
"update.channel.stable_desc": "Только стабильные релизы",
|
||||
"update.channel.prerelease": "Пре-релиз",
|
||||
"update.channel.prerelease_desc": "Включая альфа, бета и RC сборки",
|
||||
"update.save_settings": "Сохранить настройки",
|
||||
"update.settings_saved": "Настройки обновлений сохранены",
|
||||
"update.settings_save_error": "Не удалось сохранить настройки обновлений",
|
||||
"update.apply_now": "Обновить сейчас",
|
||||
"update.apply_confirm": "Скачать и установить версию {version}? Сервер будет перезапущен автоматически.",
|
||||
"update.apply_error": "Ошибка обновления",
|
||||
"update.applying": "Применяется обновление…",
|
||||
"update.downloading": "Загрузка…",
|
||||
"update.install_type_label": "Тип установки:",
|
||||
"update.install_type.installer": "Установщик Windows",
|
||||
"update.install_type.portable": "Портативная",
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "Разработка",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Поиск приложений…"
|
||||
|
||||
@@ -1844,6 +1844,43 @@
|
||||
"appearance.bg.scanlines": "扫描线",
|
||||
"appearance.bg.applied": "背景效果已应用",
|
||||
|
||||
"settings.tab.updates": "更新",
|
||||
"update.status_label": "更新状态",
|
||||
"update.current_version": "当前版本:",
|
||||
"update.badge_tooltip": "有新版本可用 — 点击查看详情",
|
||||
"update.available": "版本 {version} 可用",
|
||||
"update.up_to_date": "已是最新版本",
|
||||
"update.prerelease": "预发布",
|
||||
"update.view_release": "查看发布",
|
||||
"update.dismiss": "忽略",
|
||||
"update.check_now": "检查更新",
|
||||
"update.check_error": "检查更新失败",
|
||||
"update.last_check": "上次检查",
|
||||
"update.never": "从未",
|
||||
"update.release_notes": "发布说明",
|
||||
"update.auto_check_label": "自动检查设置",
|
||||
"update.auto_check_hint": "在后台定期检查新版本。",
|
||||
"update.enable": "启用自动检查",
|
||||
"update.interval_label": "检查间隔",
|
||||
"update.channel_label": "频道",
|
||||
"update.channel.stable": "稳定版",
|
||||
"update.channel.stable_desc": "仅稳定版本",
|
||||
"update.channel.prerelease": "预发布",
|
||||
"update.channel.prerelease_desc": "包括 alpha、beta 和 RC 版本",
|
||||
"update.save_settings": "保存设置",
|
||||
"update.settings_saved": "更新设置已保存",
|
||||
"update.settings_save_error": "保存更新设置失败",
|
||||
"update.apply_now": "立即更新",
|
||||
"update.apply_confirm": "下载并安装版本 {version}?服务器将自动重启。",
|
||||
"update.apply_error": "更新失败",
|
||||
"update.applying": "正在应用更新…",
|
||||
"update.downloading": "正在下载…",
|
||||
"update.install_type_label": "安装类型:",
|
||||
"update.install_type.installer": "Windows 安装程序",
|
||||
"update.install_type.portable": "便携版",
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "开发环境",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "搜索通知应用…"
|
||||
|
||||
Reference in New Issue
Block a user