mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
182 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|