Files
EasyCLIProxyAPI/src/pages/VersionManagementPage.tsx
T
lzt404 bac67c1524 feat: display multilingual release notes in version management
- Support Simplified Chinese, Traditional Chinese, English, and Japanese
- Follow the UI language and show a notice when notes are unavailable
- Avoid language and content fallbacks
- Generate Release bodies and update manifests from per-version Markdown
- Add collapsible notes, publication dates, and links to full releases
- Test publishing, legacy manifest compatibility, and localized rendering
2026-09-19 20:42:30 +08:00

936 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
Download,
ExternalLink,
Info,
RefreshCw,
RotateCcw,
Trash2,
} from 'lucide-react';
import { useCoreRuntime } from '../coreRuntime';
import { useCoreUpdate } from '../coreUpdate';
import { useI18n } from '../i18n';
import { useAppUpdate } from '../appUpdate';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { createVersionManagementVisitTracker } from '../services/versionManagementVisits';
import { AppReleaseNotes } from '../components/AppReleaseNotes';
export type CoreInstallResult = {
version: string;
assetName: string;
installDir: string;
binaryPath: string | null;
};
export type CoreInstallTask = {
running: boolean;
cancellable: boolean;
phase: string;
downloaded: number;
total: number | null;
percent: number | null;
message: string | null;
result: CoreInstallResult | null;
};
export type VersionSourceSettings = {
source: VersionDownloadSource;
gitcodeAvailable: boolean;
customMirrors: string[];
};
export type VersionDownloadSource = string;
function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType<typeof useI18n>['t']) {
const keys: Record<string, Parameters<typeof t>[0]> = {
github: 'kernel.versions.source.github',
gitcode: 'kernel.versions.source.gitcode',
'gh-proxy': 'kernel.versions.source.ghProxy',
'gh-fast': 'kernel.versions.source.ghFast',
};
if (source.startsWith('custom:')) {
const url = source.slice('custom:'.length);
try {
return `${t('kernel.versions.source.custom')} · ${new URL(url).host}`;
} catch {
return t('kernel.versions.source.custom');
}
}
return t(keys[source] ?? 'kernel.versions.source.github');
}
export type MessageType = 'info' | 'success' | 'error';
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
export const DEFAULT_VERSION_DOWNLOAD_SOURCE = 'github';
const recordVersionManagementVisit = createVersionManagementVisitTracker();
export function displayAppVersion(version: string) {
const resolvedVersion = version.trim();
return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`;
}
export function VersionManagementPage() {
const { t } = useI18n();
const {
info: appUpdate,
error: appUpdateError,
checking: checkingAppUpdate,
task: appUpdateTask,
check: checkAppUpdate,
requestInstall: requestAppUpdate,
} = useAppUpdate();
const {
status: coreStatus,
refreshStatus,
} = useCoreRuntime();
const {
latest,
error: latestError,
checking: checkingLatest,
hasUpdate: coreHasUpdate,
check: checkLatest,
reset: resetLatest,
} = useCoreUpdate();
const [installedAppVersion, setInstalledAppVersion] = useState('');
const [installing, setInstalling] = useState(false);
const [progress, setProgress] = useState<CoreInstallTask | null>(null);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [confirmUpdateOpen, setConfirmUpdateOpen] = useState(false);
const [cancellingInstall, setCancellingInstall] = useState(false);
const [versionSource, setVersionSource] = useState<VersionSourceSettings | null>(null);
const [versionSourceSaving, setVersionSourceSaving] = useState(false);
const [versionSourceError, setVersionSourceError] = useState('');
const [customMirrorDraft, setCustomMirrorDraft] = useState('');
const [customMirrorDialogOpen, setCustomMirrorDialogOpen] = useState(false);
const feedback = useAppNotice();
const { showNotice } = feedback;
const installDialogRef = useRef<HTMLDivElement>(null);
const customMirrorInputRef = useRef<HTMLInputElement>(null);
const completedInstallKeyRef = useRef('');
const manualInstallInProgressRef = useRef(false);
const pageVisitRef = useRef({});
const showInstallCompletedNotice = (result: CoreInstallResult, message?: string | null) => {
const key = `${result.version}\u0000${result.assetName}\u0000${result.binaryPath ?? ''}`;
if (completedInstallKeyRef.current === key) return;
completedInstallKeyRef.current = key;
showNotice(message || t('kernel.install.completed', { version: result.version }), 'success');
};
const applyInstallTask = (
task: CoreInstallTask,
showFinishedDialog = true,
showCompletionNotice = true,
) => {
if (!task.running && !task.message && !task.result) {
setProgress(null);
setInstalling(false);
setCancellingInstall(false);
return;
}
setInstalling(task.running);
if (!task.running) {
setCancellingInstall(false);
}
if (showCompletionNotice && (task.running || showFinishedDialog)) {
setProgress(task);
setInstallDialogOpen(true);
} else {
setProgress(null);
setInstallDialogOpen(false);
}
if (task.result) {
if (showCompletionNotice) {
showInstallCompletedNotice(task.result, task.message);
}
setInstallDialogOpen(false);
setProgress(null);
setCancellingInstall(false);
void refreshStatus();
return;
}
if (task.message && !task.running) {
showNotice(task.message, task.phase === '安装失败' ? 'error' : 'info');
}
};
const loadVersionSourceSettings = async () => {
try {
const settings = await invoke<VersionSourceSettings>('get_version_source_settings');
setVersionSource(settings);
setVersionSourceError('');
} catch (error) {
setVersionSourceError(String(error));
}
};
const loadInstallTask = async () => {
try {
const task = await invoke<CoreInstallTask>('get_core_install_task');
applyInstallTask(task, false, false);
} catch {}
};
const updateVersionSource = async (source: VersionDownloadSource) => {
setVersionSourceSaving(true);
setVersionSourceError('');
try {
const settings = await invoke<VersionSourceSettings>('set_download_source', { source });
setVersionSource(settings);
resetLatest();
showNotice({ key: 'kernel.versions.sourceSwitched', variables: {
source: downloadSourceLabel(settings.source, t),
} }, 'info');
} catch (error) {
await loadVersionSourceSettings();
setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) }));
} finally {
setVersionSourceSaving(false);
}
};
const addCustomMirror = async () => {
const url = customMirrorDraft.trim();
if (!url) return;
setVersionSourceSaving(true);
setVersionSourceError('');
try {
const settings = await invoke<VersionSourceSettings>('add_custom_download_mirror', { url });
setVersionSource(settings);
setCustomMirrorDraft('');
setCustomMirrorDialogOpen(false);
resetLatest();
showNotice({ key: 'kernel.versions.customMirrorAdded' }, 'success');
} catch (error) {
const message = t('kernel.versions.customMirrorAddFailed', { error: String(error) });
setVersionSourceError(message);
} finally {
setVersionSourceSaving(false);
}
};
const removeCustomMirror = async (url: string) => {
const wasSelected = versionSource?.source === `custom:${url}`;
setVersionSourceSaving(true);
setVersionSourceError('');
try {
const settings = await invoke<VersionSourceSettings>('remove_custom_download_mirror', {
url,
});
setVersionSource(settings);
showNotice({ key: 'kernel.versions.customMirrorRemoved' }, 'success');
if (wasSelected) {
resetLatest();
}
} catch (error) {
const message = t('kernel.versions.customMirrorRemoveFailed', { error: String(error) });
setVersionSourceError(message);
} finally {
setVersionSourceSaving(false);
}
};
const installVersion = async (version: string) => {
completedInstallKeyRef.current = '';
manualInstallInProgressRef.current = true;
setInstalling(true);
setCancellingInstall(false);
setInstallDialogOpen(true);
setProgress({
running: true,
cancellable: true,
phase: '准备下载',
downloaded: 0,
total: null,
percent: null,
message: null,
result: null,
});
try {
const result = await invoke<CoreInstallResult>('install_core_version', { version });
showInstallCompletedNotice(result, t('kernel.install.completed', { version: result.version }));
manualInstallInProgressRef.current = false;
setProgress({
running: false,
cancellable: false,
phase: '安装完成',
downloaded: 1,
total: 1,
percent: 100,
message: t('kernel.install.completed', { version: result.version }),
result,
});
setInstallDialogOpen(false);
setProgress(null);
setCancellingInstall(false);
await refreshStatus();
} catch (error) {
manualInstallInProgressRef.current = false;
const errorMessage = String(error);
showNotice(errorMessage, errorMessage.includes('取消') ? 'info' : 'error');
setProgress((current) => ({
running: false,
cancellable: false,
phase: errorMessage.includes('取消') ? '已取消' : '安装失败',
downloaded: current?.downloaded ?? 0,
total: current?.total ?? null,
percent: current?.percent ?? null,
message: errorMessage,
result: null,
}));
} finally {
setInstalling(false);
}
};
const cancelInstall = async () => {
if (cancellingInstall || !progress?.running || !progress.cancellable) {
return;
}
setCancellingInstall(true);
try {
await invoke('cancel_core_install');
} catch (error) {
setCancellingInstall(false);
showNotice(String(error), 'error');
}
};
const closeInstallDialog = () => {
if (installing || progress?.running) {
return;
}
setInstallDialogOpen(false);
setProgress(null);
setCancellingInstall(false);
};
const openAppRelease = async (url = appUpdate?.releaseUrl || APP_RELEASE_URL) => {
try {
await invoke('open_external_url', { url });
} catch (error) {
showNotice({ key: 'kernel.error.openUpdate', variables: { error: String(error) } }, 'error');
}
};
useEffect(() => {
if (!recordVersionManagementVisit(pageVisitRef.current)) return;
if (!checkingAppUpdate && !appUpdateTask.running) {
void checkAppUpdate();
}
if (!checkingLatest) {
void checkLatest();
}
}, [appUpdateTask.running, checkAppUpdate, checkLatest, checkingAppUpdate, checkingLatest]);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
let unlistenConfig: (() => void) | null = null;
let unlistenVersionSource: (() => void) | null = null;
listen<CoreInstallTask>('core-install-progress', (event) => {
const showTaskUi = manualInstallInProgressRef.current;
applyInstallTask(event.payload, showTaskUi, showTaskUi);
if (!event.payload.running) {
manualInstallInProgressRef.current = false;
}
})
.then((unlistenProgress) => {
if (disposed) unlistenProgress();
else unlisten = unlistenProgress;
})
.catch(() => undefined);
void listen('config-files-changed', () => {
if (disposed) return;
void loadVersionSourceSettings();
void refreshStatus();
}).then((stop) => {
if (disposed) stop();
else unlistenConfig = stop;
});
void listen<VersionSourceSettings>('version-download-source-changed', (event) => {
if (disposed) return;
setVersionSource(event.payload);
setVersionSourceError('');
showNotice({ key: 'kernel.versions.sourceAutoSwitched', variables: {
source: downloadSourceLabel(event.payload.source, t),
} }, 'info');
}).then((stop) => {
if (disposed) stop();
else unlistenVersionSource = stop;
});
loadInstallTask();
void loadVersionSourceSettings();
void getVersion()
.then((version) => {
if (!disposed) setInstalledAppVersion(version);
})
.catch(() => undefined);
return () => {
disposed = true;
unlisten?.();
unlistenConfig?.();
unlistenVersionSource?.();
};
}, []);
useEffect(() => {
if (!installDialogOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
installDialogRef.current?.focus();
const preventEscapeClose = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
}
};
document.addEventListener('keydown', preventEscapeClose);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', preventEscapeClose);
};
}, [installDialogOpen]);
useEffect(() => {
if (!customMirrorDialogOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.setTimeout(() => customMirrorInputRef.current?.focus(), 0);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !versionSourceSaving) {
setCustomMirrorDialogOpen(false);
}
};
document.addEventListener('keydown', closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape);
};
}, [customMirrorDialogOpen, versionSourceSaving]);
const latestVersion = latest?.version ?? '';
const currentVersion = coreStatus?.currentVersion ?? '';
const coreInstalled = Boolean(coreStatus?.installed);
const coreProcessBusy = Boolean(coreStatus?.starting);
const busy = checkingLatest || installing || coreProcessBusy;
const installDisabled = busy || installing;
const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion;
const currentAppVersion = resolvedAppVersion ? displayAppVersion(resolvedAppVersion) : t('common.detecting');
const latestAppVersion = appUpdate?.latestVersion ? displayAppVersion(appUpdate.latestVersion) : '';
const appHasUpdate = Boolean(appUpdate?.updateAvailable);
const appVersionStatusLabel: string | null = appUpdateTask.running
? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters<typeof t>[0])
: appUpdateError
? t('kernel.update.failed')
: appHasUpdate
? t('appUpdate.available', { version: latestAppVersion })
: checkingAppUpdate || !appUpdate
? t('appUpdate.phase.checking')
: null;
const appVersionStatusTone = appUpdateError
? 'error'
: appUpdateTask.running || checkingAppUpdate || !appUpdate
? 'info'
: 'update';
const coreVersionStatusTone = installing || progress?.running
? 'info'
: latestError
? 'error'
: coreHasUpdate
? 'update'
: 'neutral';
const coreVersionStatusLabel: string | null = installing || progress?.running
? (cancellingInstall ? t('kernel.install.cancelling') : progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress'))
: latestError
? t('kernel.update.failed')
: coreHasUpdate
? t('kernel.update.available')
: !coreInstalled
? t('kernel.status.notInstalled')
: null;
const computedPercent = progress?.percent ?? (progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null);
const progressKnown = computedPercent !== null;
const progressPercent = clampPercent(computedPercent ?? 0);
const progressText = progress
? progress.phase === '安装完成'
? t('kernel.progress.completed')
: progress.phase === '解压中'
? t('kernel.progress.extracting')
: progress.total
? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}`
: progress.downloaded > 0
? formatBytes(progress.downloaded)
: t('kernel.progress.waiting')
: '';
const installDialogTone: MessageType = progress?.result
? 'success'
: progress?.phase === '安装失败'
? 'error'
: 'info';
const installDialogTitle = progress?.running || installing
? cancellingInstall
? t('kernel.install.titleCancelling')
: t('kernel.install.titleInstalling')
: progress?.result
? t('kernel.install.titleCompleted')
: progress?.phase === '已取消'
? t('kernel.install.titleCancelled')
: t('kernel.install.titleFailed');
const installDialogMessage = cancellingInstall
? t('kernel.install.waitingStop')
: progress?.message || (installing ? t('kernel.install.taskRunning') : '');
const installDialogAction = installing || progress?.running
? cancellingInstall
? t('kernel.install.cancellingShort')
: progress?.cancellable
? t('kernel.install.cancel')
: t('common.processing')
: t('common.close');
const installDialogActionDisabled = (installing || progress?.running) && (cancellingInstall || !progress?.cancellable);
return (
<section className="page management-page version-management-page">
<MessageNotice message={versionSourceError} onDismiss={() => setVersionSourceError('')} />
<section className="panel version-list">
<div className="version-source-row" aria-label={t('kernel.versions.downloadSource')}>
<div className="version-source-copy">
<strong>{t('kernel.versions.downloadSource')}</strong>
<span>{t('kernel.versions.downloadSourceHint')}</span>
{versionSource?.gitcodeAvailable === false ? (
<span>{t('kernel.versions.gitcodeUnavailable')}</span>
) : null}
</div>
<div className="version-source-control">
<label>
<span className="sr-only">{t('kernel.versions.downloadSource')}</span>
<select
value={versionSource?.source ?? DEFAULT_VERSION_DOWNLOAD_SOURCE}
disabled={
!versionSource
|| versionSourceSaving
|| appUpdateTask.running
|| installing
}
aria-label={t('kernel.versions.downloadSource')}
onChange={(event) => void updateVersionSource(event.currentTarget.value as VersionDownloadSource)}
>
<option value="github">{t('kernel.versions.source.github')}</option>
<option value="gitcode" disabled={!versionSource?.gitcodeAvailable}>
{t('kernel.versions.source.gitcode')}
</option>
<option value="gh-proxy">{t('kernel.versions.source.ghProxy')}</option>
<option value="gh-fast">{t('kernel.versions.source.ghFast')}</option>
{versionSource?.customMirrors.map((url) => (
<option key={url} value={`custom:${url}`}>
{downloadSourceLabel(`custom:${url}`, t)}
</option>
))}
</select>
</label>
<button
type="button"
className="primary-button version-source-add-button"
disabled={versionSourceSaving || appUpdateTask.running || installing}
onClick={() => {
setVersionSourceError('');
setCustomMirrorDialogOpen(true);
}}
>
<span>{t('kernel.versions.customMirrorAdd')}</span>
</button>
</div>
</div>
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
<div className="version-card-grid">
<article className="version-list-item app-module-card">
<div className="version-item-content">
<div className="version-card-top">
<h2>{t('kernel.versions.appCardTitle')}</h2>
{appVersionStatusLabel ? (
<span className={`version-row-status ${appVersionStatusTone}`} title={appUpdateError || appVersionStatusLabel}>
{appVersionStatusLabel}
</span>
) : null}
</div>
<dl className="version-metrics-comparison">
<div className="version-metric-tile">
<dt className="version-metric-label">{t('appUpdate.current')}</dt>
<dd className="version-metric-value">{currentAppVersion}</dd>
</div>
<div className={`version-metric-tile ${appHasUpdate ? 'has-update' : ''}`}>
<dt className="version-metric-label">{t('appUpdate.latest')}</dt>
<dd className="version-metric-value">
{appUpdate ? (latestAppVersion || currentAppVersion) : (checkingAppUpdate ? t('appUpdate.checking') : t('common.detecting'))}
</dd>
</div>
</dl>
<MessageNotice message={appUpdateError} />
{!appUpdate?.autoUpdateSupported ? (
<div className="version-alert-banner neutral">
<Info size={14} />
<span>{t('appUpdate.manualFallback')}</span>
</div>
) : null}
</div>
<div className="version-card-actions">
<button
type="button"
className="secondary-button"
disabled={checkingAppUpdate || appUpdateTask.running}
onClick={() => void checkAppUpdate()}
>
<RefreshCw size={15} className={checkingAppUpdate ? 'spin' : ''} aria-hidden="true" />
<span>{checkingAppUpdate ? t('appUpdate.checking') : t('appUpdate.check')}</span>
</button>
{appHasUpdate && appUpdate?.autoUpdateSupported ? (
<button
type="button"
className="primary-button"
disabled={appUpdateTask.running}
onClick={requestAppUpdate}
>
<Download size={15} aria-hidden="true" />
<span>{t('appUpdate.installNow')}</span>
</button>
) : null}
<button
type="button"
className={appHasUpdate && !appUpdate?.autoUpdateSupported ? 'primary-button' : 'secondary-button'}
onClick={() => void openAppRelease()}
>
<ExternalLink size={15} aria-hidden="true" />
<span>{t('appUpdate.openRelease')}</span>
</button>
</div>
</article>
<article className="version-list-item core-module-card">
<div className="version-item-content">
<div className="version-card-top">
<h2>{t('kernel.versions.coreCardTitle')}</h2>
{coreVersionStatusLabel ? (
<span className={`version-row-status ${coreVersionStatusTone}`} title={coreVersionStatusLabel}>
{coreVersionStatusLabel}
</span>
) : null}
</div>
<dl className="version-metrics-comparison">
<div className="version-metric-tile">
<dt className="version-metric-label">{t('kernel.versions.current')}</dt>
<dd className="version-metric-value" title={currentVersion || t('kernel.status.notInstalled')}>
{currentVersion || t('kernel.status.notInstalled')}
</dd>
</div>
<div className={`version-metric-tile ${coreHasUpdate ? 'has-update' : ''}`}>
<dt className="version-metric-label">{t('kernel.versions.latest')}</dt>
<dd className="version-metric-value" title={latestVersion || latestError || t('kernel.update.notChecked')}>
{checkingLatest ? t('kernel.update.checking') : (latestVersion || (latestError ? t('common.detectionFailed') : t('kernel.update.notChecked')))}
</dd>
</div>
</dl>
{latestError ? (
<MessageNotice message={latestError} />
) : null}
</div>
<div className="version-card-actions core-actions">
<button
type="button"
className="secondary-button"
disabled={busy}
onClick={() => void checkLatest(true)}
>
<RefreshCw size={15} className={checkingLatest ? 'spin' : ''} aria-hidden="true" />
<span>{checkingLatest ? t('kernel.update.checking') : t('kernel.versions.check')}</span>
</button>
<button
type="button"
className={latestVersion && (!coreInstalled || coreHasUpdate) ? 'primary-button' : 'secondary-button'}
title={latestVersion ? t('kernel.versions.stopAndUpdateVersion', { version: latestVersion }) : t('kernel.versions.installLatest')}
disabled={!latestVersion || busy}
onClick={() => setConfirmUpdateOpen(true)}
>
<Download size={15} aria-hidden="true" />
<span>{!coreInstalled ? t('kernel.versions.installLatestMissing') : t('kernel.versions.installLatest')}</span>
</button>
<button
type="button"
className="secondary-button"
title={t('kernel.versions.reinstallTitle')}
disabled={!currentVersion || installDisabled}
onClick={() => void installVersion(currentVersion)}
>
<RotateCcw size={15} aria-hidden="true" />
<span>{t('kernel.versions.reinstall')}</span>
</button>
</div>
</article>
</div>
<AppReleaseNotes
key={appUpdate?.latestVersion ?? 'pending'}
info={appUpdate}
checking={checkingAppUpdate}
failed={Boolean(appUpdateError)}
onOpenUrl={openAppRelease}
/>
</section>
{customMirrorDialogOpen ? (
<div className="install-dialog-backdrop custom-mirror-dialog-backdrop">
<form
className="install-dialog app-update-dialog custom-mirror-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="custom-mirror-dialog-title"
onSubmit={(event) => {
event.preventDefault();
void addCustomMirror();
}}
>
<div className="install-dialog-heading">
<span>{t('kernel.versions.downloadSource')}</span>
<h2 id="custom-mirror-dialog-title">{t('kernel.versions.customMirrorDialogTitle')}</h2>
</div>
<p className="custom-mirror-dialog-description">
{t('kernel.versions.customMirrorDialogDescription')}
</p>
<input
ref={customMirrorInputRef}
type="url"
value={customMirrorDraft}
disabled={versionSourceSaving}
placeholder={t('kernel.versions.customMirrorPlaceholder')}
aria-label={t('kernel.versions.customMirrorPlaceholder')}
onChange={(event) => setCustomMirrorDraft(event.currentTarget.value)}
/>
{versionSource?.customMirrors.length ? (
<div className="custom-mirror-dialog-list">
<span>{t('kernel.versions.customMirrorSaved')}</span>
{versionSource.customMirrors.map((url) => (
<div key={url}>
<span title={url}>{url}</span>
<button
type="button"
disabled={versionSourceSaving}
title={t('kernel.versions.customMirrorRemove')}
aria-label={t('kernel.versions.customMirrorRemove')}
onClick={() => void removeCustomMirror(url)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
))}
</div>
) : null}
<div className="app-update-dialog-actions">
<button
type="button"
className="secondary-button"
disabled={versionSourceSaving}
onClick={() => {
setCustomMirrorDialogOpen(false);
setCustomMirrorDraft('');
setVersionSourceError('');
}}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="secondary-button"
disabled={!customMirrorDraft.trim() || versionSourceSaving}
>
<span>{t('kernel.versions.customMirrorConfirm')}</span>
</button>
</div>
</form>
</div>
) : null}
{confirmUpdateOpen ? (
<div className="install-dialog-backdrop app-update-dialog-backdrop">
<section
className="install-dialog app-update-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="core-update-confirm-title"
>
<div className="install-dialog-heading">
<span className="install-dialog-eyebrow">{t('kernel.dialog.install')}</span>
<h2 id="core-update-confirm-title">
{t('kernel.versions.confirmUpdateTitle')}
</h2>
</div>
<p className="app-update-confirm-copy">
{t('kernel.versions.stopAndConfirmDescription', { version: latestVersion })}
</p>
<div className="app-update-dialog-actions">
<button
type="button"
className="secondary-button"
onClick={() => setConfirmUpdateOpen(false)}
>
{t('common.cancel')}
</button>
<button
type="button"
className="primary-button"
onClick={() => {
setConfirmUpdateOpen(false);
void installVersion(latestVersion);
}}
>
<Download size={15} aria-hidden="true" />
<span>{t('kernel.versions.installLatest')}</span>
</button>
</div>
</section>
</div>
) : null}
{installDialogOpen && progress ? (
<div className="install-dialog-backdrop">
<div
ref={installDialogRef}
className={`install-dialog ${installDialogTone}`}
role="dialog"
aria-modal="true"
aria-labelledby="install-dialog-title"
aria-describedby="install-dialog-message"
aria-busy={installing || progress?.running}
tabIndex={-1}
>
<div className="install-dialog-heading">
<span className="install-dialog-eyebrow">{t('kernel.dialog.install')}</span>
<h2 id="install-dialog-title">{installDialogTitle}</h2>
</div>
<div className="install-dialog-phase">
<span>{t('kernel.dialog.phase')}</span>
<strong className="phase-badge">
{cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)}
</strong>
</div>
<div
className={`install-progress-track ${
progressKnown ? '' : (installing || progress?.running) ? 'unknown is-running' : 'unknown'
}`}
>
<span
className="install-progress-fill"
style={progressKnown ? { width: `${progressPercent}%` } : undefined}
/>
</div>
<div className="install-progress-meta">
<strong>{progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')}</strong>
<span>{progressText}</span>
</div>
<div
id="install-dialog-message"
className={`install-dialog-message ${installDialogTone}`}
aria-live="polite"
>
{installDialogMessage || ' '}
</div>
<div className="install-dialog-actions">
<button
type="button"
className={(installing || progress?.running) ? 'danger-button' : 'primary-button'}
disabled={installDialogActionDisabled}
onClick={(installing || progress?.running) ? cancelInstall : closeInstallDialog}
>
{installDialogAction}
</button>
</div>
</div>
</div>
) : null}
</section>
);
}
function localizeInstallPhase(
phase: string,
t: ReturnType<typeof useI18n>['t'],
) {
const keys = {
'准备下载': 'kernel.phase.preparingDownload',
'下载中': 'kernel.phase.downloading',
'解压中': 'kernel.phase.extracting',
'准备内置内核': 'kernel.phase.preparingBundled',
'安装完成': 'kernel.phase.completed',
'安装失败': 'kernel.phase.failed',
'已取消': 'kernel.phase.cancelled',
} as const;
const key = keys[phase as keyof typeof keys];
return key ? t(key) : phase;
}
function clampPercent(percent: number) {
return Math.min(100, Math.max(0, percent));
}
function formatBytes(bytes: number) {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}