mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: 系统终端
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import clsx from 'clsx'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Tab } from './tabs'
|
||||
|
||||
interface SortableTabProps {
|
||||
id: string
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SortableTab({
|
||||
id,
|
||||
value,
|
||||
children,
|
||||
className
|
||||
}: SortableTabProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id })
|
||||
|
||||
const mouseDownTime = useRef<number>(0)
|
||||
const mouseDownPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
mouseDownTime.current = Date.now()
|
||||
mouseDownPos.current = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
const timeDiff = Date.now() - mouseDownTime.current
|
||||
const distanceX = Math.abs(e.clientX - mouseDownPos.current.x)
|
||||
const distanceY = Math.abs(e.clientY - mouseDownPos.current.y)
|
||||
|
||||
// 如果时间小于200ms且移动距离小于5px,认为是点击而不是拖拽
|
||||
if (timeDiff < 200 && distanceX < 5 && distanceY < 5) {
|
||||
listeners?.onClick?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
value={value}
|
||||
{...attributes}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className={clsx(
|
||||
'cursor-move select-none border-b-2 transition-colors',
|
||||
isDragging
|
||||
? 'bg-default-100 border-primary'
|
||||
: 'hover:bg-default-100 border-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Tab>
|
||||
)
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx'
|
||||
import { type ReactNode, createContext, forwardRef, useContext } from 'react'
|
||||
|
||||
interface TabsContextValue {
|
||||
export interface TabsContextValue {
|
||||
activeKey: string
|
||||
onChange: (key: string) => void
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const TabsContext = createContext<TabsContextValue>({
|
||||
onChange: () => {}
|
||||
})
|
||||
|
||||
interface TabsProps {
|
||||
export interface TabsProps {
|
||||
activeKey: string
|
||||
onChange: (key: string) => void
|
||||
children: ReactNode
|
||||
@@ -26,7 +26,7 @@ export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface TabListProps {
|
||||
export interface TabListProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
@@ -37,38 +37,44 @@ export function TabList({ children, className }: TabListProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface TabProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
|
||||
({ value, className, children, ...props }, ref) => {
|
||||
const { activeKey, onChange } = useContext(TabsContext)
|
||||
export const Tab = forwardRef<HTMLDivElement, TabProps>(
|
||||
({ className, isSelected, value, ...props }, ref) => {
|
||||
const { onChange } = useContext(TabsContext)
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
onChange(value)
|
||||
props.onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={() => onChange(value)}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-t transition-colors',
|
||||
activeKey === value
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:bg-default-100',
|
||||
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
|
||||
isSelected
|
||||
? 'border-danger text-danger'
|
||||
: 'border-transparent hover:border-default',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Tab.displayName = 'Tab'
|
||||
|
||||
interface TabPanelProps {
|
||||
export interface TabPanelProps {
|
||||
value: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
|
38
napcat.webui/src/components/tabs/sortable_tab.tsx
Normal file
38
napcat.webui/src/components/tabs/sortable_tab.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
import { Tab } from '@/components/tabs'
|
||||
import type { TabProps } from '@/components/tabs'
|
||||
|
||||
interface SortableTabProps extends TabProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function SortableTab({ id, ...props }: SortableTabProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: 'relative' as const,
|
||||
touchAction: 'none'
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
import TerminalManager from '@/controllers/terminal_manager'
|
||||
|
||||
import XTerm, { XTermRef } from '../xterm'
|
||||
|
||||
@@ -10,48 +10,29 @@ interface TerminalInstanceProps {
|
||||
|
||||
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||
const termRef = useRef<XTermRef>(null)
|
||||
const wsRef = useRef<WebSocket>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const ws = WebUIManager.connectTerminal(id, (data) => {
|
||||
termRef.current?.write(data)
|
||||
})
|
||||
wsRef.current = ws
|
||||
|
||||
// 添加连接状态监听
|
||||
ws.onopen = () => {
|
||||
console.log('Terminal connected:', id)
|
||||
const handleData = (data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.data) {
|
||||
termRef.current?.write(parsed.data)
|
||||
}
|
||||
} catch (e) {
|
||||
termRef.current?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Terminal connection error:', error)
|
||||
termRef.current?.write(
|
||||
'\r\n\x1b[31mConnection error. Please try reconnecting.\x1b[0m\r\n'
|
||||
)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Terminal disconnected:', id)
|
||||
termRef.current?.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n')
|
||||
}
|
||||
TerminalManager.connectTerminal(id, handleData)
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
TerminalManager.disconnectTerminal(id, handleData)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleInput = (data: string) => {
|
||||
const ws = wsRef.current
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
} catch (error) {
|
||||
console.error('Failed to send terminal input:', error)
|
||||
}
|
||||
} else {
|
||||
console.warn('WebSocket is not in OPEN state')
|
||||
}
|
||||
TerminalManager.sendInput(id, data)
|
||||
}
|
||||
|
||||
return <XTerm ref={termRef} onInput={handleInput} className="h-full" />
|
||||
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
|
||||
}
|
||||
|
@@ -25,12 +25,13 @@ export type XTermRef = {
|
||||
export interface XTermProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
|
||||
onInput?: (data: string) => void
|
||||
onKey?: (key: string, event: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const domRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const { className, onInput, ...rest } = props
|
||||
const { className, onInput, onKey, ...rest } = props
|
||||
const { theme } = useTheme()
|
||||
useEffect(() => {
|
||||
if (!domRef.current) {
|
||||
@@ -40,9 +41,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
allowTransparency: true,
|
||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.0
|
||||
drawBoldTextInBrightColors: false
|
||||
})
|
||||
terminalRef.current = terminal
|
||||
const fitAddon = new FitAddon()
|
||||
@@ -74,6 +73,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
}
|
||||
})
|
||||
|
||||
terminal.onKey((event) => {
|
||||
if (onKey) {
|
||||
onKey(event.key, event.domEvent)
|
||||
}
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit()
|
||||
})
|
||||
|
88
napcat.webui/src/controllers/terminal_manager.ts
Normal file
88
napcat.webui/src/controllers/terminal_manager.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
type TerminalCallback = (data: string) => void
|
||||
|
||||
interface TerminalConnection {
|
||||
ws: WebSocket
|
||||
callbacks: Set<TerminalCallback>
|
||||
isConnected: boolean
|
||||
buffer: string[] // 添加缓存数组
|
||||
}
|
||||
|
||||
class TerminalManager {
|
||||
private connections: Map<string, TerminalConnection> = new Map()
|
||||
private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小
|
||||
|
||||
connectTerminal(id: string, callback: TerminalCallback): WebSocket {
|
||||
let conn = this.connections.get(id)
|
||||
|
||||
if (!conn) {
|
||||
const url = new URL(window.location.href)
|
||||
url.protocol = url.protocol.replace('http', 'ws')
|
||||
url.pathname = `/api/ws/terminal`
|
||||
url.searchParams.set('id', id)
|
||||
const token = JSON.parse(localStorage.getItem('token') || '')
|
||||
if (!token) {
|
||||
throw new Error('No token found')
|
||||
}
|
||||
url.searchParams.set('token', token)
|
||||
const ws = new WebSocket(url.toString())
|
||||
conn = {
|
||||
ws,
|
||||
callbacks: new Set([callback]),
|
||||
isConnected: false,
|
||||
buffer: [] // 初始化缓存
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data
|
||||
// 保存到缓存
|
||||
conn?.buffer.push(data)
|
||||
if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) {
|
||||
conn?.buffer.shift()
|
||||
}
|
||||
conn?.callbacks.forEach((cb) => cb(data))
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
if (conn) conn.isConnected = true
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (conn) conn.isConnected = false
|
||||
}
|
||||
|
||||
this.connections.set(id, conn)
|
||||
} else {
|
||||
conn.callbacks.add(callback)
|
||||
// 恢复历史内容
|
||||
conn.buffer.forEach((data) => callback(data))
|
||||
}
|
||||
|
||||
return conn.ws
|
||||
}
|
||||
|
||||
disconnectTerminal(id: string, callback: TerminalCallback) {
|
||||
const conn = this.connections.get(id)
|
||||
if (!conn) return
|
||||
|
||||
conn.callbacks.delete(callback)
|
||||
}
|
||||
|
||||
removeTerminal(id: string) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.close()
|
||||
}
|
||||
this.connections.delete(id)
|
||||
}
|
||||
|
||||
sendInput(id: string, data: string) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terminalManager = new TerminalManager()
|
||||
|
||||
export default terminalManager
|
@@ -150,41 +150,6 @@ export default class WebUIManager {
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async sendTerminalInput(
|
||||
id: string,
|
||||
input: string
|
||||
): Promise<void> {
|
||||
await serverRequest.post(`/Log/terminal/${id}/input`, { input })
|
||||
}
|
||||
|
||||
public static getTerminalStream(id: string, onData: (data: string) => void) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) throw new Error('未登录')
|
||||
|
||||
const _token = JSON.parse(token)
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
`/api/Log/terminal/${id}/stream`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const { data } = JSON.parse(event.data)
|
||||
onData(data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return eventSource
|
||||
}
|
||||
|
||||
public static async closeTerminal(id: string): Promise<void> {
|
||||
await serverRequest.post(`/Log/terminal/${id}/close`)
|
||||
}
|
||||
@@ -205,9 +170,15 @@ export default class WebUIManager {
|
||||
if (!token) throw new Error('未登录')
|
||||
|
||||
const _token = JSON.parse(token)
|
||||
const ws = new WebSocket(
|
||||
`ws://${window.location.host}/api/ws/terminal?id=${id}&token=${_token}`
|
||||
)
|
||||
|
||||
const url = new URL(window.location.origin)
|
||||
url.protocol = "ws://"
|
||||
url.pathname = "/api/ws/terminal"
|
||||
url.searchParams.set('token', _token)
|
||||
url.searchParams.set("id", id)
|
||||
console.log(url.toString())
|
||||
|
||||
const ws = new WebSocket(url.toString())
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
|
@@ -1,4 +1,11 @@
|
||||
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core'
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
@@ -9,10 +16,11 @@ import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoAdd, IoClose } from 'react-icons/io5'
|
||||
|
||||
import { SortableTab } from '@/components/sortable_tab'
|
||||
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'
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
interface TerminalTab {
|
||||
@@ -29,9 +37,9 @@ export default function TerminalPage() {
|
||||
WebUIManager.getTerminalList().then((terminals) => {
|
||||
if (terminals.length === 0) return
|
||||
|
||||
const newTabs = terminals.map((terminal, index) => ({
|
||||
const newTabs = terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
title: `Terminal ${index + 1}`
|
||||
title: terminal.id
|
||||
}))
|
||||
|
||||
setTabs(newTabs)
|
||||
@@ -44,7 +52,7 @@ export default function TerminalPage() {
|
||||
const { id } = await WebUIManager.createTerminal(80, 24)
|
||||
const newTab = {
|
||||
id,
|
||||
title: `Terminal ${tabs.length + 1}`
|
||||
title: id
|
||||
}
|
||||
|
||||
setTabs((prev) => [...prev, newTab])
|
||||
@@ -58,10 +66,16 @@ export default function TerminalPage() {
|
||||
const closeTerminal = async (id: string) => {
|
||||
try {
|
||||
await WebUIManager.closeTerminal(id)
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== id))
|
||||
terminalManager.removeTerminal(id)
|
||||
if (selectedTab === id) {
|
||||
setSelectedTab(tabs[0]?.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('关闭终端失败')
|
||||
}
|
||||
@@ -78,25 +92,49 @@ export default function TerminalPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2 p-4">
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Tabs activeKey={selectedTab} onChange={setSelectedTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TabList className="flex-1">
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<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">
|
||||
<TabList className="flex-1 !overflow-x-auto hide-scrollbar">
|
||||
<SortableContext
|
||||
items={tabs}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab key={tab.id} id={tab.id} value={tab.id}>
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
value={tab.id}
|
||||
isSelected={selectedTab === tab.id}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
{tab.title}
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="ml-2"
|
||||
className="min-w-0 w-4 h-4"
|
||||
onPress={() => closeTerminal(tab.id)}
|
||||
color={selectedTab === tab.id ? 'danger' : 'default'}
|
||||
>
|
||||
<IoClose />
|
||||
</Button>
|
||||
@@ -106,15 +144,21 @@ export default function TerminalPage() {
|
||||
</TabList>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={createNewTerminal}
|
||||
startContent={<IoAdd />}
|
||||
className="text-xl"
|
||||
/>
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} value={tab.id} className="flex-1">
|
||||
<TerminalInstance id={tab.id} />
|
||||
</TabPanel>
|
||||
))}
|
||||
<div className="flex-grow overflow-hidden">
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} value={tab.id} className="h-full">
|
||||
<TerminalInstance id={tab.id} />
|
||||
</TabPanel>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
@@ -35,6 +35,20 @@ body {
|
||||
.font-noto-serif {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar-track {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
Reference in New Issue
Block a user