feat: prepare Zhinian desktop pilot
This commit is contained in:
243
src/pages/NianxxPlay/index.tsx
Normal file
243
src/pages/NianxxPlay/index.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { AlertTriangle, ArrowLeft, Film, FolderClock, Loader2, RefreshCcw, WalletCards } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
|
||||
const DEFAULT_NIANXX_PLAY_URL = 'http://127.0.0.1:3000';
|
||||
|
||||
type NianxxPlayRoute = '/' | '/projects' | '/billing';
|
||||
type ServiceState = 'checking' | 'starting' | 'running' | 'error';
|
||||
|
||||
type NianxxPlayServiceStatus = {
|
||||
success: boolean;
|
||||
running: boolean;
|
||||
starting: boolean;
|
||||
managed: boolean;
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
projectDir?: string;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const NIANXX_PLAY_NAV: Array<{
|
||||
path: NianxxPlayRoute;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{ path: '/', labelKey: 'host.nav.create', icon: Film },
|
||||
{ path: '/projects', labelKey: 'host.nav.projects', icon: FolderClock },
|
||||
{ path: '/billing', labelKey: 'host.nav.billing', icon: WalletCards },
|
||||
];
|
||||
|
||||
function buildEmbeddedSrc(baseUrl: string, route: NianxxPlayRoute, reloadKey: number) {
|
||||
try {
|
||||
const url = new URL(route, baseUrl);
|
||||
url.searchParams.set('zhinianEmbed', '1');
|
||||
url.searchParams.set('zhinianHostReload', String(reloadKey));
|
||||
return url.toString();
|
||||
} catch {
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
return `${normalizedBase}${route}?zhinianEmbed=1&zhinianHostReload=${reloadKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function NianxxPlay() {
|
||||
const { t } = useTranslation('appCenter');
|
||||
const navigate = useNavigate();
|
||||
const [webviewKey, setWebviewKey] = useState(0);
|
||||
const [currentRoute, setCurrentRoute] = useState<NianxxPlayRoute>('/');
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const configuredAppUrl = useMemo(() => (
|
||||
import.meta.env.VITE_NIANXX_PLAY_URL?.trim() || ''
|
||||
), []);
|
||||
const [serviceState, setServiceState] = useState<ServiceState>(configuredAppUrl ? 'running' : 'checking');
|
||||
const [serviceError, setServiceError] = useState<string | null>(null);
|
||||
const [appUrl, setAppUrl] = useState(configuredAppUrl || DEFAULT_NIANXX_PLAY_URL);
|
||||
const initialSrc = useMemo(
|
||||
() => buildEmbeddedSrc(appUrl, currentRoute, webviewKey),
|
||||
[appUrl, currentRoute, webviewKey],
|
||||
);
|
||||
|
||||
const ensureService = useCallback(async () => {
|
||||
if (configuredAppUrl) {
|
||||
setAppUrl(configuredAppUrl);
|
||||
setServiceState('running');
|
||||
setServiceError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
setServiceState('checking');
|
||||
setServiceError(null);
|
||||
try {
|
||||
const status = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/status');
|
||||
if (status.running) {
|
||||
setAppUrl(status.baseUrl);
|
||||
setServiceState('running');
|
||||
return true;
|
||||
}
|
||||
|
||||
setServiceState('starting');
|
||||
const started = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
setAppUrl(started.baseUrl || DEFAULT_NIANXX_PLAY_URL);
|
||||
if (started.running) {
|
||||
setServiceState('running');
|
||||
setServiceError(null);
|
||||
return true;
|
||||
}
|
||||
setServiceState('error');
|
||||
setServiceError(started.error || t('host.serviceFailed'));
|
||||
return false;
|
||||
} catch (error) {
|
||||
setServiceState('error');
|
||||
setServiceError(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}, [configuredAppUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const ok = await ensureService();
|
||||
if (cancelled || !ok) return;
|
||||
setWebviewKey((value) => value + 1);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ensureService]);
|
||||
|
||||
const reloadApp = async () => {
|
||||
setLoadError(null);
|
||||
const ok = await ensureService();
|
||||
if (ok) {
|
||||
setWebviewKey((value) => value + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const openRoute = (route: NianxxPlayRoute) => {
|
||||
setLoadError(null);
|
||||
setCurrentRoute(route);
|
||||
setWebviewKey((value) => value + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="nianxx-play-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
|
||||
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/app-center')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('host.back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={reloadApp}
|
||||
aria-label={t('host.reload')}
|
||||
title={t('host.reload')}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold tracking-normal">
|
||||
{t('host.title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex shrink-0 items-center gap-1 justify-self-center rounded-lg border border-slate-200/70 bg-white/60 p-1 dark:border-white/10 dark:bg-white/5" aria-label={t('host.nav.label')}>
|
||||
{NIANXX_PLAY_NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = currentRoute === item.path;
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
type="button"
|
||||
onClick={() => openRoute(item.path)}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-white text-[#075985] shadow-sm dark:bg-slate-950 dark:text-blue-200'
|
||||
: 'text-slate-600 hover:bg-white/70 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div aria-hidden className="min-w-0" />
|
||||
</header>
|
||||
|
||||
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
|
||||
{serviceState === 'running' && (
|
||||
<iframe
|
||||
key={webviewKey}
|
||||
src={initialSrc}
|
||||
title={t('host.title')}
|
||||
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
|
||||
onLoad={() => setLoadError(null)}
|
||||
onError={() => setLoadError(t('host.loadFailed'))}
|
||||
/>
|
||||
)}
|
||||
{serviceState !== 'running' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
|
||||
<div className="max-w-md">
|
||||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
|
||||
{serviceState === 'error'
|
||||
? <AlertTriangle className="h-5 w-5" />
|
||||
: <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
</div>
|
||||
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
|
||||
{serviceState === 'error' ? t('host.serviceFailed') : t('host.serviceStarting')}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{serviceError || t('host.serviceStartingDesc')}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('host.reload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{serviceState === 'running' && loadError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
|
||||
<div className="max-w-md">
|
||||
<div className="text-base font-semibold text-slate-950 dark:text-slate-50">
|
||||
{t('host.loadFailed')}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{loadError}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('host.reload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NianxxPlay;
|
||||
Reference in New Issue
Block a user