dashboard-nanobot/frontend/src/app/AppChrome.tsx

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>
);
}