Files
EasyCLIProxyAPI/src/pages/Kernel.tsx
T
lzt404 ae18553e1d style(home): convert API access cards to horizontal rows and refine layout
- Redesign API access format cards into full-width horizontal rows, one per line
- Enforce strictly equal heights between control and API access panels
- Keep control action buttons in a single row without wrapping at 1247x703
- Tighten spacing between brand heading and URL input while keeping `http://` prefixes vertically aligned
- Unify card surfaces with a clean, bright, high-contrast theme across light and dark modes
- Enable click-to-copy directly on URL code blocks with visual feedback indicators
2026-09-21 21:58:01 +08:00

391 lines
14 KiB
TypeScript

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 {
Check,
Copy,
Eye,
EyeOff,
} from 'lucide-react';
import { type CoreStatus, useCoreRuntime } from '../coreRuntime';
import openaiIcon from '../assets/icons/openai-light.svg';
import claudeIcon from '../assets/icons/claude.svg';
import geminiIcon from '../assets/icons/gemini.svg';
import { clientApiProfiles } from '../services/clientAccess';
import { useI18n } from '../i18n';
import { useAppUpdate } from '../appUpdate';
import { FloatingNotice, useAppNotice } from '../appNotice';
import { VersionManagementPage, displayAppVersion } from './VersionManagementPage';
type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process';
type GuiSettings = {
host: string;
port: number;
runOnStartup: boolean;
};
type CoreConfigSummary = {
apiKeys: Array<{ apiKey: string }>;
};
type CoreTlsSettings = {
enabled: boolean;
cert: string;
key: string;
};
export type KernelView = 'home' | 'versions';
export function KernelPage({ view = 'home' }: { view?: KernelView }) {
if (view === 'versions') {
return <VersionManagementPage />;
}
const { t } = useI18n();
const { info: appUpdate } = useAppUpdate();
const {
status: coreStatus,
statusError,
refreshStatus,
publishStatus,
} = useCoreRuntime();
const [installedAppVersion, setInstalledAppVersion] = useState('');
const [listenHost, setListenHost] = useState('127.0.0.1');
const [customPort, setCustomPort] = useState('8317');
const [processBusy, setProcessBusy] = useState(false);
const processFeedback = useAppNotice();
const { showNotice: showProcessNotice, clearNotice: clearProcessNotice } = processFeedback;
const copyFeedback = useAppNotice();
const [copiedApiField, setCopiedApiField] = useState('');
const [homeApiKey, setHomeApiKey] = useState<string | null | undefined>(undefined);
const [homeApiKeyError, setHomeApiKeyError] = useState(false);
const [showHomeApiKey, setShowHomeApiKey] = useState(false);
const [tlsEnabled, setTlsEnabled] = useState(false);
const savedPortRef = useRef(8317);
const copiedApiTimerRef = useRef<number | null>(null);
useEffect(() => {
let disposed = false;
let unlistenConfig: (() => void) | null = null;
void listen('config-files-changed', () => {
if (disposed) return;
void loadGuiSettings();
void loadTlsSettings();
void refreshStatus();
void loadHomeApiKey();
}).then((stop) => {
if (disposed) stop();
else unlistenConfig = stop;
});
loadGuiSettings();
void loadTlsSettings();
void getVersion()
.then((version) => {
if (!disposed) setInstalledAppVersion(version);
})
.catch(() => undefined);
void loadHomeApiKey();
return () => {
disposed = true;
unlistenConfig?.();
if (copiedApiTimerRef.current !== null) {
window.clearTimeout(copiedApiTimerRef.current);
}
};
}, []);
const runCoreProcessCommand = async (command: CoreProcessCommand) => {
const actionLabel =
command === 'start_core_process'
? t('kernel.action.start')
: command === 'stop_core_process'
? t('kernel.action.stop')
: t('kernel.action.restart');
setProcessBusy(true);
clearProcessNotice();
try {
const result = await invoke<CoreStatus>(command);
publishStatus(result);
if (command === 'restart_core_process') {
showProcessNotice({ key: 'kernel.notice.restarted' }, 'success');
}
return true;
} catch (error) {
const errorMessage = String(error);
await refreshStatus();
showProcessNotice(
{ key: 'kernel.notice.actionFailed', variables: { action: actionLabel, error: errorMessage } },
'error',
);
return false;
} finally {
setProcessBusy(false);
}
};
const loadGuiSettings = async () => {
try {
const settings = await invoke<GuiSettings>('get_gui_settings');
setListenHost(settings.host);
setCustomPort(String(settings.port));
savedPortRef.current = settings.port;
} catch {}
};
const loadHomeApiKey = async () => {
try {
const settings = await invoke<CoreConfigSummary>('get_core_config_settings');
setHomeApiKey(settings.apiKeys[0]?.apiKey ?? null);
setHomeApiKeyError(false);
} catch {
setHomeApiKey(undefined);
setHomeApiKeyError(true);
}
};
const loadTlsSettings = async () => {
try {
const settings = await invoke<CoreTlsSettings>('get_core_tls_settings');
setTlsEnabled(settings.enabled);
} catch {
setTlsEnabled(false);
}
};
const copyApiValue = async (value: string, field: string) => {
copyFeedback.clearNotice();
try {
await navigator.clipboard.writeText(value);
setCopiedApiField(field);
if (copiedApiTimerRef.current !== null) {
window.clearTimeout(copiedApiTimerRef.current);
}
copiedApiTimerRef.current = window.setTimeout(() => {
setCopiedApiField('');
copiedApiTimerRef.current = null;
}, 1800);
} catch {
copyFeedback.showNotice({ key: 'kernel.notice.copyFailed' }, 'error');
}
};
const currentVersion = coreStatus?.currentVersion ?? '';
const coreInstalled = Boolean(coreStatus?.installed);
const coreRunning = Boolean(coreStatus?.running);
const coreReady = Boolean(coreStatus?.ready);
const coreProcessBusy = processBusy || Boolean(coreStatus?.starting);
const statusTone = statusError ? 'error' : coreRunning ? 'success' : 'neutral';
const statusLabel = coreStatus
? coreRunning
? t('kernel.status.running')
: coreInstalled
? t('kernel.status.stopped')
: t('kernel.status.notInstalled')
: statusError
? t('common.detectionFailed')
: t('common.detecting');
const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion;
const currentAppVersion = resolvedAppVersion
? displayAppVersion(resolvedAppVersion)
: t('common.detecting');
const apiPort = Number(customPort);
const apiProfiles = clientApiProfiles(
Number.isInteger(apiPort) && apiPort >= 1 && apiPort <= 65535
? apiPort
: savedPortRef.current,
tlsEnabled,
listenHost,
);
const apiProfileIcons = {
openai: openaiIcon,
claude: claudeIcon,
gemini: geminiIcon,
} as const;
return (
<section className="page kernel-page home-page">
<div className="kernel-layout home-layout">
<div className="panel control-panel">
<div className="panel-heading">
<div>
<h2>{t('kernel.control.title')}</h2>
</div>
<span className={`state-pill ${statusTone}`} title={statusError || undefined} role="status">
{coreProcessBusy ? t('common.processing') : statusLabel}
</span>
</div>
<dl className="panel-detail-grid">
<div className="panel-detail-row">
<dt>{t('kernel.control.installStatus')}</dt>
<dd>{coreStatus ? (coreInstalled ? t('kernel.control.installed') : t('kernel.status.notInstalled')) : t('common.detecting')}</dd>
</div>
<div className="panel-detail-row">
<dt>{t('kernel.control.runStatus')}</dt>
<dd>{coreStatus ? (coreRunning ? t('kernel.status.running') : t('kernel.control.notRunning')) : t('common.detecting')}</dd>
</div>
<div className="panel-detail-row">
<dt>{t('kernel.control.pid')}</dt>
<dd>{coreStatus?.processId || t('kernel.control.noPid')}</dd>
</div>
<div className="panel-detail-row">
<dt>{t('kernel.overview.coreVersion')}</dt>
<dd>
{currentVersion
|| (coreInstalled ? t('common.unavailable') : t('kernel.status.notInstalled'))}
</dd>
</div>
<div className="panel-detail-row">
<dt>{t('kernel.overview.appVersion')}</dt>
<dd>{currentAppVersion}</dd>
</div>
</dl>
<div className="button-row panel-action-row control-action-row">
<button
type="button"
className={coreRunning ? 'danger-button' : 'primary-button'}
disabled={!coreInstalled || coreProcessBusy}
onClick={() =>
void runCoreProcessCommand(
coreRunning ? 'stop_core_process' : 'start_core_process',
)
}
>
{coreProcessBusy ? t('common.processing') : coreRunning ? t('kernel.action.stop') : t('kernel.action.start')}
</button>
<button
type="button"
className="secondary-button"
disabled={!coreInstalled || !coreRunning || coreProcessBusy}
onClick={() =>
void runCoreProcessCommand('restart_core_process')
}
>
{t('kernel.action.restart')}
</button>
<button
type="button"
className="secondary-button"
disabled={coreProcessBusy}
onClick={() => void refreshStatus()}
>
{t('kernel.control.refresh')}
</button>
</div>
<FloatingNotice key={processFeedback.revision} notice={processFeedback.notice} onDismiss={processFeedback.clearNotice} />
</div>
</div>
<section className="panel client-api-panel">
<div className="panel-heading client-api-heading">
<div>
<h2>{t('app.nav.api')}</h2>
<div className="client-api-key-actions">
{homeApiKeyError ? (
<span className="client-api-meta error">{t('common.detectionFailed')}</span>
) : homeApiKey ? (
<>
<span className="client-api-meta">{t('kernel.access.firstKey')}</span>
<code className="client-api-key-code">
{showHomeApiKey ? homeApiKey : '••••••••••••••••'}
</code>
<button
type="button"
className="icon-button quiet"
onClick={() => setShowHomeApiKey((current) => !current)}
title={showHomeApiKey ? t('config.keys.hide') : t('config.keys.show')}
aria-label={showHomeApiKey ? t('config.keys.hide') : t('config.keys.show')}
>
{showHomeApiKey ? <EyeOff size={14} aria-hidden="true" /> : <Eye size={14} aria-hidden="true" />}
</button>
<button
type="button"
className={`icon-button quiet ${copiedApiField === 'home:apikey' ? 'copied' : ''}`}
onClick={() => void copyApiValue(homeApiKey, 'home:apikey')}
title={copiedApiField === 'home:apikey' ? t('config.notice.keyCopied') : t('config.keys.copy')}
aria-label={copiedApiField === 'home:apikey' ? t('config.notice.keyCopied') : t('config.keys.copy')}
>
{copiedApiField === 'home:apikey' ? <Check size={14} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
</button>
</>
) : homeApiKey === null ? (
<span className="client-api-meta quiet">{t('kernel.access.noConfiguredKey')}</span>
) : (
<span className="client-api-meta quiet">{t('common.loading')}</span>
)}
</div>
</div>
<span className={`state-pill ${coreReady ? 'success' : 'neutral'}`}>
{coreReady ? t('kernel.access.connectable') : t('kernel.access.waiting')}
</span>
</div>
<FloatingNotice key={copyFeedback.revision} notice={copyFeedback.notice} onDismiss={copyFeedback.clearNotice} />
<div className="client-api-grid">
{apiProfiles.map((profile) => (
<article key={profile.id} className={`client-api-card ${profile.id}`}>
<div className="client-api-card-heading">
<span className="client-api-logo">
<img src={apiProfileIcons[profile.id]} alt="" />
</span>
<div>
<strong>{profile.name}</strong>
<span className="client-api-format-tag">{profile.description}</span>
</div>
</div>
<div className="client-api-values">
<div className="client-api-value-row">
<span>{t('kernel.access.apiUrl')}</span>
<code
title={profile.baseUrl}
onClick={() =>
void copyApiValue(
profile.baseUrl,
`${profile.id}:base`,
)
}
>
{profile.baseUrl}
</code>
<button
type="button"
className={`icon-button quiet ${copiedApiField === `${profile.id}:base` ? 'copied' : ''}`}
onClick={() =>
void copyApiValue(
profile.baseUrl,
`${profile.id}:base`,
)
}
title={t(copiedApiField === `${profile.id}:base` ? 'kernel.access.apiCopied' : 'kernel.access.copyApi', { name: profile.name })}
aria-label={t(copiedApiField === `${profile.id}:base` ? 'kernel.access.apiCopied' : 'kernel.access.copyApi', { name: profile.name })}
>
{copiedApiField === `${profile.id}:base` ? (
<Check size={15} aria-hidden="true" />
) : (
<Copy size={15} aria-hidden="true" />
)}
</button>
</div>
</div>
</article>
))}
</div>
</section>
</section>
);
}