Redesign summary panel and compact window layout
This commit is contained in:
180
src/app/App.tsx
180
src/app/App.tsx
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, ShieldAlert, Trash2, Wand2 } from 'lucide-react';
|
||||
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react';
|
||||
import {
|
||||
applyProfiles,
|
||||
fetchSingBoxSubscription,
|
||||
@@ -50,6 +50,7 @@ type ServiceVisualState = 'active' | 'settling' | null;
|
||||
type PanelId = 'summary' | 'proxifyre' | 'proxy';
|
||||
type StatusTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||
type TabTransitionDirection = 'left' | 'right';
|
||||
type SummaryRouteFlow = 'proxy' | 'direct' | 'idle';
|
||||
|
||||
interface DraftItem {
|
||||
id: string;
|
||||
@@ -140,7 +141,9 @@ const MAIN_TARGET_ID = 'main-proxy';
|
||||
const MAIN_PROFILE_ID = 'main-profile';
|
||||
const LOCAL_SINGBOX_TARGET_ID = 'local-singbox';
|
||||
const LOG_VISIBLE_MS = 6500;
|
||||
const PANEL_ORDER: PanelId[] = ['summary', 'proxifyre', 'proxy'];
|
||||
const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy'];
|
||||
const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href;
|
||||
const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href;
|
||||
|
||||
const fallbackComponents: ComponentStatus[] = [
|
||||
{
|
||||
@@ -993,17 +996,15 @@ export function App() {
|
||||
|
||||
function renderTabs() {
|
||||
const tabs: Array<{ id: PanelId; label: string }> = [
|
||||
{ id: 'summary', label: 'Сводка' },
|
||||
{ id: 'proxifyre', label: 'ProxiFyre' },
|
||||
{ id: 'summary', label: 'ProxyWarden' },
|
||||
{ id: 'proxy', label: 'VPN / Прокси' },
|
||||
];
|
||||
|
||||
return <Tabs items={tabs} activeId={activePanel} onChange={switchPanel} ariaLabel="Разделы Proxy" />;
|
||||
return <Tabs items={tabs} activeId={activePanel} onChange={switchPanel} ariaLabel="Разделы ProxyWarden" />;
|
||||
}
|
||||
|
||||
function renderSummaryPanel() {
|
||||
const appliedRouteMode = appliedSnapshot?.routeMode ?? routeMode;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tab-panel summary-panel"
|
||||
@@ -1011,39 +1012,11 @@ export function App() {
|
||||
id="panel-summary"
|
||||
aria-labelledby="tab-summary"
|
||||
>
|
||||
{renderSummaryStatusControl()}
|
||||
|
||||
<div className="summary-readout">
|
||||
<div className="summary-readout-head">
|
||||
<div>
|
||||
<span>Применено сейчас</span>
|
||||
<strong>{appliedSnapshot ? routeModeLabel(appliedRouteMode) : 'Состояние загружается'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="summary-lines">
|
||||
<div>
|
||||
<dt>ProxiFyre</dt>
|
||||
<dd>{proxyfierTitle(proxyfier, isDetectingComponents)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Маршрут</dt>
|
||||
<dd>{snapshotRouteLine(appliedSnapshot, singBoxStatus)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>VPN сервер</dt>
|
||||
<dd>{snapshotServerText(appliedSnapshot)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Приложения</dt>
|
||||
<dd>{snapshotItemsText(appliedSnapshot)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Конфиг</dt>
|
||||
<dd>{generatedConfigPath || 'Примененный config еще не создан.'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h1 className="summary-brand-title">ProxyWarden</h1>
|
||||
<div className="summary-main">
|
||||
{renderSummaryStatusControl()}
|
||||
{renderRouteChain('vertical')}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1230,30 +1203,28 @@ export function App() {
|
||||
const installed = Boolean(proxyfier?.installed);
|
||||
const running = Boolean(proxyfier?.running);
|
||||
const working = serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart';
|
||||
const buttonLabel = !installed ? 'Не установлен' : running ? 'Отключить' : 'Включить';
|
||||
const stateLabel = working || systemSummary.tone === 'checking'
|
||||
? 'Проверяю'
|
||||
: systemSummary.tone === 'ok' ? 'Работает' : 'Не работает';
|
||||
const buttonAriaLabel = !installed
|
||||
? 'ProxiFyre не установлен'
|
||||
: running ? 'Отключить ProxyWarden' : 'Включить ProxyWarden';
|
||||
const imageSrc = running ? proxyWardenToggleOnImage : proxyWardenToggleOffImage;
|
||||
|
||||
return (
|
||||
<div className={`summary-status-control ${systemSummary.tone} ${running ? 'on' : 'off'}`}>
|
||||
{systemSummary.tone === 'checking' ? <BusyRing /> : null}
|
||||
<span className={`summary-status-dot ${systemSummary.tone}`} aria-hidden="true" />
|
||||
<div className="summary-status-copy">
|
||||
<span>Состояние маршрута</span>
|
||||
<strong>{systemSummary.title}</strong>
|
||||
<p>{summaryControlText(appliedSnapshot, proxyfier, isDetectingComponents)}</p>
|
||||
</div>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant={!installed ? 'neutral' : running ? 'danger' : 'primary'}
|
||||
size="lg"
|
||||
className="summary-status-button"
|
||||
leftIcon={<Power size={18} strokeWidth={2} />}
|
||||
className="summary-toggle-button"
|
||||
onClick={() => void setProxiFyreServiceRunning(!running)}
|
||||
disabled={!installed || isDetectingComponents || Boolean(serviceAction)}
|
||||
loading={working}
|
||||
loadingLabel={serviceAction === 'restart' ? 'Перезапускаю' : running ? 'Отключаю' : 'Включаю'}
|
||||
aria-label={buttonAriaLabel}
|
||||
aria-pressed={installed ? running : undefined}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
{systemSummary.tone === 'checking' || working ? <BusyRing /> : null}
|
||||
<img src={imageSrc} alt="" draggable={false} />
|
||||
</button>
|
||||
<strong className={`summary-state-label ${systemSummary.tone}`}>{stateLabel}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1602,13 +1573,12 @@ export function App() {
|
||||
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
|
||||
</section>
|
||||
|
||||
{renderRouteChain()}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRouteChain() {
|
||||
const segments = routeChainSegments({
|
||||
function renderRouteChain(variant: 'horizontal' | 'vertical' = 'horizontal') {
|
||||
const input: RouteChainInput = {
|
||||
routeMode,
|
||||
proxyInput,
|
||||
proxyfier,
|
||||
@@ -1617,23 +1587,41 @@ export function App() {
|
||||
selectedServer,
|
||||
appCount: items.length,
|
||||
isDetectingComponents,
|
||||
});
|
||||
};
|
||||
const flow = variant === 'vertical' ? summaryRouteFlow(input) : 'proxy';
|
||||
const segments = variant === 'vertical'
|
||||
? summaryRouteChainSegments(input, flow)
|
||||
: routeChainSegments(input);
|
||||
const isVertical = variant === 'vertical';
|
||||
|
||||
return (
|
||||
<section className="route-chain" aria-label="Текущий маршрут">
|
||||
<section className={`route-chain route-chain--${variant} route-chain--${flow}`} aria-label="Текущий маршрут">
|
||||
{isVertical ? (
|
||||
<span className="route-chain-rail" aria-hidden="true">
|
||||
{flow === 'idle' ? null : (
|
||||
<>
|
||||
<span className="route-chain-packet route-chain-packet--one" />
|
||||
<span className="route-chain-packet route-chain-packet--two" />
|
||||
<span className="route-chain-packet route-chain-packet--three" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{segments.map((segment, index) => (
|
||||
<DetailsPopover
|
||||
className={`route-chain-segment ${segment.tone}`}
|
||||
details={segment.details}
|
||||
popoverLabel={segment.label}
|
||||
align={index >= segments.length - 2 ? 'end' : 'start'}
|
||||
align={isVertical || index >= segments.length - 2 ? 'end' : 'start'}
|
||||
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join('. ')}`}
|
||||
key={segment.id}
|
||||
>
|
||||
<span className="route-chain-dot" aria-hidden="true" />
|
||||
<span>{segment.label}</span>
|
||||
<strong>{segment.value}</strong>
|
||||
{index < segments.length - 1 ? <span className="route-chain-arrow" aria-hidden="true">-></span> : null}
|
||||
{index < segments.length - 1 ? (
|
||||
<span className="route-chain-arrow" aria-hidden="true">{isVertical ? '↓' : '->'}</span>
|
||||
) : null}
|
||||
</DetailsPopover>
|
||||
))}
|
||||
</section>
|
||||
@@ -1656,23 +1644,6 @@ export function App() {
|
||||
style={shellStyle}
|
||||
>
|
||||
<section className="simple-panel">
|
||||
<header className="simple-header">
|
||||
<div>
|
||||
<h1>ProxyWarden</h1>
|
||||
<small>Proxy для приложений</small>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
loading={isLoading || isDetectingComponents}
|
||||
loadingLabel={isLoading ? 'Загружаю' : 'Проверяю'}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{renderTabs()}
|
||||
<div className={`tab-panel-frame swipe-${tabTransitionDirection}`} key={activePanel}>
|
||||
{renderActivePanel()}
|
||||
@@ -2111,6 +2082,57 @@ function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView {
|
||||
};
|
||||
}
|
||||
|
||||
function summaryRouteFlow(input: RouteChainInput): SummaryRouteFlow {
|
||||
if (input.isDetectingComponents || input.appCount <= 0) return 'idle';
|
||||
if (!input.proxyfier?.running) return 'direct';
|
||||
return 'proxy';
|
||||
}
|
||||
|
||||
function summaryRouteChainSegments(input: RouteChainInput, flow: SummaryRouteFlow): RouteChainSegment[] {
|
||||
if (flow === 'proxy') return routeChainSegments(input);
|
||||
|
||||
const appSegment: RouteChainSegment = {
|
||||
id: 'apps',
|
||||
label: 'Приложения',
|
||||
value: appCountCompact(input.appCount),
|
||||
tone: input.appCount > 0 ? 'ok' : 'warning',
|
||||
details: [
|
||||
input.appCount > 0 ? appCountText(input.appCount) : 'Добавь хотя бы одно приложение на вкладке ProxiFyre.',
|
||||
],
|
||||
};
|
||||
|
||||
if (flow === 'idle') {
|
||||
return [
|
||||
appSegment,
|
||||
{
|
||||
id: 'idle',
|
||||
label: 'Трафик',
|
||||
value: input.isDetectingComponents ? 'проверяю' : 'ожидает приложения',
|
||||
tone: input.isDetectingComponents ? 'checking' : 'warning',
|
||||
details: input.isDetectingComponents
|
||||
? ['Проверяю ProxiFyre и текущий маршрут.']
|
||||
: ['Пока нет выбранных приложений, ProxyWarden ничего не маршрутизирует.'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
appSegment,
|
||||
{
|
||||
id: 'direct',
|
||||
label: 'Интернет',
|
||||
value: 'напрямую',
|
||||
tone: 'warning',
|
||||
details: [
|
||||
input.proxyfier?.installed
|
||||
? 'ProxiFyre остановлен, поэтому выбранные приложения не перехватываются.'
|
||||
: 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.',
|
||||
'Пакеты идут обычным системным маршрутом без SOCKS5.',
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function routeChainSegments(input: RouteChainInput): RouteChainSegment[] {
|
||||
const proxyValidation = input.routeMode === 'external' && input.proxyInput.trim()
|
||||
? safeProxyError(input.proxyInput)
|
||||
|
||||
Reference in New Issue
Block a user