feat: redesign version management and restore core after updates
- Add a dedicated version management page - Improve app, core, and model catalog status displays - Preserve the core runtime state during updates - Automatically restart the core after successful, failed, or cancelled updates - Add tests for version management and core runtime restoration
This commit is contained in:
1 parent
6462699a3d
commit
32b47575b5
10 files changed
+1609
-1245
No files matched your search
+101
-11
@@ -24,14 +24,22 @@ pub(crate) fn detect_bundled_core() -> Result<Option<BundledCoreInfo>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn install_bundled_core(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
state: tauri::State<'_, CoreDownloadState>,
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreInstallResult, String> {
|
||||
let (info, archive_path) = bundled_core_archive()?
|
||||
.ok_or_else(|| "当前发行包没有匹配此系统架构的内置内核".to_string())?;
|
||||
let token = CancellationToken::new();
|
||||
state.start(token, Some(info.version.clone()))?;
|
||||
let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path);
|
||||
let result = install_core_with_runtime_restore(
|
||||
&app,
|
||||
process_state.inner(),
|
||||
gui_config_state.inner(),
|
||||
|| install_bundled_core_inner(&window, state.inner(), &info, &archive_path),
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = cleanup_core_work_dirs();
|
||||
}
|
||||
@@ -78,24 +86,40 @@ pub(crate) fn get_core_install_task(state: tauri::State<'_, CoreDownloadState>)
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_core_version(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
state: tauri::State<'_, CoreDownloadState>,
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
version: Option<String>,
|
||||
) -> Result<CoreInstallResult, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let proxy_url = config.proxy_url;
|
||||
let proxy_url = config.proxy_url.clone();
|
||||
let token = CancellationToken::new();
|
||||
state.start(token.clone(), version.clone())?;
|
||||
let result = install_core_version_inner(
|
||||
&window,
|
||||
state.inner(),
|
||||
token,
|
||||
version,
|
||||
&proxy_url,
|
||||
config.prefer_gitcode_downloads,
|
||||
)
|
||||
.await;
|
||||
let (was_running, install_result) =
|
||||
match pause_core_for_install(&app, process_state.inner(), &config) {
|
||||
Ok(was_running) => (
|
||||
was_running,
|
||||
install_core_version_inner(
|
||||
&window,
|
||||
state.inner(),
|
||||
token,
|
||||
version,
|
||||
&proxy_url,
|
||||
config.prefer_gitcode_downloads,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
Err(error) => (false, Err(error)),
|
||||
};
|
||||
let result = restore_core_after_install(
|
||||
&app,
|
||||
process_state.inner(),
|
||||
&config,
|
||||
was_running,
|
||||
install_result,
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = cleanup_core_work_dirs();
|
||||
}
|
||||
@@ -104,6 +128,72 @@ pub(crate) async fn install_core_version(
|
||||
result
|
||||
}
|
||||
|
||||
fn install_core_with_runtime_restore<F>(
|
||||
app: &tauri::AppHandle,
|
||||
process_state: &CoreProcessState,
|
||||
gui_config_state: &GuiConfigState,
|
||||
install: F,
|
||||
) -> Result<CoreInstallResult, String>
|
||||
where
|
||||
F: FnOnce() -> Result<CoreInstallResult, String>,
|
||||
{
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let was_running = pause_core_for_install(app, process_state, &config)?;
|
||||
let result = install();
|
||||
restore_core_after_install(app, process_state, &config, was_running, result)
|
||||
}
|
||||
|
||||
fn pause_core_for_install(
|
||||
app: &tauri::AppHandle,
|
||||
process_state: &CoreProcessState,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<bool, String> {
|
||||
let was_running = current_core_status(Some(process_state), Some(config.port))?.running;
|
||||
if was_running {
|
||||
stop_core_process_inner(process_state)?;
|
||||
emit_current_core_status(app, process_state, config.port);
|
||||
}
|
||||
Ok(was_running)
|
||||
}
|
||||
|
||||
fn restore_core_after_install<T>(
|
||||
app: &tauri::AppHandle,
|
||||
process_state: &CoreProcessState,
|
||||
config: &GuiConfigFile,
|
||||
was_running: bool,
|
||||
install_result: Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let restart_result = if was_running {
|
||||
start_core_process_inner(process_state, config)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
emit_current_core_status(app, process_state, config.port);
|
||||
combine_install_and_restart_results(install_result, restart_result)
|
||||
}
|
||||
|
||||
pub(crate) fn combine_install_and_restart_results<T>(
|
||||
install_result: Result<T, String>,
|
||||
restart_result: Result<(), String>,
|
||||
) -> Result<T, String> {
|
||||
match (install_result, restart_result) {
|
||||
(Ok(result), Ok(())) => Ok(result),
|
||||
(Ok(_), Err(restart_error)) => {
|
||||
Err(format!("内核已安装,但自动恢复运行失败: {restart_error}"))
|
||||
}
|
||||
(Err(install_error), Ok(())) => Err(install_error),
|
||||
(Err(install_error), Err(restart_error)) => Err(format!(
|
||||
"{install_error};自动恢复原内核运行状态也失败: {restart_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_current_core_status(app: &tauri::AppHandle, process_state: &CoreProcessState, port: u16) {
|
||||
if let Ok(status) = current_core_status(Some(process_state), Some(port)) {
|
||||
emit_core_status(app, &status);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn start_core_process(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -9,6 +9,40 @@ fn core_process_starting_state_tracks_automatic_launch() {
|
||||
assert!(!state.is_starting());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_core_install_remains_successful_when_restart_succeeds() {
|
||||
let result = combine_install_and_restart_results(Ok("installed"), Ok(()));
|
||||
assert_eq!(result.unwrap(), "installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_core_install_reports_automatic_restart_failure() {
|
||||
let result = combine_install_and_restart_results(Ok("installed"), Err("port busy".into()));
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"内核已安装,但自动恢复运行失败: port busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_core_install_keeps_the_install_error_after_runtime_is_restored() {
|
||||
let result =
|
||||
combine_install_and_restart_results::<()>(Err("download cancelled".into()), Ok(()));
|
||||
assert_eq!(result.unwrap_err(), "download cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_core_install_reports_restart_failure_too() {
|
||||
let result = combine_install_and_restart_results::<()>(
|
||||
Err("checksum mismatch".into()),
|
||||
Err("port busy".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"checksum mismatch;自动恢复原内核运行状态也失败: port busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_preserves_only_regular_bundled_assets() {
|
||||
let root = agent_test_home("bundled-assets");
|
||||
|
||||
+4
-3
@@ -22,6 +22,7 @@ import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
|
||||
import { ConfigPanelPage } from './pages/ConfigPanel';
|
||||
import { ApiAccessPage } from './pages/ApiAccessPage';
|
||||
import { KernelPage } from './pages/Kernel';
|
||||
import { VersionManagementPage } from './pages/VersionManagementPage';
|
||||
import { OAuthManagementPage } from './pages/ManagementPages';
|
||||
import { AgentsPage } from './pages/AgentsPage';
|
||||
import { UsageRecordsPage } from './pages/UsageRecordsPage';
|
||||
@@ -44,7 +45,7 @@ const pages = [
|
||||
id: 'versions',
|
||||
labelKey: 'app.nav.versions',
|
||||
icon: PackageOpen,
|
||||
component: VersionManagementPage,
|
||||
component: VersionManagementPageWrapper,
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
@@ -96,8 +97,8 @@ function HomePage() {
|
||||
return <KernelPage view="home" />;
|
||||
}
|
||||
|
||||
function VersionManagementPage() {
|
||||
return <KernelPage view="versions" />;
|
||||
function VersionManagementPageWrapper() {
|
||||
return <VersionManagementPage />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
@@ -936,6 +936,26 @@ export const jaOverrides = {
|
||||
'kernel.notice.restarted': 'コアを再起動しました',
|
||||
'kernel.control.refresh': '状態を更新',
|
||||
'kernel.versions.title': 'コアバージョン管理',
|
||||
'kernel.versions.subtitle': 'アプリ、プロキシコア、モデルカタログのバージョン更新およびオフラインインストールを管理します。',
|
||||
'kernel.versions.checkAll': 'すべて更新確認',
|
||||
'kernel.versions.checkingAll': 'すべて確認中...',
|
||||
'kernel.versions.checkAllSuccess': '全コンポーネントの更新確認が完了しました',
|
||||
'kernel.versions.appCardTitle': 'デスクトップアプリ',
|
||||
'kernel.versions.coreCardTitle': 'プロキシコア',
|
||||
'kernel.versions.catalogCardTitle': 'Codex モデルカタログ',
|
||||
'kernel.versions.gitcodeEnabled': 'GitCode 国内ミラー有効',
|
||||
'kernel.versions.gitcodeDisabled': '公式 GitHub ソース',
|
||||
'kernel.versions.gitcodeSwitchSuccess': 'ダウンロード元を変更しバージョンを再確認しました',
|
||||
'kernel.versions.bundledPackage': '内蔵オフラインパッケージ',
|
||||
'kernel.versions.offlineRecommend': 'ネットワーク制限がありますか?インターネット接続なしで内蔵オフラインコアを直接インストールできます。',
|
||||
'kernel.versions.autoUpdateSupported': 'アプリ内自動更新対応',
|
||||
'kernel.versions.autoUpdateManual': 'ポータブル版 / 手動更新',
|
||||
'kernel.versions.coreRunningNotice': 'コアの更新または再インストール時、実行中のコアは自動的に停止します。',
|
||||
'kernel.versions.currentRunning': '現在実行中',
|
||||
'kernel.versions.currentInstalled': '現在インストール済',
|
||||
'kernel.versions.latestCloud': '公式最新',
|
||||
'kernel.versions.viewChangelog': '変更履歴',
|
||||
'kernel.versions.installLatestMissing': '最新版をインストール',
|
||||
'kernel.versions.offlineHint': 'GitHub に接続できない場合は、オフラインインストールで組み込みコアを使用できます。',
|
||||
'kernel.versions.current': '現在のバージョン',
|
||||
'kernel.versions.latest': '最新バージョン',
|
||||
|
||||
@@ -936,6 +936,26 @@ export const en: Record<MessageKey, string> = {
|
||||
'kernel.notice.restarted': 'Core restarted',
|
||||
'kernel.control.refresh': 'Refresh Status',
|
||||
'kernel.versions.title': 'Core Version Management',
|
||||
'kernel.versions.subtitle': 'Manage desktop application, proxy core runtime, and model catalog updates with offline support.',
|
||||
'kernel.versions.checkAll': 'Check All Updates',
|
||||
'kernel.versions.checkingAll': 'Checking All...',
|
||||
'kernel.versions.checkAllSuccess': 'Update check completed for all components',
|
||||
'kernel.versions.appCardTitle': 'Desktop Application',
|
||||
'kernel.versions.coreCardTitle': 'Proxy Core Kernel',
|
||||
'kernel.versions.catalogCardTitle': 'Codex Model Catalog',
|
||||
'kernel.versions.gitcodeEnabled': 'GitCode China Mirror Active',
|
||||
'kernel.versions.gitcodeDisabled': 'Official GitHub Source',
|
||||
'kernel.versions.gitcodeSwitchSuccess': 'Download source updated and versions rechecked',
|
||||
'kernel.versions.bundledPackage': 'Bundled Offline Package',
|
||||
'kernel.versions.offlineRecommend': 'Network restricted? You can install the bundled offline core directly without an internet connection.',
|
||||
'kernel.versions.autoUpdateSupported': 'In-app auto-update supported',
|
||||
'kernel.versions.autoUpdateManual': 'Portable Edition / Manual Update',
|
||||
'kernel.versions.coreRunningNotice': 'The running core will be stopped automatically during updates or reinstallation.',
|
||||
'kernel.versions.currentRunning': 'Current Running',
|
||||
'kernel.versions.currentInstalled': 'Current Installed',
|
||||
'kernel.versions.latestCloud': 'Official Latest',
|
||||
'kernel.versions.viewChangelog': 'Changelog',
|
||||
'kernel.versions.installLatestMissing': 'Install Latest',
|
||||
'kernel.versions.offlineHint': 'If GitHub is unavailable, use Offline Install to install the bundled core.',
|
||||
'kernel.versions.current': 'Current Version',
|
||||
'kernel.versions.latest': 'Latest Version',
|
||||
|
||||
@@ -934,6 +934,26 @@ export const zhCN = {
|
||||
'kernel.notice.restarted': '内核已重启',
|
||||
'kernel.control.refresh': '刷新状态',
|
||||
'kernel.versions.title': '内核版本管理',
|
||||
'kernel.versions.subtitle': '管理客户端应用、代理内核及模型目录的版本更新与离线安装。',
|
||||
'kernel.versions.checkAll': '检查全部更新',
|
||||
'kernel.versions.checkingAll': '正在检查全部...',
|
||||
'kernel.versions.checkAllSuccess': '已完成全部组件更新检查',
|
||||
'kernel.versions.appCardTitle': '桌面客户端',
|
||||
'kernel.versions.coreCardTitle': '代理内核',
|
||||
'kernel.versions.catalogCardTitle': 'Codex 模型目录',
|
||||
'kernel.versions.gitcodeEnabled': '已启用 GitCode 国内加速',
|
||||
'kernel.versions.gitcodeDisabled': '官方 GitHub 源',
|
||||
'kernel.versions.gitcodeSwitchSuccess': '已切换下载源并重新检测版本',
|
||||
'kernel.versions.bundledPackage': '内置离线安装包',
|
||||
'kernel.versions.offlineRecommend': '网络连接受限?可直接一键安装内置离线内核,无需联网。',
|
||||
'kernel.versions.autoUpdateSupported': '支持应用内自动升级',
|
||||
'kernel.versions.autoUpdateManual': '便携绿色版 / 手动更新',
|
||||
'kernel.versions.coreRunningNotice': '更新或重装内核时,正在运行的内核将自动先停止。',
|
||||
'kernel.versions.currentRunning': '当前运行',
|
||||
'kernel.versions.currentInstalled': '当前安装',
|
||||
'kernel.versions.latestCloud': '官方最新',
|
||||
'kernel.versions.viewChangelog': '更新日志',
|
||||
'kernel.versions.installLatestMissing': '安装最新版',
|
||||
'kernel.versions.offlineHint': 'GitHub 无法连接时,可使用离线安装使用内置内核。',
|
||||
'kernel.versions.current': '当前版本',
|
||||
'kernel.versions.latest': '最新版本',
|
||||
|
||||
+53
-928
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,975 @@
|
||||
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 {
|
||||
AlertCircle,
|
||||
AppWindow,
|
||||
ArrowRight,
|
||||
Box,
|
||||
Check,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Info,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useCoreRuntime } from '../coreRuntime';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppUpdate } from '../appUpdate';
|
||||
|
||||
export type CorePlatform = {
|
||||
os: string;
|
||||
arch: string;
|
||||
assetOs: string;
|
||||
assetArch: string;
|
||||
archiveKind: 'tar.gz' | 'zip';
|
||||
};
|
||||
|
||||
export type CoreLatest = {
|
||||
version: string;
|
||||
assetName: string;
|
||||
};
|
||||
|
||||
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 CodexModelCatalogUpdateResult = {
|
||||
outcome: 'updated' | 'unchanged';
|
||||
};
|
||||
|
||||
export type VersionSourceSettings = {
|
||||
preferGitcodeDownloads: boolean;
|
||||
gitcodeAvailable: boolean;
|
||||
};
|
||||
|
||||
export type MessageType = 'info' | 'success' | 'error';
|
||||
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
|
||||
|
||||
let latestAutoCheckStarted = false;
|
||||
let cachedLatest: CoreLatest | null = null;
|
||||
let cachedLatestError = '';
|
||||
let latestCheckPromise: Promise<CoreLatest> | null = null;
|
||||
let latestRequestEpoch = 0;
|
||||
|
||||
export function displayAppVersion(version: string) {
|
||||
const resolvedVersion = version.trim();
|
||||
return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`;
|
||||
}
|
||||
|
||||
export function requestLatestCore(force = false) {
|
||||
if (!force && latestCheckPromise) {
|
||||
return latestCheckPromise;
|
||||
}
|
||||
|
||||
const requestEpoch = ++latestRequestEpoch;
|
||||
const request = invoke<CoreLatest>('check_latest_core')
|
||||
.then((result) => {
|
||||
if (requestEpoch === latestRequestEpoch) {
|
||||
cachedLatest = result;
|
||||
cachedLatestError = '';
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestEpoch === latestRequestEpoch) {
|
||||
cachedLatest = null;
|
||||
cachedLatestError = String(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (latestCheckPromise === request) {
|
||||
latestCheckPromise = null;
|
||||
}
|
||||
});
|
||||
latestCheckPromise = request;
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export function VersionManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
info: appUpdate,
|
||||
error: appUpdateError,
|
||||
checking: checkingAppUpdate,
|
||||
task: appUpdateTask,
|
||||
check: checkAppUpdate,
|
||||
requestInstall: requestAppUpdate,
|
||||
} = useAppUpdate();
|
||||
|
||||
const {
|
||||
status: coreStatus,
|
||||
statusError,
|
||||
refreshStatus,
|
||||
} = useCoreRuntime();
|
||||
|
||||
const [installedAppVersion, setInstalledAppVersion] = useState('');
|
||||
const [platform, setPlatform] = useState<CorePlatform | null>(null);
|
||||
const [platformError, setPlatformError] = useState('');
|
||||
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
|
||||
const [latestError, setLatestError] = useState(cachedLatestError);
|
||||
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
|
||||
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progress, setProgress] = useState<CoreInstallTask | null>(null);
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [cancellingInstall, setCancellingInstall] = useState(false);
|
||||
|
||||
const [versionSource, setVersionSource] = useState<VersionSourceSettings | null>(null);
|
||||
const [versionSourceSaving, setVersionSourceSaving] = useState(false);
|
||||
const [versionSourceError, setVersionSourceError] = useState('');
|
||||
|
||||
const [catalogUpdating, setCatalogUpdating] = useState(false);
|
||||
const [catalogUpdateError, setCatalogUpdateError] = useState('');
|
||||
const [catalogUpdateNotice, setCatalogUpdateNotice] = useState('');
|
||||
|
||||
const [toastNotice, setToastNotice] = useState<{
|
||||
message: string;
|
||||
tone: MessageType;
|
||||
} | null>(null);
|
||||
|
||||
const installDialogRef = useRef<HTMLDivElement>(null);
|
||||
const latestCheckEpochRef = useRef(0);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
|
||||
const showToast = (message: string, tone: MessageType = 'info') => {
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
setToastNotice({ message, tone });
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToastNotice(null);
|
||||
toastTimerRef.current = null;
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const applyInstallTask = (task: CoreInstallTask, showFinishedDialog = true) => {
|
||||
if (!task.running && !task.message && !task.result) {
|
||||
setProgress(null);
|
||||
setInstalling(false);
|
||||
setCancellingInstall(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setInstalling(task.running);
|
||||
if (!task.running) {
|
||||
setCancellingInstall(false);
|
||||
}
|
||||
|
||||
if (task.running || showFinishedDialog) {
|
||||
setProgress(task);
|
||||
setInstallDialogOpen(true);
|
||||
} else {
|
||||
setProgress(null);
|
||||
}
|
||||
|
||||
if (task.result) {
|
||||
showToast(task.message || t('kernel.install.completed', { version: task.result.version }), 'success');
|
||||
void refreshStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.message && !task.running) {
|
||||
showToast(task.message, task.phase === '安装失败' ? 'error' : 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlatform = async () => {
|
||||
try {
|
||||
const result = await invoke<CorePlatform>('detect_core_platform');
|
||||
setPlatform(result);
|
||||
setPlatformError('');
|
||||
} catch (error) {
|
||||
setPlatform(null);
|
||||
setPlatformError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const checkLatest = async (force = false) => {
|
||||
const checkEpoch = ++latestCheckEpochRef.current;
|
||||
setCheckingLatest(true);
|
||||
setLatestError('');
|
||||
|
||||
try {
|
||||
const result = await requestLatestCore(force);
|
||||
if (checkEpoch === latestCheckEpochRef.current) {
|
||||
setLatest(result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (checkEpoch === latestCheckEpochRef.current) {
|
||||
setLatest(null);
|
||||
setLatestError(String(error));
|
||||
}
|
||||
} finally {
|
||||
if (checkEpoch === latestCheckEpochRef.current) {
|
||||
setCheckingLatest(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateVersionSource = async (enabled: boolean) => {
|
||||
setVersionSourceSaving(true);
|
||||
setVersionSourceError('');
|
||||
try {
|
||||
const settings = await invoke<VersionSourceSettings>('set_prefer_gitcode_downloads', { enabled });
|
||||
setVersionSource(settings);
|
||||
cachedLatest = null;
|
||||
cachedLatestError = '';
|
||||
setLatest(null);
|
||||
showToast(t('kernel.versions.gitcodeSwitchSuccess'), 'success');
|
||||
await Promise.allSettled([checkAppUpdate(), checkLatest(true)]);
|
||||
} catch (error) {
|
||||
await loadVersionSourceSettings();
|
||||
setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) }));
|
||||
showToast(t('kernel.versions.gitcodeSaveFailed', { error: String(error) }), 'error');
|
||||
} finally {
|
||||
setVersionSourceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const installVersion = async (version: string) => {
|
||||
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 });
|
||||
showToast(t('kernel.install.completed', { version: result.version }), 'success');
|
||||
setProgress({
|
||||
running: false,
|
||||
cancellable: false,
|
||||
phase: '安装完成',
|
||||
downloaded: 1,
|
||||
total: 1,
|
||||
percent: 100,
|
||||
message: t('kernel.install.completed', { version: result.version }),
|
||||
result,
|
||||
});
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
showToast(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);
|
||||
showToast(String(error), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const closeInstallDialog = () => {
|
||||
if (installing || progress?.running) {
|
||||
return;
|
||||
}
|
||||
setInstallDialogOpen(false);
|
||||
setProgress(null);
|
||||
setCancellingInstall(false);
|
||||
};
|
||||
|
||||
const openAppRelease = async () => {
|
||||
try {
|
||||
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
|
||||
} catch (error) {
|
||||
showToast(t('kernel.error.openUpdate', { error: String(error) }), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const syncModelCatalog = async () => {
|
||||
setCatalogUpdating(true);
|
||||
setCatalogUpdateError('');
|
||||
setCatalogUpdateNotice('');
|
||||
try {
|
||||
const result = await invoke<CodexModelCatalogUpdateResult>('update_codex_model_catalog');
|
||||
const notice = result.outcome === 'updated'
|
||||
? t('appUpdate.catalog.updated')
|
||||
: t('appUpdate.catalog.unchanged');
|
||||
setCatalogUpdateNotice(notice);
|
||||
showToast(notice, 'success');
|
||||
} catch (error) {
|
||||
const err = String(error);
|
||||
setCatalogUpdateError(err);
|
||||
showToast(err, 'error');
|
||||
} finally {
|
||||
setCatalogUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let unlistenConfig: (() => void) | null = null;
|
||||
|
||||
listen<CoreInstallTask>('core-install-progress', (event) => {
|
||||
applyInstallTask(event.payload);
|
||||
})
|
||||
.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;
|
||||
});
|
||||
|
||||
loadPlatform();
|
||||
loadInstallTask();
|
||||
loadVersionSourceSettings();
|
||||
|
||||
void getVersion()
|
||||
.then((version) => {
|
||||
if (!disposed) setInstalledAppVersion(version);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!latestAutoCheckStarted) {
|
||||
latestAutoCheckStarted = true;
|
||||
void checkLatest();
|
||||
} else if (latestCheckPromise) {
|
||||
void checkLatest();
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
unlistenConfig?.();
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// Derived state calculations
|
||||
const latestVersion = latest?.version ?? '';
|
||||
const currentVersion = coreStatus?.currentVersion ?? '';
|
||||
const coreInstalled = Boolean(coreStatus?.installed);
|
||||
const coreRunning = Boolean(coreStatus?.running);
|
||||
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 appVersionStatusTone = appUpdateError
|
||||
? 'error'
|
||||
: appUpdateTask.running
|
||||
? 'update'
|
||||
: appHasUpdate
|
||||
? 'update'
|
||||
: appUpdate
|
||||
? 'success'
|
||||
: '';
|
||||
|
||||
const appVersionStatusLabel = appUpdateTask.running
|
||||
? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters<typeof t>[0])
|
||||
: appUpdateError
|
||||
? t('kernel.update.failed')
|
||||
: appHasUpdate
|
||||
? t('appUpdate.available', { version: latestAppVersion })
|
||||
: appUpdate
|
||||
? t('appUpdate.upToDate')
|
||||
: t('appUpdate.phase.checking');
|
||||
|
||||
const coreHasUpdate = Boolean(latestVersion && currentVersion && currentVersion !== latestVersion);
|
||||
|
||||
const coreVersionStatusTone = installing || progress?.running
|
||||
? 'info'
|
||||
: coreRunning
|
||||
? 'success'
|
||||
: latestError
|
||||
? 'error'
|
||||
: coreHasUpdate
|
||||
? 'update'
|
||||
: coreInstalled
|
||||
? 'success'
|
||||
: 'neutral';
|
||||
|
||||
const coreVersionStatusLabel = installing || progress?.running
|
||||
? (cancellingInstall ? t('kernel.install.cancelling') : progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress'))
|
||||
: coreRunning
|
||||
? `${t('kernel.status.running')}${coreStatus?.processId ? ` (PID: ${coreStatus.processId})` : ''}`
|
||||
: latestError
|
||||
? t('kernel.update.failed')
|
||||
: coreHasUpdate
|
||||
? t('kernel.update.available')
|
||||
: coreInstalled
|
||||
? t('kernel.status.stopped')
|
||||
: t('kernel.status.notInstalled');
|
||||
|
||||
const platformOsLabel = platform?.os || (platformError ? t('common.detectionFailed') : t('common.detecting'));
|
||||
const platformArchLabel = platform?.arch || (platformError ? t('common.detectionFailed') : t('common.detecting'));
|
||||
|
||||
// Install dialog calculations
|
||||
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);
|
||||
|
||||
const isGitcodeActive = Boolean(versionSource?.preferGitcodeDownloads);
|
||||
|
||||
return (
|
||||
<section className="page management-page version-management-page">
|
||||
{/* Network Download Mirror Banner */}
|
||||
<section className="version-mirror-card" aria-label={t('kernel.versions.gitcodeSource')}>
|
||||
<div className="version-mirror-content">
|
||||
<div className="version-mirror-icon-box">
|
||||
<Zap size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="version-mirror-info">
|
||||
<div className="version-mirror-headline">
|
||||
<strong>{t('kernel.versions.gitcodeSource')}</strong>
|
||||
{isGitcodeActive ? (
|
||||
<span className="version-mirror-pill active">
|
||||
{t('kernel.versions.gitcodeEnabled')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p>
|
||||
{versionSource?.gitcodeAvailable === false
|
||||
? t('kernel.versions.gitcodeUnavailable')
|
||||
: t('kernel.versions.gitcodeSourceHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="version-mirror-toggle">
|
||||
<label className="switch-control" title={t('kernel.versions.gitcodeSource')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={isGitcodeActive}
|
||||
disabled={
|
||||
(!versionSource?.gitcodeAvailable && !versionSource?.preferGitcodeDownloads)
|
||||
|| versionSourceSaving
|
||||
|| appUpdateTask.running
|
||||
|| installing
|
||||
}
|
||||
aria-label={t('kernel.versions.gitcodeSource')}
|
||||
onChange={(event) => void updateVersionSource(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{versionSourceError ? (
|
||||
<div className="version-mirror-error" role="alert">
|
||||
<AlertCircle size={15} />
|
||||
<span>{versionSourceError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Main Version Management Cards Grid */}
|
||||
<div className="version-grid">
|
||||
{/* Card 1: 桌面客户端 (Desktop Application) */}
|
||||
<article className="panel version-module-card app-module-card">
|
||||
<div className="version-card-top">
|
||||
<div className="version-card-title-group">
|
||||
<span className="version-card-icon app-icon" aria-hidden="true">
|
||||
<AppWindow size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{t('kernel.versions.appCardTitle')}</h2>
|
||||
<small className="version-card-subtitle">{t('appUpdate.title')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`state-pill ${appVersionStatusTone}`} title={appUpdateError || appVersionStatusLabel}>
|
||||
{appVersionStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="version-metrics-comparison">
|
||||
<div className="version-metric-tile">
|
||||
<span className="version-metric-label">{t('appUpdate.current')}</span>
|
||||
<strong className="version-metric-value">{currentAppVersion}</strong>
|
||||
<span className="version-metric-chip current">{t('kernel.versions.currentRunning')}</span>
|
||||
</div>
|
||||
|
||||
<div className="version-comparison-divider" aria-hidden="true">
|
||||
<ArrowRight size={18} />
|
||||
</div>
|
||||
|
||||
<div className={`version-metric-tile ${appHasUpdate ? 'has-update' : ''}`}>
|
||||
<span className="version-metric-label">{t('appUpdate.latest')}</span>
|
||||
<strong className="version-metric-value">
|
||||
{appUpdate ? (latestAppVersion || t('appUpdate.upToDate')) : (checkingAppUpdate ? t('appUpdate.checking') : t('common.detecting'))}
|
||||
</strong>
|
||||
<span className={`version-metric-chip ${appHasUpdate ? 'update' : 'latest'}`}>
|
||||
{appHasUpdate ? t('kernel.update.available') : t('kernel.update.latest')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="version-meta-bar">
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
<span>
|
||||
{appUpdate?.autoUpdateSupported
|
||||
? t('kernel.versions.autoUpdateSupported')
|
||||
: t('kernel.versions.autoUpdateManual')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{appUpdateError ? (
|
||||
<div className="version-alert-banner error" role="alert">
|
||||
<AlertCircle size={16} />
|
||||
<span>{appUpdateError}</span>
|
||||
</div>
|
||||
) : !appUpdate?.autoUpdateSupported ? (
|
||||
<div className="version-alert-banner neutral">
|
||||
<Info size={16} />
|
||||
<span>{t('appUpdate.manualFallback')}</span>
|
||||
</div>
|
||||
) : appHasUpdate ? (
|
||||
<div className="version-alert-banner update">
|
||||
<Download size={16} />
|
||||
<span>{t('appUpdate.badgeAvailable', { version: latestAppVersion })}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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 version-action-highlight"
|
||||
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>
|
||||
|
||||
{/* Card 2: 代理内核 (Proxy Core Kernel) */}
|
||||
<article className="panel version-module-card core-module-card">
|
||||
<div className="version-card-top">
|
||||
<div className="version-card-title-group">
|
||||
<span className="version-card-icon core-icon" aria-hidden="true">
|
||||
<Box size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{t('kernel.versions.coreCardTitle')}</h2>
|
||||
<small className="version-card-subtitle">{t('kernel.versions.title')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`state-pill ${coreVersionStatusTone}`} title={coreVersionStatusLabel}>
|
||||
{coreVersionStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="version-metrics-comparison">
|
||||
<div className="version-metric-tile">
|
||||
<span className="version-metric-label">{t('kernel.versions.current')}</span>
|
||||
<strong className="version-metric-value" title={currentVersion || t('kernel.status.notInstalled')}>
|
||||
{currentVersion || t('kernel.status.notInstalled')}
|
||||
</strong>
|
||||
<span className={`version-metric-chip ${coreInstalled ? 'current' : 'warning'}`}>
|
||||
{coreInstalled ? t('kernel.versions.currentInstalled') : t('kernel.status.notInstalled')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="version-comparison-divider" aria-hidden="true">
|
||||
<ArrowRight size={18} />
|
||||
</div>
|
||||
|
||||
<div className={`version-metric-tile ${coreHasUpdate ? 'has-update' : ''}`}>
|
||||
<span className="version-metric-label">{t('kernel.versions.latest')}</span>
|
||||
<strong 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')))}
|
||||
</strong>
|
||||
<span className={`version-metric-chip ${coreHasUpdate ? 'update' : 'latest'}`}>
|
||||
{coreHasUpdate ? t('kernel.update.available') : t('kernel.versions.latestCloud')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="version-runtime-chips">
|
||||
<div className="version-runtime-chip platform">
|
||||
<Cpu size={15} aria-hidden="true" />
|
||||
<span><strong>{t('kernel.versions.platform')}:</strong> {platformOsLabel} / {platformArchLabel}</span>
|
||||
</div>
|
||||
<div className={`version-runtime-chip status ${coreRunning ? 'running' : 'stopped'}`}>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
<span>{coreRunning ? t('kernel.status.running') : t('kernel.control.notRunning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestError ? (
|
||||
<div className="version-alert-banner error" role="alert">
|
||||
<AlertCircle size={16} />
|
||||
<span>{latestError}</span>
|
||||
</div>
|
||||
) : coreRunning ? (
|
||||
<div className="version-alert-banner info">
|
||||
<Info size={16} />
|
||||
<span>{t('kernel.versions.coreRunningNotice')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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 || currentVersion !== latestVersion) ? 'primary-button version-action-highlight' : 'secondary-button'}
|
||||
title={latestVersion ? t('kernel.versions.stopAndUpdateVersion', { version: latestVersion }) : t('kernel.versions.installLatest')}
|
||||
disabled={!latestVersion || busy}
|
||||
onClick={() => void installVersion(latestVersion)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Card 3: Codex 模型目录 (Model Catalog) */}
|
||||
<article className="panel version-module-card catalog-module-card">
|
||||
<div className="version-card-top">
|
||||
<div className="version-card-title-group">
|
||||
<span className="version-card-icon catalog-icon" aria-hidden="true">
|
||||
<Database size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{t('kernel.versions.catalogCardTitle')}</h2>
|
||||
<small className="version-card-subtitle">{t('appUpdate.catalog.label')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`state-pill ${catalogUpdateError ? 'error' : catalogUpdating ? 'update' : 'success'}`}>
|
||||
{catalogUpdating
|
||||
? t('appUpdate.catalog.updating')
|
||||
: catalogUpdateError
|
||||
? t('kernel.update.failed')
|
||||
: t('appUpdate.catalog.unchanged')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="version-catalog-body">
|
||||
<p className="version-catalog-description">
|
||||
{t('appUpdate.catalog.description')}
|
||||
</p>
|
||||
<p className="version-catalog-hint">
|
||||
{t('appUpdate.catalog.updateHint')}
|
||||
</p>
|
||||
|
||||
{catalogUpdateError ? (
|
||||
<div className="version-alert-banner error" role="alert">
|
||||
<AlertCircle size={16} />
|
||||
<span>{catalogUpdateError}</span>
|
||||
</div>
|
||||
) : catalogUpdateNotice ? (
|
||||
<div className="version-alert-banner success">
|
||||
<Check size={16} />
|
||||
<span>{catalogUpdateNotice}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="version-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={catalogUpdating || appUpdateTask.running || busy}
|
||||
onClick={() => void syncModelCatalog()}
|
||||
title={t('appUpdate.catalog.updateHint')}
|
||||
>
|
||||
<Database size={15} className={catalogUpdating ? 'spin' : ''} aria-hidden="true" />
|
||||
<span>
|
||||
{catalogUpdating
|
||||
? t('appUpdate.catalog.updating')
|
||||
: t('appUpdate.catalog.update')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* Installation Progress Modal Dialog */}
|
||||
{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}
|
||||
|
||||
{/* Floating Notice Toast */}
|
||||
{toastNotice ? (
|
||||
<div
|
||||
className={`config-toast ${toastNotice.tone}`}
|
||||
role="status"
|
||||
title={toastNotice.message}
|
||||
>
|
||||
{toastNotice.tone === 'success' ? (
|
||||
<Check size={18} aria-hidden="true" />
|
||||
) : toastNotice.tone === 'error' ? (
|
||||
<AlertCircle size={18} aria-hidden="true" />
|
||||
) : (
|
||||
<Info size={18} aria-hidden="true" />
|
||||
)}
|
||||
<span>{toastNotice.message}</span>
|
||||
</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`;
|
||||
}
|
||||
+363
-303
@@ -1011,311 +1011,443 @@ textarea:focus-visible {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Version Management Redesign
|
||||
========================================================================== */
|
||||
|
||||
.version-management-page {
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-page-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
/* Mirror & Acceleration Banner */
|
||||
.version-mirror-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
gap: 14px 18px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--ui-accent) 20%, var(--theme-e3e1db));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--theme-fffdf8) 85%, var(--ui-accent-soft));
|
||||
box-shadow: var(--ui-panel-shadow);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.version-mirror-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex: 1 1 300px;
|
||||
}
|
||||
|
||||
.version-mirror-icon-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
border-radius: 10px;
|
||||
color: var(--ui-accent-strong);
|
||||
background: color-mix(in srgb, var(--ui-accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.version-mirror-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-mirror-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.version-mirror-headline strong {
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.version-mirror-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.version-mirror-pill.active {
|
||||
color: var(--theme-2f6b3f);
|
||||
background: color-mix(in srgb, var(--theme-2f6b3f) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-2f6b3f) 28%, transparent);
|
||||
}
|
||||
|
||||
.version-mirror-pill.inactive {
|
||||
color: var(--theme-7a746c);
|
||||
background: var(--theme-f4f1ea);
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
}
|
||||
|
||||
.version-mirror-info p {
|
||||
margin: 0;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.version-mirror-toggle {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.version-mirror-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
color: var(--theme-9f2d20);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.version-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.version-module-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--ui-accent-soft) 62%, transparent), transparent 48%),
|
||||
var(--theme-fffdf8);
|
||||
background: var(--theme-fffdf8);
|
||||
box-shadow: var(--ui-panel-shadow);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.version-page-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.version-page-title-icon,
|
||||
.version-product-icon {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--ui-accent-strong);
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.version-page-title-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 1px solid color-mix(in srgb, var(--ui-accent) 22%, transparent);
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.version-page-title > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-page-title h1 {
|
||||
margin: 0;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 24px;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.version-page-title p {
|
||||
margin: 5px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-meta);
|
||||
line-height: var(--line-height-body);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-subpage-panel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.version-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.version-source-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px 18px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--ui-accent) 17%, var(--theme-e3e1db));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--theme-fffdf8) 82%, var(--ui-accent-soft));
|
||||
}
|
||||
|
||||
.version-source-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.version-source-copy > svg {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.version-source-copy > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.version-source-copy strong {
|
||||
color: var(--theme-39352f);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.version-source-copy span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: 1.4;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.version-source-error {
|
||||
.version-module-card.catalog-module-card {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.version-product-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
padding: 26px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-product-heading {
|
||||
.version-card-top {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.version-product-identity {
|
||||
.version-card-title-group {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-product-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
.version-card-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.version-product-icon.core {
|
||||
.version-card-icon.app-icon {
|
||||
color: var(--ui-accent-strong);
|
||||
background: var(--ui-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--ui-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.version-card-icon.core-icon {
|
||||
color: var(--theme-554075);
|
||||
background: var(--theme-f1ecfb);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-554075) 20%, transparent);
|
||||
}
|
||||
|
||||
.version-product-identity h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.version-card-icon.catalog-icon {
|
||||
color: var(--theme-3f6f98);
|
||||
background: var(--theme-f1f6fa);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-3f6f98) 20%, transparent);
|
||||
}
|
||||
|
||||
.version-product-heading .state-pill {
|
||||
min-width: 0;
|
||||
max-width: 52%;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.version-card-title-group h2 {
|
||||
margin: 0;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.version-comparison {
|
||||
.version-card-subtitle {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Metric Comparison & Tri-metrics */
|
||||
.version-metrics-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 118px;
|
||||
padding: 20px;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-f4f1ea);
|
||||
}
|
||||
|
||||
.version-comparison-arrow {
|
||||
|
||||
.version-comparison-divider {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--theme-9a958d);
|
||||
}
|
||||
|
||||
.version-value-block {
|
||||
display: grid;
|
||||
.version-metric-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.version-value-block span {
|
||||
.version-metric-label {
|
||||
color: var(--theme-8a847c);
|
||||
font-size: var(--font-size-meta);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.version-value-block strong {
|
||||
min-width: 0;
|
||||
.version-metric-value {
|
||||
overflow: hidden;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: var(--font-size-metric-compact);
|
||||
line-height: var(--line-height-tight);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-value-block.latest strong {
|
||||
.version-metric-tile.has-update .version-metric-value {
|
||||
color: var(--ui-accent-strong);
|
||||
}
|
||||
|
||||
.version-core-values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 118px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-f4f1ea);
|
||||
}
|
||||
|
||||
.version-core-values .version-value-block {
|
||||
align-content: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.version-core-values .version-value-block + .version-value-block {
|
||||
border-left: 1px solid var(--theme-e3e1db);
|
||||
}
|
||||
|
||||
.version-core-values .version-value-block strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.version-card-support {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
.version-metric-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-height: 58px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 10px;
|
||||
background: var(--theme-faf8f2);
|
||||
align-self: flex-start;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.version-card-support > svg {
|
||||
.version-metric-chip.current {
|
||||
color: var(--theme-5f5a53);
|
||||
background: var(--theme-e8e5dc);
|
||||
}
|
||||
|
||||
.version-metric-chip.latest {
|
||||
color: var(--theme-2f6b3f);
|
||||
background: color-mix(in srgb, var(--theme-2f6b3f) 14%, transparent);
|
||||
}
|
||||
|
||||
.version-metric-chip.update {
|
||||
color: var(--ui-accent-strong);
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
|
||||
.version-metric-chip.warning {
|
||||
color: var(--theme-c77b30);
|
||||
background: color-mix(in srgb, var(--theme-c77b30) 14%, transparent);
|
||||
}
|
||||
|
||||
.version-metric-chip.bundled {
|
||||
color: var(--theme-554075);
|
||||
background: color-mix(in srgb, var(--theme-554075) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Feature & Meta bars */
|
||||
.version-meta-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--theme-faf8f2);
|
||||
border: 1px solid var(--theme-e8e4dc);
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.version-meta-bar > svg {
|
||||
flex: none;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.version-runtime-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-runtime-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--theme-faf8f2);
|
||||
border: 1px solid var(--theme-e8e4dc);
|
||||
font-size: 12px;
|
||||
color: var(--theme-5f5a53);
|
||||
}
|
||||
|
||||
.version-runtime-chip.status.running {
|
||||
color: var(--theme-2f6b3f);
|
||||
background: color-mix(in srgb, var(--theme-2f6b3f) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--theme-2f6b3f) 24%, transparent);
|
||||
}
|
||||
|
||||
.version-runtime-chip.status.stopped {
|
||||
color: var(--theme-7a746c);
|
||||
}
|
||||
|
||||
.version-card-support > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.version-card-support strong,
|
||||
.version-card-support span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.version-runtime-chip.status.running .status-dot {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-2f6b3f) 30%, transparent);
|
||||
animation: pulse-dot 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
/* Alert / Notification Banners */
|
||||
.version-alert-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.version-alert-banner > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.version-alert-banner.info {
|
||||
color: var(--theme-5f5a53);
|
||||
background: var(--theme-faf9f5);
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
}
|
||||
|
||||
.version-alert-banner.neutral {
|
||||
color: var(--theme-6d6760);
|
||||
background: var(--theme-f4f1ea);
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
}
|
||||
|
||||
.version-alert-banner.update {
|
||||
color: var(--ui-accent-strong);
|
||||
background: var(--ui-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--ui-accent) 26%, transparent);
|
||||
}
|
||||
|
||||
.version-alert-banner.warning {
|
||||
color: var(--theme-8b5b21);
|
||||
background: color-mix(in srgb, var(--theme-8b5b21) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-8b5b21) 24%, transparent);
|
||||
}
|
||||
|
||||
.version-alert-banner.error {
|
||||
color: var(--theme-9f2d20);
|
||||
background: var(--theme-fff1ee);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-9f2d20) 24%, transparent);
|
||||
}
|
||||
|
||||
.version-alert-banner.success {
|
||||
color: var(--theme-2f6b3f);
|
||||
background: var(--theme-eef7f0);
|
||||
border: 1px solid color-mix(in srgb, var(--theme-2f6b3f) 24%, transparent);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.version-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.version-card-actions.core-actions {
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.version-card-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-card-support strong {
|
||||
color: var(--theme-39352f);
|
||||
font-size: var(--font-size-meta);
|
||||
.version-action-highlight {
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--ui-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.version-card-support span {
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-caption);
|
||||
/* Catalog Card Body */
|
||||
.version-catalog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-card-support span.success {
|
||||
color: var(--theme-2f6b3f);
|
||||
.version-catalog-description {
|
||||
margin: 0;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.version-card-support span.error {
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
|
||||
.version-card-note {
|
||||
align-self: start;
|
||||
.version-catalog-hint {
|
||||
margin: 0;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
.version-card-note-slot {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.version-card-note.error {
|
||||
color: var(--theme-9f2d20);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.client-api-panel {
|
||||
@@ -1517,13 +1649,6 @@ textarea:focus-visible {
|
||||
grid-template-rows: repeat(3, auto);
|
||||
}
|
||||
|
||||
.version-management-layout > .version-product-card {
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.version-management-layout > .version-product-card > .panel-action-row {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.kernel-layout > .panel > .panel-heading {
|
||||
min-height: 0;
|
||||
@@ -1628,14 +1753,6 @@ textarea:focus-visible {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.button-row.version-action-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: unset;
|
||||
grid-auto-rows: 40px;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control-action-row > button {
|
||||
width: 100%;
|
||||
@@ -1651,35 +1768,8 @@ textarea:focus-visible {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.software-update-actions {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: 40px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.software-update-actions > button:last-child:nth-child(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.software-update-actions > button,
|
||||
.version-action-row > button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
gap: 7px;
|
||||
padding: 0 10px;
|
||||
line-height: normal;
|
||||
overflow-wrap: normal;
|
||||
text-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container workspace (min-width: 840px) {
|
||||
.version-page-header {
|
||||
grid-template-columns: minmax(260px, 0.8fr) minmax(360px, 1.2fr);
|
||||
}
|
||||
|
||||
.home-page {
|
||||
grid-template-columns: minmax(320px, 0.8fr) minmax(0, 1.2fr);
|
||||
grid-auto-rows: 1fr;
|
||||
@@ -1723,16 +1813,6 @@ textarea:focus-visible {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.version-management-layout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.version-management-layout > .panel {
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-detail-grid,
|
||||
.home-page > .client-api-panel > .client-api-grid {
|
||||
align-content: space-evenly;
|
||||
@@ -1743,37 +1823,17 @@ textarea:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
@container workspace (max-width: 700px) {
|
||||
.version-page-header {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.version-page-title p {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.version-product-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.version-product-heading .state-pill {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.version-core-values {
|
||||
@container workspace (max-width: 760px) {
|
||||
.version-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.version-core-values .version-value-block {
|
||||
padding: 14px 18px;
|
||||
.version-module-card.catalog-module-card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.version-core-values .version-value-block + .version-value-block {
|
||||
border-top: 1px solid var(--theme-e3e1db);
|
||||
border-left: 0;
|
||||
.version-card-actions.core-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { displayAppVersion } from '../src/pages/VersionManagementPage';
|
||||
import { appUpdateIndicatorState } from '../src/appUpdateModel';
|
||||
|
||||
describe('VersionManagement helper functions', () => {
|
||||
it('formats app versions with v prefix properly', () => {
|
||||
expect(displayAppVersion('1.0.0')).toBe('v1.0.0');
|
||||
expect(displayAppVersion('v1.2.3')).toBe('v1.2.3');
|
||||
expect(displayAppVersion(' 2.3.4 ')).toBe('v2.3.4');
|
||||
expect(displayAppVersion(' v3.4.5 ')).toBe('v3.4.5');
|
||||
});
|
||||
|
||||
it('resolves update indicators based on availability and processing state', () => {
|
||||
expect(appUpdateIndicatorState(true, false)).toBe('available');
|
||||
expect(appUpdateIndicatorState(true, true)).toBe('processing');
|
||||
expect(appUpdateIndicatorState(false, true)).toBe('processing');
|
||||
expect(appUpdateIndicatorState(false, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user