Expand README with architecture and setup details
This commit is contained in:
235
src/api/tauriCommands.ts
Normal file
235
src/api/tauriCommands.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type {
|
||||
ActivityEntry,
|
||||
ComponentStatus,
|
||||
LocalSingBoxConfig,
|
||||
Profile,
|
||||
ProfileInput,
|
||||
SubscriptionCache,
|
||||
SubscriptionServer,
|
||||
Target,
|
||||
TargetInput,
|
||||
} from '../domain/types';
|
||||
|
||||
export interface CommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
routeLine: string;
|
||||
activeProfileCount: number;
|
||||
routedAppCount: number;
|
||||
activeTarget?: Target;
|
||||
components: ComponentStatus[];
|
||||
recentActivity: ActivityEntry[];
|
||||
generatedConfigPath: string;
|
||||
}
|
||||
|
||||
export interface SavedStateResponse {
|
||||
profiles: Profile[];
|
||||
targets: Target[];
|
||||
generatedConfigPath: string;
|
||||
}
|
||||
|
||||
export interface ProxiFyreSetupItem {
|
||||
id: string;
|
||||
name: string;
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface ProxiFyreSetupStatus {
|
||||
ready: boolean;
|
||||
missingCount: number;
|
||||
items: ProxiFyreSetupItem[];
|
||||
}
|
||||
|
||||
export type SingBoxSetupItem = ProxiFyreSetupItem;
|
||||
|
||||
export interface SingBoxSetupStatus {
|
||||
ready: boolean;
|
||||
missingCount: number;
|
||||
items: SingBoxSetupItem[];
|
||||
}
|
||||
|
||||
export interface LocalSingBoxStatusResponse {
|
||||
config: LocalSingBoxConfig;
|
||||
cache?: SubscriptionCache;
|
||||
component: ComponentStatus;
|
||||
generatedConfigPath: string;
|
||||
lanListenHost?: string;
|
||||
}
|
||||
|
||||
export interface PingServerResponse {
|
||||
tag: string;
|
||||
server: string;
|
||||
serverPort: number;
|
||||
ok: boolean;
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GenerateSingBoxConfigResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
adapterId: string;
|
||||
generatedConfigPath: string;
|
||||
selectedServerTag: string;
|
||||
listenHost: string;
|
||||
listenPort: number;
|
||||
check?: {
|
||||
checked: boolean;
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
activity: ActivityEntry;
|
||||
}
|
||||
|
||||
export interface HelperApplyResult {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
action: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApplyProfilesResponse {
|
||||
success: boolean;
|
||||
changed: boolean;
|
||||
message: string;
|
||||
adapterId: string;
|
||||
generatedConfigPath: string;
|
||||
enabledProfiles: number;
|
||||
routedApps: number;
|
||||
helper: HelperApplyResult;
|
||||
activity: ActivityEntry;
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return invoke<StatusResponse>('get_status');
|
||||
}
|
||||
|
||||
export function getSavedState(): Promise<SavedStateResponse> {
|
||||
return invoke<SavedStateResponse>('get_saved_state');
|
||||
}
|
||||
|
||||
export function getProfiles(): Promise<Profile[]> {
|
||||
return invoke<Profile[]>('get_profiles');
|
||||
}
|
||||
|
||||
export function saveProfile(input: ProfileInput): Promise<Profile> {
|
||||
return invoke<Profile>('save_profile', { input });
|
||||
}
|
||||
|
||||
export function getTargets(): Promise<Target[]> {
|
||||
return invoke<Target[]>('get_targets');
|
||||
}
|
||||
|
||||
export function saveTarget(input: TargetInput): Promise<Target> {
|
||||
return invoke<Target>('save_target', { input });
|
||||
}
|
||||
|
||||
export function getComponents(): Promise<ComponentStatus[]> {
|
||||
return invoke<ComponentStatus[]>('get_components');
|
||||
}
|
||||
|
||||
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
|
||||
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
|
||||
}
|
||||
|
||||
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
|
||||
}
|
||||
|
||||
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
|
||||
return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
|
||||
}
|
||||
|
||||
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
|
||||
input: { subscriptionUrl },
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription');
|
||||
}
|
||||
|
||||
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
|
||||
}
|
||||
|
||||
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
|
||||
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
|
||||
input: {
|
||||
tag: server.tag,
|
||||
server: server.server,
|
||||
serverPort: server.serverPort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>('ping_singbox_server', {
|
||||
input: { tag },
|
||||
});
|
||||
}
|
||||
|
||||
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
|
||||
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
|
||||
}
|
||||
|
||||
export function pingProxyTarget(host: string, port: number): Promise<PingServerResponse> {
|
||||
return invoke<PingServerResponse>('ping_proxy_target', {
|
||||
input: { host, port },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
|
||||
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config');
|
||||
}
|
||||
|
||||
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||
}
|
||||
|
||||
export function openConfigLocation(): Promise<string> {
|
||||
return invoke<string>('open_config_location');
|
||||
}
|
||||
|
||||
export function startProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_proxifyre_service');
|
||||
}
|
||||
|
||||
export function stopProxiFyreService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_proxifyre_service');
|
||||
}
|
||||
|
||||
export function installProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_proxifyre');
|
||||
}
|
||||
|
||||
export function uninstallProxiFyre(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_proxifyre');
|
||||
}
|
||||
|
||||
export function startSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('start_singbox_service');
|
||||
}
|
||||
|
||||
export function stopSingBoxService(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('stop_singbox_service');
|
||||
}
|
||||
|
||||
export function installSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('install_singbox');
|
||||
}
|
||||
|
||||
export function uninstallSingBox(): Promise<ComponentStatus> {
|
||||
return invoke<ComponentStatus>('uninstall_singbox');
|
||||
}
|
||||
2162
src/app/App.tsx
Normal file
2162
src/app/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
83
src/app/readiness.ts
Normal file
83
src/app/readiness.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export type RouteMode = 'external' | 'local-singbox';
|
||||
|
||||
export interface ApplyReadinessInput {
|
||||
routeMode: RouteMode;
|
||||
appCount: number;
|
||||
proxiFyreInstalled: boolean;
|
||||
singBoxInstalled: boolean;
|
||||
selectedServerTag?: string;
|
||||
externalProxyValue: string;
|
||||
externalProxyError?: string | null;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyReadiness {
|
||||
ready: boolean;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
|
||||
if (input.busy) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Операция уже выполняется',
|
||||
text: 'Дождись завершения текущего действия перед повторным применением.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.proxiFyreInstalled) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'ProxiFyre не установлен',
|
||||
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.appCount < 1) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Нет приложений',
|
||||
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeMode === 'external') {
|
||||
if (!input.externalProxyValue.trim()) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Прокси не указан',
|
||||
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.externalProxyError) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Проверь формат прокси',
|
||||
text: input.externalProxyError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.routeMode === 'local-singbox') {
|
||||
if (!input.singBoxInstalled) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Local sing-box не установлен',
|
||||
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.selectedServerTag) {
|
||||
return {
|
||||
ready: false,
|
||||
title: 'Сервер не выбран',
|
||||
text: 'Выбери сервер Local sing-box перед применением маршрута.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
15
src/app/viewModel.ts
Normal file
15
src/app/viewModel.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ComponentStatus } from '../domain/types';
|
||||
import type { ServiceControlState } from '../ui';
|
||||
|
||||
export function serviceControlState(
|
||||
component: ComponentStatus | undefined,
|
||||
checking: boolean,
|
||||
): ServiceControlState {
|
||||
if (checking) return 'checking';
|
||||
if (!component) return 'missing';
|
||||
if (component.state === 'error') return 'error';
|
||||
if (component.running) return 'running';
|
||||
if (component.installed) return 'stopped';
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
101
src/domain/types.ts
Normal file
101
src/domain/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type Protocol = 'TCP' | 'UDP';
|
||||
export type ProfileItemType = 'process' | 'folder' | 'exe';
|
||||
export type TargetKind = 'local' | 'external';
|
||||
export type ProxyProtocol = 'socks5' | 'http';
|
||||
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
|
||||
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
|
||||
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
|
||||
|
||||
export interface ProfileItemInput {
|
||||
type: ProfileItemType | string;
|
||||
value: string;
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
targetId?: string;
|
||||
protocols?: string[];
|
||||
items?: ProfileItemInput[];
|
||||
}
|
||||
|
||||
export interface ProfileItem {
|
||||
type: ProfileItemType;
|
||||
value: string;
|
||||
recursive: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
targetId: string;
|
||||
protocols: Protocol[];
|
||||
items: ProfileItem[];
|
||||
}
|
||||
|
||||
export interface TargetInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
kind?: TargetKind | string;
|
||||
protocol?: ProxyProtocol | string;
|
||||
host: string;
|
||||
port: number;
|
||||
requiresComponent?: ComponentId | string;
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: TargetKind;
|
||||
protocol: ProxyProtocol;
|
||||
host: string;
|
||||
port: number;
|
||||
requiresComponent?: ComponentId;
|
||||
}
|
||||
|
||||
export interface ComponentStatus {
|
||||
id: ComponentId;
|
||||
name: string;
|
||||
state: ComponentState;
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
problems: string[];
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface LocalSingBoxConfig {
|
||||
subscriptionDisplayUrl?: string;
|
||||
hasSubscription: boolean;
|
||||
selectedServerTag?: string;
|
||||
listenHost: string;
|
||||
listenPort: number;
|
||||
serviceName: string;
|
||||
installRoot: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionServer {
|
||||
tag: string;
|
||||
type: string;
|
||||
server: string;
|
||||
serverPort: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionCache {
|
||||
servers: SubscriptionServer[];
|
||||
userInfo: Record<string, number | string | boolean | null>;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string;
|
||||
at: string;
|
||||
level: ActivityLevel;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles/app.css';
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
2668
src/styles/app.css
Normal file
2668
src/styles/app.css
Normal file
File diff suppressed because it is too large
Load Diff
54
src/ui/ActionMenu.tsx
Normal file
54
src/ui/ActionMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
export interface ActionMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionMenuProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
label: string;
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ActionMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
label,
|
||||
items,
|
||||
disabled,
|
||||
}: ActionMenuProps) {
|
||||
return (
|
||||
<div className="ui-action-menu">
|
||||
<IconButton
|
||||
label={label}
|
||||
icon={<MoreHorizontal size={20} strokeWidth={2} />}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
{open ? (
|
||||
<div className="ui-action-menu-popover" role="menu">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? 'is-danger' : ''}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
49
src/ui/Button.tsx
Normal file
49
src/ui/Button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
'ui-button',
|
||||
`ui-button--${variant}`,
|
||||
`ui-button--${size}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={classes}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
|
||||
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
|
||||
) : null}
|
||||
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/ui/Field.tsx
Normal file
38
src/ui/Field.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string | null;
|
||||
hint?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
action,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const helpId = `${inputId}-help`;
|
||||
|
||||
return (
|
||||
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
|
||||
<span className="ui-field-label">{label}</span>
|
||||
<div className="ui-field-row">
|
||||
<input
|
||||
{...props}
|
||||
id={inputId}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error || hint ? helpId : undefined}
|
||||
/>
|
||||
{action}
|
||||
</div>
|
||||
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/ui/IconButton.tsx
Normal file
42
src/ui/IconButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
||||
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
variant?: IconButtonVariant;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'neutral',
|
||||
loading = false,
|
||||
className,
|
||||
disabled,
|
||||
title,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const classes = [
|
||||
'ui-icon-button',
|
||||
`ui-icon-button--${variant}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={props.type ?? 'button'}
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/ui/LogDock.tsx
Normal file
82
src/ui/LogDock.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface LogDockEntry {
|
||||
id: string;
|
||||
kind: 'success' | 'error' | 'info';
|
||||
title: string;
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface LogDockProps {
|
||||
entries: LogDockEntry[];
|
||||
activeEntry: LogDockEntry | null;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
formatTime: (timestamp: number) => string;
|
||||
}
|
||||
|
||||
function isNativePreviewError(entry: LogDockEntry | null) {
|
||||
if (!entry) return false;
|
||||
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
|
||||
}
|
||||
|
||||
function displayEntry(entry: LogDockEntry | null) {
|
||||
if (!entry) return null;
|
||||
if (!isNativePreviewError(entry)) return entry;
|
||||
return {
|
||||
...entry,
|
||||
title: 'Desktop-команды недоступны',
|
||||
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
|
||||
};
|
||||
}
|
||||
|
||||
export function LogDock({
|
||||
entries,
|
||||
activeEntry,
|
||||
open,
|
||||
onToggle,
|
||||
formatTime,
|
||||
}: LogDockProps) {
|
||||
const current = displayEntry(activeEntry);
|
||||
|
||||
return (
|
||||
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
|
||||
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
|
||||
{current ? (
|
||||
<>
|
||||
<strong>{current.title}</strong>
|
||||
<span>{current.text}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="log-muted">Журнал событий</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}>
|
||||
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span>
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="log-history">
|
||||
{entries.length ? entries.map((entry) => {
|
||||
const friendly = displayEntry(entry);
|
||||
return (
|
||||
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||
<time>{formatTime(entry.at)}</time>
|
||||
<div>
|
||||
<strong>{friendly?.title ?? entry.title}</strong>
|
||||
<span>{friendly?.text ?? entry.text}</span>
|
||||
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="log-history-row">
|
||||
<time>--:--:--</time>
|
||||
<span>Событий пока нет.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
93
src/ui/ServiceControlRow.tsx
Normal file
93
src/ui/ServiceControlRow.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, type ButtonVariant } from './Button';
|
||||
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
|
||||
|
||||
export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error';
|
||||
|
||||
export interface ServicePrimaryAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceControlRowProps {
|
||||
state: ServiceControlState;
|
||||
title: string;
|
||||
detail: string;
|
||||
primaryAction?: ServicePrimaryAction;
|
||||
menu?: {
|
||||
label: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
visualState?: 'working' | 'settling' | null;
|
||||
className?: string;
|
||||
inlineActions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ServiceControlRow({
|
||||
state,
|
||||
title,
|
||||
detail,
|
||||
primaryAction,
|
||||
menu,
|
||||
visualState,
|
||||
className,
|
||||
inlineActions,
|
||||
children,
|
||||
}: ServiceControlRowProps) {
|
||||
const classes = [
|
||||
'ui-service-row',
|
||||
`ui-service-row--${state}`,
|
||||
visualState ? `ui-service-row--${visualState}` : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<span className="ui-service-border-glow" aria-hidden="true">
|
||||
<span className="ui-service-border-glow-segment top" />
|
||||
<span className="ui-service-border-glow-segment right" />
|
||||
<span className="ui-service-border-glow-segment bottom" />
|
||||
<span className="ui-service-border-glow-segment left" />
|
||||
</span>
|
||||
<span className="ui-service-dot" aria-hidden="true" />
|
||||
<div className="ui-service-text">
|
||||
<strong>{title}</strong>
|
||||
<span>{detail}</span>
|
||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||
</div>
|
||||
<div className="ui-service-actions">
|
||||
{primaryAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={primaryAction.variant ?? 'neutral'}
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.disabled}
|
||||
loading={primaryAction.loading}
|
||||
loadingLabel={primaryAction.loadingLabel}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{menu ? (
|
||||
<ActionMenu
|
||||
label={menu.label}
|
||||
open={menu.open}
|
||||
onOpenChange={menu.onOpenChange}
|
||||
items={menu.items}
|
||||
disabled={menu.disabled}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
11
src/ui/StatusPill.tsx
Normal file
11
src/ui/StatusPill.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||
|
||||
export interface StatusPillProps {
|
||||
tone?: StatusPillTone;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
|
||||
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
81
src/ui/Tabs.tsx
Normal file
81
src/ui/Tabs.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
export interface TabItem<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TabsProps<T extends string> {
|
||||
items: Array<TabItem<T>>;
|
||||
activeId: T;
|
||||
onChange: (id: T) => void;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function Tabs<T extends string>({
|
||||
items,
|
||||
activeId,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: TabsProps<T>) {
|
||||
const refs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
function moveFocus(currentId: T, direction: 1 | -1) {
|
||||
const currentIndex = items.findIndex((item) => item.id === currentId);
|
||||
const nextIndex = (currentIndex + direction + items.length) % items.length;
|
||||
const next = items[nextIndex];
|
||||
if (!next) return;
|
||||
onChange(next.id);
|
||||
window.requestAnimationFrame(() => refs.current[nextIndex]?.focus());
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
moveFocus(id, 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
moveFocus(id, -1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
const first = items[0];
|
||||
if (!first) return;
|
||||
onChange(first.id);
|
||||
window.requestAnimationFrame(() => refs.current[0]?.focus());
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
const last = items[items.length - 1];
|
||||
if (!last) return;
|
||||
onChange(last.id);
|
||||
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-tabs" role="tablist" aria-label={ariaLabel}>
|
||||
{items.map((item, index) => {
|
||||
const active = item.id === activeId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`tab-${item.id}`}
|
||||
aria-controls={`panel-${item.id}`}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
}}
|
||||
onClick={() => onChange(item.id)}
|
||||
onKeyDown={(event) => handleKeyDown(event, item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
17
src/ui/index.ts
Normal file
17
src/ui/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||
export { Field } from './Field';
|
||||
export type { FieldProps } from './Field';
|
||||
export { IconButton } from './IconButton';
|
||||
export type { IconButtonProps, IconButtonVariant } from './IconButton';
|
||||
export { LogDock } from './LogDock';
|
||||
export type { LogDockEntry, LogDockProps } from './LogDock';
|
||||
export { ServiceControlRow } from './ServiceControlRow';
|
||||
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow';
|
||||
export { StatusPill } from './StatusPill';
|
||||
export type { StatusPillProps, StatusPillTone } from './StatusPill';
|
||||
export { Tabs } from './Tabs';
|
||||
export type { TabItem, TabsProps } from './Tabs';
|
||||
|
||||
Reference in New Issue
Block a user