438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
|
import axios from 'axios';
|
|
import { ChevronDown, ChevronUp, MoonStar, SunMedium, X } from 'lucide-react';
|
|
import { useAppStore } from './store/appStore';
|
|
import { useBotsSync } from './hooks/useBotsSync';
|
|
import { APP_ENDPOINTS } from './config/env';
|
|
import { ImageFactoryModule } from './modules/images/ImageFactoryModule';
|
|
import { BotWizardModule } from './modules/onboarding/BotWizardModule';
|
|
import { BotDashboardModule } from './modules/dashboard/BotDashboardModule';
|
|
import { pickLocale } from './i18n';
|
|
import { appZhCn } from './i18n/app.zh-cn';
|
|
import { appEn } from './i18n/app.en';
|
|
import { LucentIconButton } from './components/lucent/LucentIconButton';
|
|
import { LucentTooltip } from './components/lucent/LucentTooltip';
|
|
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
|
|
import './App.css';
|
|
|
|
function getSingleBotPasswordKey(botId: string) {
|
|
return `nanobot-bot-page-password:${String(botId || '').trim()}`;
|
|
}
|
|
|
|
function AuthenticatedApp({
|
|
forcedBotId,
|
|
compactMode,
|
|
}: {
|
|
forcedBotId?: string;
|
|
compactMode: boolean;
|
|
}) {
|
|
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
|
const [showImageFactory, setShowImageFactory] = useState(false);
|
|
const [showCreateWizard, setShowCreateWizard] = useState(false);
|
|
const [singleBotPassword, setSingleBotPassword] = useState('');
|
|
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
|
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
|
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
|
|
useBotsSync(forcedBotId);
|
|
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
|
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
|
|
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView);
|
|
const forced = String(forcedBotId || '').trim();
|
|
const forcedBot = forced ? activeBots[forced] : undefined;
|
|
const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked);
|
|
|
|
useEffect(() => {
|
|
if (!forced) {
|
|
document.title = t.title;
|
|
return;
|
|
}
|
|
const botName = String(forcedBot?.name || '').trim();
|
|
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`;
|
|
}, [forced, forcedBot?.name, t.title]);
|
|
|
|
useEffect(() => {
|
|
setHeaderCollapsed(isSingleBotCompactView);
|
|
}, [isSingleBotCompactView, forcedBotId]);
|
|
|
|
useEffect(() => {
|
|
setSingleBotUnlocked(false);
|
|
setSingleBotPassword('');
|
|
setSingleBotPasswordError('');
|
|
}, [forced]);
|
|
|
|
useEffect(() => {
|
|
if (!forced || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
|
const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forced)) || '' : '';
|
|
if (!stored) return;
|
|
let alive = true;
|
|
const boot = async () => {
|
|
try {
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: stored });
|
|
if (!alive) return;
|
|
setSingleBotUnlocked(true);
|
|
setSingleBotPassword('');
|
|
setSingleBotPasswordError('');
|
|
} catch {
|
|
if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forced));
|
|
if (!alive) return;
|
|
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
|
|
}
|
|
};
|
|
void boot();
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [forced, forcedBot?.has_access_password, locale, singleBotUnlocked]);
|
|
|
|
const unlockSingleBot = async () => {
|
|
const entered = String(singleBotPassword || '').trim();
|
|
if (!entered) {
|
|
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
|
|
return;
|
|
}
|
|
if (!forced) return;
|
|
setSingleBotSubmitting(true);
|
|
try {
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: entered });
|
|
if (typeof window !== 'undefined') {
|
|
window.sessionStorage.setItem(getSingleBotPasswordKey(forced), entered);
|
|
}
|
|
setSingleBotPasswordError('');
|
|
setSingleBotUnlocked(true);
|
|
setSingleBotPassword('');
|
|
} catch {
|
|
if (typeof window !== 'undefined') {
|
|
window.sessionStorage.removeItem(getSingleBotPasswordKey(forced));
|
|
}
|
|
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
|
|
} finally {
|
|
setSingleBotSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
|
<div className="app-frame">
|
|
<header
|
|
className={`app-header ${isSingleBotCompactView ? 'app-header-collapsible' : ''} ${isSingleBotCompactView && headerCollapsed ? 'is-collapsed' : ''}`}
|
|
onClick={() => {
|
|
if (isSingleBotCompactView && headerCollapsed) setHeaderCollapsed(false);
|
|
}}
|
|
>
|
|
<div className="row-between app-header-top">
|
|
<div className="app-title">
|
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
|
<div className="app-title-main">
|
|
<h1>{t.title}</h1>
|
|
{isSingleBotCompactView ? (
|
|
<button
|
|
type="button"
|
|
className="app-header-toggle-inline"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setHeaderCollapsed((v) => !v);
|
|
}}
|
|
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
|
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
|
>
|
|
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="app-header-actions">
|
|
{!headerCollapsed ? (
|
|
<div className="global-switches">
|
|
<div className="switch-compact">
|
|
<LucentTooltip content={t.dark}>
|
|
<button
|
|
className={`switch-btn ${theme === 'dark' ? 'active' : ''}`}
|
|
onClick={() => setTheme('dark')}
|
|
aria-label={t.dark}
|
|
>
|
|
<MoonStar size={14} />
|
|
</button>
|
|
</LucentTooltip>
|
|
<LucentTooltip content={t.light}>
|
|
<button
|
|
className={`switch-btn ${theme === 'light' ? 'active' : ''}`}
|
|
onClick={() => setTheme('light')}
|
|
aria-label={t.light}
|
|
>
|
|
<SunMedium size={14} />
|
|
</button>
|
|
</LucentTooltip>
|
|
</div>
|
|
|
|
<div className="switch-compact">
|
|
<LucentTooltip content={t.zh}>
|
|
<button
|
|
className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`}
|
|
onClick={() => setLocale('zh')}
|
|
aria-label={t.zh}
|
|
>
|
|
<span>ZH</span>
|
|
</button>
|
|
</LucentTooltip>
|
|
<LucentTooltip content={t.en}>
|
|
<button
|
|
className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`}
|
|
onClick={() => setLocale('en')}
|
|
aria-label={t.en}
|
|
>
|
|
<span>EN</span>
|
|
</button>
|
|
</LucentTooltip>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="main-stage">
|
|
<BotDashboardModule
|
|
onOpenCreateWizard={() => setShowCreateWizard(true)}
|
|
onOpenImageFactory={() => setShowImageFactory(true)}
|
|
forcedBotId={forcedBotId || undefined}
|
|
compactMode={compactMode}
|
|
/>
|
|
</main>
|
|
</div>
|
|
|
|
{!compactMode && showImageFactory && (
|
|
<div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}>
|
|
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-title-row modal-title-with-close">
|
|
<div className="modal-title-main">
|
|
<h3>{t.nav.images.title}</h3>
|
|
</div>
|
|
<div className="modal-title-actions">
|
|
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowImageFactory(false)} tooltip={t.close} aria-label={t.close}>
|
|
<X size={14} />
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
<div className="app-modal-body">
|
|
<ImageFactoryModule />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!compactMode && showCreateWizard && (
|
|
<div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}>
|
|
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-title-row modal-title-with-close">
|
|
<div className="modal-title-main">
|
|
<h3>{t.nav.onboarding.title}</h3>
|
|
</div>
|
|
<div className="modal-title-actions">
|
|
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCreateWizard(false)} tooltip={t.close} aria-label={t.close}>
|
|
<X size={14} />
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
<div className="app-modal-body">
|
|
<BotWizardModule
|
|
onCreated={() => {
|
|
setShowCreateWizard(false);
|
|
}}
|
|
onGoDashboard={() => setShowCreateWizard(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{shouldPromptSingleBotPassword ? (
|
|
<div className="modal-mask app-modal-mask">
|
|
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
<h1>{forcedBot?.name || forced}</h1>
|
|
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
|
|
<div className="app-login-form">
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
value={singleBotPassword}
|
|
onChange={(event) => {
|
|
setSingleBotPassword(event.target.value);
|
|
if (singleBotPasswordError) setSingleBotPasswordError('');
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') void unlockSingleBot();
|
|
}}
|
|
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
|
|
autoFocus
|
|
/>
|
|
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
|
|
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
|
|
{singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PanelLoginGate({
|
|
children,
|
|
}: {
|
|
children: (props: { forcedBotId?: string; compactMode: boolean }) => ReactElement;
|
|
}) {
|
|
const { theme, locale } = useAppStore();
|
|
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
|
const urlView = useMemo(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const pathMatch = window.location.pathname.match(/^\/bot\/([^/?#]+)/i);
|
|
let forcedBotIdFromPath = '';
|
|
if (pathMatch?.[1]) {
|
|
try {
|
|
forcedBotIdFromPath = decodeURIComponent(pathMatch[1]).trim();
|
|
} catch {
|
|
forcedBotIdFromPath = String(pathMatch[1]).trim();
|
|
}
|
|
}
|
|
const forcedBotIdFromQuery =
|
|
(params.get('botId') || params.get('bot_id') || params.get('id') || '').trim();
|
|
const forcedBotId = forcedBotIdFromPath || forcedBotIdFromQuery;
|
|
const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase();
|
|
const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw);
|
|
const compactMode = compactByFlag || forcedBotId.length > 0;
|
|
return { forcedBotId, compactMode };
|
|
}, []);
|
|
|
|
const [checking, setChecking] = useState(true);
|
|
const [required, setRequired] = useState(false);
|
|
const [authenticated, setAuthenticated] = useState(false);
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const bypassPanelGate = Boolean(String(urlView.forcedBotId || '').trim());
|
|
|
|
useEffect(() => {
|
|
if (bypassPanelGate) {
|
|
setRequired(false);
|
|
setAuthenticated(true);
|
|
setChecking(false);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
const boot = async () => {
|
|
try {
|
|
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
|
|
if (!alive) return;
|
|
const enabled = Boolean(status.data?.enabled);
|
|
if (!enabled) {
|
|
setRequired(false);
|
|
setAuthenticated(true);
|
|
setChecking(false);
|
|
return;
|
|
}
|
|
setRequired(true);
|
|
const stored = getPanelAccessPassword();
|
|
if (!stored) {
|
|
setChecking(false);
|
|
return;
|
|
}
|
|
try {
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
|
|
if (!alive) return;
|
|
setAuthenticated(true);
|
|
} catch {
|
|
clearPanelAccessPassword();
|
|
if (!alive) return;
|
|
setError(locale === 'zh' ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
|
|
} finally {
|
|
if (alive) setChecking(false);
|
|
}
|
|
} catch {
|
|
if (!alive) return;
|
|
setRequired(false);
|
|
setAuthenticated(true);
|
|
setChecking(false);
|
|
}
|
|
};
|
|
void boot();
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [bypassPanelGate, locale]);
|
|
|
|
const onSubmit = async () => {
|
|
const next = String(password || '').trim();
|
|
if (!next) {
|
|
setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access password.');
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
setError('');
|
|
try {
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
|
|
setPanelAccessPassword(next);
|
|
setAuthenticated(true);
|
|
} catch {
|
|
clearPanelAccessPassword();
|
|
setError(locale === 'zh' ? '面板访问密码错误。' : 'Invalid panel access password.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (checking) {
|
|
return (
|
|
<div className="app-shell" data-theme={theme}>
|
|
<div className="app-login-shell">
|
|
<div className="app-login-card">
|
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
<h1>{t.title}</h1>
|
|
<p>{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (required && !authenticated) {
|
|
return (
|
|
<div className="app-shell" data-theme={theme}>
|
|
<div className="app-login-shell">
|
|
<div className="app-login-card">
|
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
<h1>{t.title}</h1>
|
|
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
|
|
<div className="app-login-form">
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') void onSubmit();
|
|
}}
|
|
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
|
|
/>
|
|
{error ? <div className="app-login-error">{error}</div> : null}
|
|
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
|
|
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return children(urlView);
|
|
}
|
|
|
|
function App() {
|
|
return <PanelLoginGate>{(urlView) => <AuthenticatedApp {...urlView} />}</PanelLoginGate>;
|
|
}
|
|
|
|
export default App;
|