Files
NapCatQQ/packages/napcat-webui-frontend/src/pages/dashboard/terminal.tsx

182 lines
5.6 KiB
TypeScript

import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { IoAdd, IoClose } from 'react-icons/io5';
import key from '@/const/key';
import { TabList, TabPanel, Tabs } from '@/components/tabs';
import { SortableTab } from '@/components/tabs/sortable_tab.tsx';
import { TerminalInstance } from '@/components/terminal/terminal-instance';
import terminalManager from '@/controllers/terminal_manager';
interface TerminalTab {
id: string;
title: string;
}
export default function TerminalPage () {
const [tabs, setTabs] = useState<TerminalTab[]>([]);
const [selectedTab, setSelectedTab] = useState<string>('');
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
useEffect(() => {
// 获取已存在的终端列表
terminalManager.getTerminalList().then((terminals) => {
if (terminals.length === 0) return;
const newTabs = terminals.map((terminal) => ({
id: terminal.id,
title: terminal.id,
}));
setTabs(newTabs);
setSelectedTab(newTabs[0].id);
});
}, []);
const createNewTerminal = async () => {
try {
const { id } = await terminalManager.createTerminal(80, 24);
const newTab = {
id,
title: id,
};
setTabs((prev) => [...prev, newTab]);
setSelectedTab(id);
} catch (error: unknown) {
console.error('Failed to create terminal:', error);
toast.error((error as Error).message);
}
};
const closeTerminal = async (id: string) => {
try {
await terminalManager.closeTerminal(id);
terminalManager.removeTerminal(id);
if (selectedTab === id) {
const previousIndex = tabs.findIndex((tab) => tab.id === id) - 1;
if (previousIndex >= 0) {
setSelectedTab(tabs[previousIndex].id);
} else {
setSelectedTab(tabs[0]?.id || '');
}
}
setTabs((prev) => prev.filter((tab) => tab.id !== id));
} catch (_error) {
toast.error('关闭终端失败');
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
setTabs((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over?.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
return (
<div className='flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]'>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Tabs
activeKey={selectedTab}
onChange={setSelectedTab}
className='h-full overflow-hidden'
>
<div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'>
{tabs.length > 0 && (
<TabList className={clsx(
'flex-1 !overflow-x-auto w-full hide-scrollbar backdrop-blur-sm p-1 rounded-lg border border-white/20',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/40 dark:bg-black/20'
)}>
<SortableContext
items={tabs}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableTab
key={tab.id}
id={tab.id}
value={tab.id}
isSelected={selectedTab === tab.id}
className='flex gap-2 items-center flex-shrink-0'
>
{tab.title}
<Button
isIconOnly
radius='full'
variant='flat'
size='sm'
className='min-w-0 w-4 h-4 flex-shrink-0'
onPress={() => closeTerminal(tab.id)}
color={selectedTab === tab.id ? 'primary' : 'default'}
>
<IoClose />
</Button>
</SortableTab>
))}
</SortableContext>
</TabList>
)}
<Button
isIconOnly
color='primary'
size='sm'
variant='flat'
onPress={createNewTerminal}
startContent={<IoAdd />}
className='text-xl ml-auto'
/>
</div>
<div className='flex-grow overflow-hidden'>
{tabs.length === 0 && (
<div className='flex flex-col gap-2 items-center justify-center h-full text-gray-500 py-5'>
<IoAdd className='text-4xl' />
<div className='text-sm'></div>
</div>
)}
{tabs.map((tab) => (
<TabPanel key={tab.id} value={tab.id} className='h-full'>
<TerminalInstance id={tab.id} />
</TabPanel>
))}
</div>
</Tabs>
</DndContext>
</div>
);
}