363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import { createPortal } from 'react-dom';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
BadgeCheck,
|
|
Files,
|
|
History,
|
|
KeyRound,
|
|
LayoutDashboard,
|
|
LogOut,
|
|
MessageCircle,
|
|
MoonStar,
|
|
Rocket,
|
|
Settings2,
|
|
Shield,
|
|
SunMedium,
|
|
UserRound,
|
|
Users,
|
|
Waypoints,
|
|
Wrench,
|
|
} from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { PasswordInput } from '../components/PasswordInput';
|
|
import { pickLocale } from '../i18n';
|
|
import { appZhCn } from '../i18n/app.zh-cn';
|
|
import { appEn } from '../i18n/app.en';
|
|
import { useAppStore } from '../store/appStore';
|
|
import type { SysAuthBootstrap, SysMenuItem } from '../types/sys';
|
|
|
|
const iconMap: Record<string, LucideIcon> = {
|
|
'layout-dashboard': LayoutDashboard,
|
|
waypoints: Waypoints,
|
|
wrench: Wrench,
|
|
'sliders-horizontal': Settings2,
|
|
files: Files,
|
|
rocket: Rocket,
|
|
shield: Shield,
|
|
users: Users,
|
|
'badge-check': BadgeCheck,
|
|
'layout-grid': LayoutDashboard,
|
|
'message-circle': MessageCircle,
|
|
'user-round': UserRound,
|
|
history: History,
|
|
'key-round': KeyRound,
|
|
};
|
|
|
|
export function ThemeLocaleSwitches() {
|
|
const { theme, setTheme, locale, setLocale } = useAppStore();
|
|
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
|
|
|
return (
|
|
<div className="global-switches">
|
|
<div className="switch-compact">
|
|
<button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
|
|
<MoonStar size={14} />
|
|
</button>
|
|
<button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
|
|
<SunMedium size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="switch-compact">
|
|
<button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
|
|
<span>ZH</span>
|
|
</button>
|
|
<button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
|
|
<span>EN</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CompactHeaderSwitches() {
|
|
const { theme, setTheme, locale, setLocale } = useAppStore();
|
|
const isZh = locale === 'zh';
|
|
|
|
return (
|
|
<div className="compact-header-switches">
|
|
<button
|
|
type="button"
|
|
className="compact-header-pill"
|
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
aria-label={isZh ? '切换主题' : 'Toggle theme'}
|
|
title={isZh ? '切换主题' : 'Toggle theme'}
|
|
>
|
|
{theme === 'dark' ? <MoonStar size={16} /> : <SunMedium size={16} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="compact-header-pill compact-header-pill-text"
|
|
onClick={() => setLocale(isZh ? 'en' : 'zh')}
|
|
aria-label={isZh ? '切换语言' : 'Toggle language'}
|
|
title={isZh ? '切换语言' : 'Toggle language'}
|
|
>
|
|
<span>{isZh ? 'ZH' : 'EN'}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type DashboardLoginProps = {
|
|
username: string;
|
|
password: string;
|
|
submitting: boolean;
|
|
error: string;
|
|
onUsernameChange: (value: string) => void;
|
|
onPasswordChange: (value: string) => void;
|
|
onSubmit: () => void;
|
|
defaultUsername: string;
|
|
};
|
|
|
|
export function DashboardLogin({
|
|
username,
|
|
password,
|
|
submitting,
|
|
error,
|
|
onUsernameChange,
|
|
onPasswordChange,
|
|
onSubmit,
|
|
defaultUsername,
|
|
}: DashboardLoginProps) {
|
|
const { theme, locale } = useAppStore();
|
|
const passwordToggleLabels = locale === 'zh'
|
|
? { show: '显示密码', hide: '隐藏密码' }
|
|
: { show: 'Show password', hide: 'Hide password' };
|
|
|
|
return (
|
|
<div className="app-shell sys-login-shell" data-theme={theme}>
|
|
<div className="sys-login-layout">
|
|
<div className="app-login-card sys-login-card">
|
|
<div className="sys-login-brand">
|
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon sys-login-logo" />
|
|
<h1>Nanobot</h1>
|
|
<p>{locale === 'zh' ? '用户登录' : 'User Sign In'}</p>
|
|
</div>
|
|
<div className="app-login-form">
|
|
<label className="field-label">{locale === 'zh' ? '用户名' : 'Username'}</label>
|
|
<input
|
|
className="input"
|
|
value={username}
|
|
onChange={(event) => onUsernameChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') onSubmit();
|
|
}}
|
|
placeholder={defaultUsername || (locale === 'zh' ? '用户名' : 'Username')}
|
|
autoFocus
|
|
/>
|
|
<label className="field-label">{locale === 'zh' ? '密码' : 'Password'}</label>
|
|
<PasswordInput
|
|
className="input"
|
|
value={password}
|
|
onChange={(event) => onPasswordChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') onSubmit();
|
|
}}
|
|
placeholder={locale === 'zh' ? '密码' : 'Password'}
|
|
toggleLabels={passwordToggleLabels}
|
|
/>
|
|
{error ? <div className="app-login-error">{error}</div> : null}
|
|
<button className="btn btn-primary app-login-submit sys-login-submit" onClick={onSubmit} disabled={submitting}>
|
|
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
|
|
</button>
|
|
<div className="sys-login-hint">
|
|
{locale === 'zh'
|
|
? `首次初始化默认账号:${defaultUsername || 'admin'}`
|
|
: `Initial seeded account: ${defaultUsername || 'admin'}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type SidebarMenuProps = {
|
|
menus: SysMenuItem[];
|
|
activeMenuKey: string;
|
|
onNavigate: (path: string) => void;
|
|
};
|
|
|
|
export function SidebarMenu({ menus, activeMenuKey, onNavigate }: SidebarMenuProps) {
|
|
const { locale } = useAppStore();
|
|
|
|
return (
|
|
<nav className="sys-sidebar-nav">
|
|
{menus.map((group) => (
|
|
<section key={group.menu_key} className="sys-sidebar-group">
|
|
<div className="sys-sidebar-group-title">
|
|
{locale === 'zh' ? group.title : (group.title_en || group.title)}
|
|
</div>
|
|
<div className="sys-sidebar-group-items">
|
|
{(group.children || []).map((item) => {
|
|
const Icon = iconMap[item.icon || 'layout-dashboard'] || LayoutDashboard;
|
|
const active = item.menu_key === activeMenuKey;
|
|
return (
|
|
<button
|
|
key={item.menu_key}
|
|
type="button"
|
|
className={`sys-sidebar-item ${active ? 'is-active' : ''}`}
|
|
onClick={() => onNavigate(item.route_path)}
|
|
>
|
|
<Icon size={16} />
|
|
<span>{locale === 'zh' ? item.title : (item.title_en || item.title)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
type SidebarAccountPillProps = {
|
|
authBootstrap: SysAuthBootstrap;
|
|
isZh: boolean;
|
|
onOpenProfile: () => void;
|
|
onLogout: () => void;
|
|
};
|
|
|
|
export function SidebarAccountPill({ authBootstrap, isZh, onOpenProfile, onLogout }: SidebarAccountPillProps) {
|
|
const username = String(authBootstrap.user.display_name || authBootstrap.user.username || '');
|
|
const subtitle = authBootstrap.user.role?.name || (isZh ? '账户设置' : 'Account settings');
|
|
|
|
return (
|
|
<div className="sys-user-pill sys-user-pill-sidebar">
|
|
<button className="sys-user-pill-main" type="button" onClick={onOpenProfile}>
|
|
<span className="sys-user-pill-avatar">{username.slice(0, 1).toUpperCase()}</span>
|
|
<span className="sys-user-pill-copy">
|
|
<strong>{username}</strong>
|
|
<span>{subtitle}</span>
|
|
</span>
|
|
</button>
|
|
<button className="sys-user-pill-action" type="button" onClick={onLogout} aria-label={isZh ? '退出登录' : 'Logout'} title={isZh ? '退出登录' : 'Logout'}>
|
|
<LogOut size={14} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function isNormalUserRole(authBootstrap: SysAuthBootstrap) {
|
|
return String(authBootstrap.user.role?.role_key || '').trim().toLowerCase() === 'normal_user';
|
|
}
|
|
|
|
export function normalizeAssignedBotTone(enabled?: boolean, dockerStatus?: string) {
|
|
if (enabled === false) return 'is-disabled';
|
|
return String(dockerStatus || '').toUpperCase() === 'RUNNING' ? 'is-running' : 'is-stopped';
|
|
}
|
|
|
|
export function useResolvedAssignedBots(authBootstrap: SysAuthBootstrap) {
|
|
const activeBots = useAppStore((state) => state.activeBots);
|
|
return useMemo(() => {
|
|
const assigned = Array.isArray(authBootstrap.assigned_bots) ? authBootstrap.assigned_bots : [];
|
|
if (assigned.length === 0) return [];
|
|
const liveBotIds = new Set(Object.keys(activeBots).filter((botId) => String(botId || '').trim()));
|
|
const preferLiveIntersection = liveBotIds.size > 0;
|
|
return assigned
|
|
.filter((item) => !preferLiveIntersection || liveBotIds.has(String(item.id || '').trim()))
|
|
.map((item) => {
|
|
const live = activeBots[item.id];
|
|
return live
|
|
? {
|
|
id: live.id,
|
|
name: live.name || item.name,
|
|
enabled: live.enabled,
|
|
docker_status: live.docker_status,
|
|
node_id: live.node_id || item.node_id,
|
|
node_display_name: live.node_display_name || item.node_display_name || item.node_id,
|
|
}
|
|
: item;
|
|
})
|
|
.filter((item) => String(item.id || '').trim());
|
|
}, [activeBots, authBootstrap.assigned_bots]);
|
|
}
|
|
|
|
type BotSwitcherTriggerProps = {
|
|
authBootstrap: SysAuthBootstrap;
|
|
isZh: boolean;
|
|
selectedBotId: string;
|
|
onSelectBot: (botId: string) => void;
|
|
className?: string;
|
|
};
|
|
|
|
export function BotSwitcherTrigger({
|
|
authBootstrap,
|
|
isZh,
|
|
selectedBotId,
|
|
onSelectBot,
|
|
className,
|
|
}: BotSwitcherTriggerProps) {
|
|
const bots = useResolvedAssignedBots(authBootstrap);
|
|
const theme = useAppStore((state) => state.theme);
|
|
const [open, setOpen] = useState(false);
|
|
const selectedBot = bots.find((bot) => bot.id === selectedBotId) || bots[0];
|
|
const shortName = String(selectedBot?.name || selectedBot?.id || '').slice(0, 1).toUpperCase() || 'B';
|
|
const tone = normalizeAssignedBotTone(selectedBot?.enabled, selectedBot?.docker_status);
|
|
const canRenderPortal = typeof document !== 'undefined';
|
|
|
|
useEffect(() => {
|
|
if (bots.length <= 1) setOpen(false);
|
|
}, [bots.length]);
|
|
|
|
return (
|
|
<div className={`bot-switcher-wrap ${className || ''}`}>
|
|
<button
|
|
type="button"
|
|
className={`bot-switcher-trigger ${tone}${open ? ' is-open' : ''}`}
|
|
onClick={() => {
|
|
if (bots.length > 1) {
|
|
setOpen((value) => !value);
|
|
}
|
|
}}
|
|
title={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current Bot')}
|
|
aria-label={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current Bot')}
|
|
>
|
|
<span className="bot-switcher-trigger-avatar">{shortName}</span>
|
|
</button>
|
|
{open && canRenderPortal ? createPortal(
|
|
<div className="app-shell bot-switcher-portal-shell" data-theme={theme}>
|
|
<div className="bot-switcher-overlay" onClick={() => setOpen(false)}>
|
|
<div className="bot-switcher-popover" onClick={(event) => event.stopPropagation()}>
|
|
<div className="bot-switcher-popover-head">
|
|
<strong>{isZh ? '切换 Bot' : 'Switch Bot'}</strong>
|
|
<span>{isZh ? `${bots.length} 个` : `${bots.length}`}</span>
|
|
</div>
|
|
<div className="bot-switcher-popover-list">
|
|
{bots.map((bot) => {
|
|
const active = bot.id === selectedBot?.id;
|
|
const itemTone = normalizeAssignedBotTone(bot.enabled, bot.docker_status);
|
|
return (
|
|
<button
|
|
key={bot.id}
|
|
type="button"
|
|
className={`bot-switcher-item ${itemTone}${active ? ' is-active' : ''}`}
|
|
onClick={() => {
|
|
onSelectBot(bot.id);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<div className="bot-switcher-item-main">
|
|
<span className="bot-switcher-item-avatar">{String(bot.name || bot.id || '').slice(0, 1).toUpperCase() || 'B'}</span>
|
|
<div className="bot-switcher-item-copy">
|
|
<strong>{bot.name || bot.id}</strong>
|
|
<span className="mono">{bot.id}</span>
|
|
</div>
|
|
</div>
|
|
<div className="bot-switcher-item-meta">
|
|
<span className={`badge ${itemTone === 'is-running' ? 'badge-ok' : itemTone === 'is-disabled' ? 'badge-err' : 'badge-unknown'}`}>
|
|
{itemTone === 'is-running' ? (isZh ? '运行中' : 'Running') : itemTone === 'is-disabled' ? (isZh ? '已停用' : 'Disabled') : (isZh ? '已停止' : 'Stopped')}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|