dashboard-nanobot/frontend/src/modules/dashboard/components/BotListPanel.tsx

362 lines
14 KiB
TypeScript

import { Boxes, ChevronLeft, ChevronRight, EllipsisVertical, ExternalLink, FileText, Gauge, Lock, Plus, Power, Square, Trash2 } from 'lucide-react';
import type { RefObject } from 'react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { CompactPanelTab } from '../types';
import './DashboardMenus.css';
import './BotListPanel.css';
interface BotListLabels {
batchStart: string;
batchStop: string;
botSearchNoResult: string;
botSearchPlaceholder: string;
clearSearch: string;
delete: string;
disable: string;
disabled: string;
enable: string;
extensions: string;
image: string;
manageImages: string;
newBot: string;
paginationNext: string;
paginationPage: (page: number, totalPages: number) => string;
paginationPrev: string;
searchAction: string;
start: string;
stop: string;
syncingPageSize: string;
templateManager: string;
titleBots: string;
}
interface BotListPanelProps {
bots: any[];
filteredBots: any[];
pagedBots: any[];
selectedBotId: string;
normalizedBotListQuery: string;
botListQuery: string;
botListPageSizeReady: boolean;
botListPage: number;
botListTotalPages: number;
botListMenuOpen: boolean;
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
operatingBotId: string | null;
compactMode: boolean;
isZh: boolean;
isLoadingTemplates: boolean;
isBatchOperating: boolean;
labels: BotListLabels;
botSearchInputName: string;
botListMenuRef: RefObject<HTMLDivElement | null>;
onOpenCreateWizard?: () => void;
onOpenImageFactory?: () => void;
onToggleMenu?: () => void;
onCloseMenu: () => void;
onOpenTemplateManager: () => Promise<void> | void;
onBatchStartBots: () => Promise<void> | void;
onBatchStopBots: () => Promise<void> | void;
onBotListQueryChange: (value: string) => void;
onBotListPageChange: (value: number | ((prev: number) => number)) => void;
onSelectBot: (botId: string) => void;
onSetCompactPanelTab: (tab: CompactPanelTab) => void;
onSetBotEnabled: (botId: string, enabled: boolean) => Promise<void> | void;
onStartBot: (botId: string, dockerStatus: string) => Promise<void> | void;
onStopBot: (botId: string, dockerStatus: string) => Promise<void> | void;
onOpenResourceMonitor: (botId: string) => void;
onRemoveBot: (botId: string) => Promise<void> | void;
}
export function BotListPanel({
bots,
filteredBots,
pagedBots,
selectedBotId,
normalizedBotListQuery,
botListQuery,
botListPageSizeReady,
botListPage,
botListTotalPages,
botListMenuOpen,
controlStateByBot,
operatingBotId,
compactMode,
isZh,
isLoadingTemplates,
isBatchOperating,
labels,
botSearchInputName,
botListMenuRef,
onOpenCreateWizard,
onOpenImageFactory,
onToggleMenu,
onCloseMenu,
onOpenTemplateManager,
onBatchStartBots,
onBatchStopBots,
onBotListQueryChange,
onBotListPageChange,
onSelectBot,
onSetCompactPanelTab,
onSetBotEnabled,
onStartBot,
onStopBot,
onOpenResourceMonitor,
onRemoveBot,
}: BotListPanelProps) {
return (
<section className="panel stack ops-bot-list">
<div className="row-between">
<h2 style={{ fontSize: 18 }}>
{normalizedBotListQuery
? `${labels.titleBots} (${filteredBots.length}/${bots.length})`
: `${labels.titleBots} (${bots.length})`}
</h2>
<div className="ops-list-actions" ref={botListMenuRef}>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
onClick={onOpenCreateWizard}
tooltip={labels.newBot}
aria-label={labels.newBot}
>
<Plus size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleMenu}
tooltip={labels.extensions}
aria-label={labels.extensions}
aria-haspopup="menu"
aria-expanded={botListMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{botListMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={labels.extensions}>
<button
className="ops-more-item"
role="menuitem"
disabled={!onOpenImageFactory}
onClick={() => {
onCloseMenu();
onOpenImageFactory?.();
}}
>
<Boxes size={14} />
<span>{labels.manageImages}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isLoadingTemplates}
onClick={() => {
void onOpenTemplateManager();
}}
>
<FileText size={14} />
<span>{labels.templateManager}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={() => {
void onBatchStartBots();
}}
>
<Power size={14} />
<span>{labels.batchStart}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={() => {
void onBatchStopBots();
}}
>
<Square size={14} />
<span>{labels.batchStop}</span>
</button>
</div>
) : null}
</div>
</div>
<div className="ops-bot-list-toolbar">
<ProtectedSearchInput
value={botListQuery}
onChange={onBotListQueryChange}
onClear={() => {
onBotListQueryChange('');
onBotListPageChange(1);
}}
onSearchAction={() => onBotListPageChange(1)}
debounceMs={120}
placeholder={labels.botSearchPlaceholder}
ariaLabel={labels.botSearchPlaceholder}
clearTitle={labels.clearSearch}
searchTitle={labels.searchAction}
name={botSearchInputName}
id={botSearchInputName}
/>
</div>
<div className="list-scroll">
{!botListPageSizeReady ? (
<div className="ops-bot-list-empty">{labels.syncingPageSize}</div>
) : null}
{botListPageSizeReady
? pagedBots.map((bot) => {
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isEnabled = bot.enabled !== false;
const isEnabling = controlState === 'enabling';
const isDisabling = controlState === 'disabling';
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
const showActionPending = isOperating && !isEnabling && !isDisabling;
return (
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
onClick={() => {
onSelectBot(bot.id);
if (compactMode) onSetCompactPanelTab('chat');
}}
>
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
{bot.has_access_password ? (
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
<Lock size={12} />
</span>
) : null}
<div className="ops-bot-name">{bot.name}</div>
<LucentIconButton
className="ops-bot-open-inline"
onClick={(e) => {
e.stopPropagation();
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
window.open(target, '_blank', 'noopener,noreferrer');
}}
tooltip={isZh ? '新页面打开' : 'Open in new page'}
aria-label={isZh ? '新页面打开' : 'Open in new page'}
>
<ExternalLink size={11} />
</LucentIconButton>
</div>
<div className="mono ops-bot-id">{bot.id}</div>
</div>
<div className="ops-bot-top-actions">
{!isEnabled ? (
<span className="badge badge-err">{labels.disabled}</span>
) : null}
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
</div>
<div className="ops-bot-meta">{labels.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-actions">
<label
className="ops-bot-enable-switch"
title={isEnabled ? labels.disable : labels.enable}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
disabled={isOperating || isEnabling || isDisabling}
onChange={(e) => {
void onSetBotEnabled(bot.id, e.target.checked);
}}
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
/>
<span className="ops-bot-enable-switch-track" />
</label>
<div className="ops-bot-actions-main">
<LucentIconButton
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void (isRunning ? onStopBot(bot.id, bot.docker_status) : onStartBot(bot.id, bot.docker_status));
}}
tooltip={isRunning ? labels.stop : labels.start}
aria-label={isRunning ? labels.stop : labels.start}
>
{showActionPending ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : isRunning ? <Square size={14} /> : <Power size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
onOpenResourceMonitor(bot.id);
}}
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
>
<Gauge size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void onRemoveBot(bot.id);
}}
tooltip={labels.delete}
aria-label={labels.delete}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
</div>
);
})
: null}
{botListPageSizeReady && filteredBots.length === 0 ? (
<div className="ops-bot-list-empty">{labels.botSearchNoResult}</div>
) : null}
</div>
{botListPageSizeReady ? (
<div className="ops-bot-list-pagination">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => onBotListPageChange((p) => Math.max(1, p - 1))}
disabled={botListPage <= 1}
tooltip={labels.paginationPrev}
aria-label={labels.paginationPrev}
>
<ChevronLeft size={14} />
</LucentIconButton>
<div className="ops-bot-list-page-indicator pager-status">{labels.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => onBotListPageChange((p) => Math.min(botListTotalPages, p + 1))}
disabled={botListPage >= botListTotalPages}
tooltip={labels.paginationNext}
aria-label={labels.paginationNext}
>
<ChevronRight size={14} />
</LucentIconButton>
</div>
) : null}
</section>
);
}