mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
style: font & terminal
style: font & terminal
This commit is contained in:
@@ -46,9 +46,9 @@
|
|||||||
"@react-aria/visually-hidden": "^3.8.19",
|
"@react-aria/visually-hidden": "^3.8.19",
|
||||||
"@reduxjs/toolkit": "^2.5.1",
|
"@reduxjs/toolkit": "^2.5.1",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.4",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
BIN
napcat.webui/public/fonts/AaCute.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono-Italic.ttf
Normal file
Binary file not shown.
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
BIN
napcat.webui/public/fonts/JetBrainsMono.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,7 +27,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
|||||||
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
<CardBody className="items-center md:gap-1 p-1 md:p-2">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'font-outfit flex-1',
|
'flex-1',
|
||||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||||
title({
|
title({
|
||||||
color: size === 'md' ? 'pink' : 'yellow',
|
color: size === 'md' ? 'pink' : 'yellow',
|
||||||
|
@@ -58,7 +58,7 @@ export default function FileTable({
|
|||||||
onDownload
|
onDownload
|
||||||
}: FileTableProps) {
|
}: FileTableProps) {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const pages = Math.ceil(files.length / PAGE_SIZE)
|
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
|
||||||
const start = (page - 1) * PAGE_SIZE
|
const start = (page - 1) * PAGE_SIZE
|
||||||
const end = start + PAGE_SIZE
|
const end = start + PAGE_SIZE
|
||||||
const displayFiles = files.slice(start, end)
|
const displayFiles = files.slice(start, end)
|
||||||
|
@@ -36,7 +36,7 @@ export default function Hitokoto() {
|
|||||||
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
<div className="text-danger-400">一言加载失败:{error.message}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="font-noto-serif">{data?.hitokoto}</div>
|
<div>{data?.hitokoto}</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||||
{data?.from_who}
|
{data?.from_who}
|
||||||
|
@@ -34,7 +34,7 @@ export default function HoverTiltedCard({
|
|||||||
rotateAmplitude = 14,
|
rotateAmplitude = 14,
|
||||||
showTooltip = false,
|
showTooltip = false,
|
||||||
overlayContent = (
|
overlayContent = (
|
||||||
<div className="text-center font-ubuntu mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
||||||
NapCat
|
NapCat
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
@@ -138,7 +138,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
shadow="sm"
|
shadow="sm"
|
||||||
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
||||||
>
|
>
|
||||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||||
<span className="mr-2">请求体</span>
|
<span className="mr-2">请求体</span>
|
||||||
<Button
|
<Button
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -186,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<PageLoading loading={isFetching} />
|
<PageLoading loading={isFetching} />
|
||||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||||
<span className="mr-2">响应</span>
|
<span className="mr-2">响应</span>
|
||||||
<Button
|
<Button
|
||||||
color="warning"
|
color="warning"
|
||||||
|
@@ -67,7 +67,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||||
>
|
>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<h2 className="font-ubuntu font-bold">{api.description}</h2>
|
<h2 className="font-bold">{api.description}</h2>
|
||||||
<div
|
<div
|
||||||
className={clsx('text-sm text-danger-200', {
|
className={clsx('text-sm text-danger-200', {
|
||||||
'!text-danger-400': apiName === selectedApi
|
'!text-danger-400': apiName === selectedApi
|
||||||
|
@@ -23,9 +23,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
|||||||
<PageLoading loading={loading} />
|
<PageLoading loading={loading} />
|
||||||
{error ? (
|
{error ? (
|
||||||
<CardBody className="items-center gap-1 justify-center">
|
<CardBody className="items-center gap-1 justify-center">
|
||||||
<div className="font-outfit flex-1 text-content1-foreground">
|
<div className="flex-1 text-content1-foreground">Error</div>
|
||||||
Error
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||||
{error.message}
|
{error.message}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,10 +49,8 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col justify-center">
|
<div className="flex-col justify-center">
|
||||||
<div className="font-outfit text-lg truncate">{data?.nick}</div>
|
<div className="text-lg truncate">{data?.nick}</div>
|
||||||
<div className="font-ubuntu text-danger-500 text-sm">
|
<div className="text-danger-500 text-sm">{data?.uin}</div>
|
||||||
{data?.uin}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
)}
|
)}
|
||||||
|
@@ -47,11 +47,11 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
style={{ overflow: 'hidden' }}
|
style={{ overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
||||||
<div className="flex justify-center items-center mt-2 gap-2">
|
<div className="flex justify-center items-center my-2 gap-2">
|
||||||
<Image radius="none" height={40} src={logo} className="mb-2" />
|
<Image radius="none" height={40} src={logo} className="mb-2" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center hm-medium',
|
'flex items-center font-bold',
|
||||||
'!text-2xl shiny-text'
|
'!text-2xl shiny-text'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@@ -10,23 +10,24 @@ interface TerminalInstanceProps {
|
|||||||
|
|
||||||
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||||
const termRef = useRef<XTermRef>(null)
|
const termRef = useRef<XTermRef>(null)
|
||||||
|
const connected = useRef(false)
|
||||||
|
|
||||||
|
const handleData = (data: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.data) {
|
||||||
|
termRef.current?.write(parsed.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
termRef.current?.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleData = (data: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
if (parsed.data) {
|
|
||||||
termRef.current?.write(parsed.data)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
termRef.current?.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TerminalManager.connectTerminal(id, handleData)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
TerminalManager.disconnectTerminal(id, handleData)
|
if (connected.current) {
|
||||||
|
TerminalManager.disconnectTerminal(id, handleData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -34,5 +35,22 @@ export function TerminalInstance({ id }: TerminalInstanceProps) {
|
|||||||
TerminalManager.sendInput(id, data)
|
TerminalManager.sendInput(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
|
const handleResize = (cols: number, rows: number) => {
|
||||||
|
if (!connected.current) {
|
||||||
|
connected.current = true
|
||||||
|
console.log('instance', rows, cols)
|
||||||
|
TerminalManager.connectTerminal(id, handleData, { rows, cols })
|
||||||
|
} else {
|
||||||
|
TerminalManager.sendResize(id, cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<XTerm
|
||||||
|
ref={termRef}
|
||||||
|
onInput={handleInput}
|
||||||
|
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
|
import { CanvasAddon } from '@xterm/addon-canvas'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { WebglAddon } from '@xterm/addon-webgl'
|
// import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
|||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme'
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
|
|
||||||
import { gradientText } from '@/utils/terminal'
|
|
||||||
|
|
||||||
export type XTermRef = {
|
export type XTermRef = {
|
||||||
write: (
|
write: (
|
||||||
...args: Parameters<Terminal['write']>
|
...args: Parameters<Terminal['write']>
|
||||||
@@ -20,53 +19,44 @@ export type XTermRef = {
|
|||||||
) => ReturnType<Terminal['writeln']>
|
) => ReturnType<Terminal['writeln']>
|
||||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||||
clear: () => void
|
clear: () => void
|
||||||
|
terminalRef: React.RefObject<Terminal | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XTermProps
|
export interface XTermProps
|
||||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||||
onInput?: (data: string) => void
|
onInput?: (data: string) => void
|
||||||
onKey?: (key: string, event: KeyboardEvent) => void
|
onKey?: (key: string, event: KeyboardEvent) => void
|
||||||
|
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||||
}
|
}
|
||||||
|
|
||||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||||
const domRef = useRef<HTMLDivElement>(null)
|
const domRef = useRef<HTMLDivElement>(null)
|
||||||
const terminalRef = useRef<Terminal | null>(null)
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
const { className, onInput, onKey, ...rest } = props
|
const { className, onInput, onKey, onResize, ...rest } = props
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!domRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
fontFamily:
|
||||||
|
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||||
cursorInactiveStyle: 'outline',
|
cursorInactiveStyle: 'outline',
|
||||||
drawBoldTextInBrightColors: false
|
drawBoldTextInBrightColors: false,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.2
|
||||||
})
|
})
|
||||||
terminalRef.current = terminal
|
terminalRef.current = terminal
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
terminal.loadAddon(
|
terminal.loadAddon(
|
||||||
new WebLinksAddon((event, uri) => {
|
new WebLinksAddon((event, uri) => {
|
||||||
if (event.ctrlKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
window.open(uri, '_blank')
|
window.open(uri, '_blank')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
terminal.loadAddon(fitAddon)
|
terminal.loadAddon(fitAddon)
|
||||||
terminal.loadAddon(new WebglAddon())
|
terminal.open(domRef.current!)
|
||||||
terminal.open(domRef.current)
|
|
||||||
|
|
||||||
terminal.writeln(
|
|
||||||
gradientText(
|
|
||||||
'Welcome to NapCat WebUI',
|
|
||||||
[255, 0, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
terminal.loadAddon(new CanvasAddon())
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
if (onInput) {
|
if (onInput) {
|
||||||
onInput(data)
|
onInput(data)
|
||||||
@@ -81,6 +71,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
|
// 获取当前终端尺寸
|
||||||
|
const cols = terminal.cols
|
||||||
|
const rows = terminal.rows
|
||||||
|
if (onResize) {
|
||||||
|
onResize(cols, rows)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 字体加载完成后重新调整终端大小
|
// 字体加载完成后重新调整终端大小
|
||||||
@@ -100,21 +96,49 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.options.theme = {
|
if (theme === 'dark') {
|
||||||
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
terminalRef.current.options.theme = {
|
||||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
background: '#00000000',
|
||||||
selectionBackground:
|
black: '#000000',
|
||||||
theme === 'dark'
|
red: '#cd3131',
|
||||||
? 'rgba(179, 0, 0, 0.3)'
|
green: '#0dbc79',
|
||||||
: 'rgba(255, 167, 167, 0.3)',
|
yellow: '#e5e510',
|
||||||
cursor: theme === 'dark' ? '#fff' : '#000',
|
blue: '#2472c8',
|
||||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
cyan: '#11a8cd',
|
||||||
black: theme === 'dark' ? '#fff' : '#000'
|
white: '#e5e5e5',
|
||||||
|
brightBlack: '#666666',
|
||||||
|
brightRed: '#f14c4c',
|
||||||
|
brightGreen: '#23d18b',
|
||||||
|
brightYellow: '#f5f543',
|
||||||
|
brightBlue: '#3b8eea',
|
||||||
|
brightCyan: '#29b8db',
|
||||||
|
brightWhite: '#e5e5e5',
|
||||||
|
foreground: '#cccccc',
|
||||||
|
selectionBackground: '#3a3d41',
|
||||||
|
cursor: '#ffffff'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
terminalRef.current.options.theme = {
|
||||||
|
background: '#ffffff00',
|
||||||
|
black: '#000000',
|
||||||
|
red: '#aa3731',
|
||||||
|
green: '#448c27',
|
||||||
|
yellow: '#cb9000',
|
||||||
|
blue: '#325cc0',
|
||||||
|
cyan: '#0083b2',
|
||||||
|
white: '#7f7f7f',
|
||||||
|
brightBlack: '#777777',
|
||||||
|
brightRed: '#f05050',
|
||||||
|
brightGreen: '#60cb00',
|
||||||
|
brightYellow: '#ffbc5d',
|
||||||
|
brightBlue: '#007acc',
|
||||||
|
brightCyan: '#00aacb',
|
||||||
|
brightWhite: '#b0b0b0',
|
||||||
|
foreground: '#000000',
|
||||||
|
selectionBackground: '#bfdbfe',
|
||||||
|
cursor: '#007acc'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
terminalRef.current.options.fontWeight =
|
|
||||||
theme === 'dark' ? 'normal' : '600'
|
|
||||||
terminalRef.current.options.fontWeightBold =
|
|
||||||
theme === 'dark' ? 'bold' : '900'
|
|
||||||
}
|
}
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
@@ -139,7 +163,8 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
|||||||
},
|
},
|
||||||
clear: () => {
|
clear: () => {
|
||||||
terminalRef.current?.clear()
|
terminalRef.current?.clear()
|
||||||
}
|
},
|
||||||
|
terminalRef: terminalRef
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
@@ -51,7 +51,7 @@ export const siteConfig = {
|
|||||||
href: '/config'
|
href: '/config'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'NapCat日志',
|
label: '猫猫日志',
|
||||||
icon: (
|
icon: (
|
||||||
<div className="w-5 h-5">
|
<div className="w-5 h-5">
|
||||||
<LogIcon />
|
<LogIcon />
|
||||||
|
@@ -41,9 +41,16 @@ class TerminalManager {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
connectTerminal(id: string, callback: TerminalCallback): WebSocket {
|
connectTerminal(
|
||||||
|
id: string,
|
||||||
|
callback: TerminalCallback,
|
||||||
|
config?: {
|
||||||
|
cols?: number
|
||||||
|
rows?: number
|
||||||
|
}
|
||||||
|
): WebSocket {
|
||||||
let conn = this.connections.get(id)
|
let conn = this.connections.get(id)
|
||||||
|
const { cols = 80, rows = 24 } = config || {}
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.protocol = url.protocol.replace('http', 'ws')
|
url.protocol = url.protocol.replace('http', 'ws')
|
||||||
@@ -74,6 +81,7 @@ class TerminalManager {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (conn) conn.isConnected = true
|
if (conn) conn.isConnected = true
|
||||||
|
this.sendResize(id, cols, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -111,6 +119,13 @@ class TerminalManager {
|
|||||||
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendResize(id: string, cols: number, rows: number) {
|
||||||
|
const conn = this.connections.get(id)
|
||||||
|
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||||
|
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalManager = new TerminalManager()
|
const terminalManager = new TerminalManager()
|
||||||
|
@@ -98,7 +98,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
|
||||||
'dark:bg-background dark:shadow-danger-100',
|
'dark:bg-background dark:shadow-danger-100',
|
||||||
'bg-background !bg-opacity-50',
|
'bg-background !bg-opacity-50',
|
||||||
'shadow-sm shadow-danger-50',
|
'shadow-sm shadow-danger-50',
|
||||||
|
@@ -100,7 +100,7 @@ export default function TerminalPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-4">
|
<div className="flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
|
@@ -1,111 +1,13 @@
|
|||||||
/* HarmonyOS Sans SC */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Harmony';
|
font-family: 'Aa偷吃可爱长大的';
|
||||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Bold.ttf') format('truetype');
|
src: url('/fonts/AaCute.woff') format('woff');
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Harmony';
|
font-family: 'JetBrains Mono';
|
||||||
src: url('/webui/fonts/harmony/HarmonyOS_Sans_SC_Regular.ttf') format('truetype');
|
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ubuntu */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Ubuntu';
|
font-family: 'JetBrains Mono';
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Bold.ttf') format('truetype');
|
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Regular.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Light.ttf') format('truetype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-BoldItalic.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Italic.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-LightItalic.ttf') format('truetype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-Medium.ttf') format('truetype');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Ubuntu';
|
|
||||||
src: url('/webui/fonts/ubuntu/Ubuntu-MediumItalic.ttf') format('truetype');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LibreBaskerville */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Libre Baskerville';
|
|
||||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Bold.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Libre Baskerville';
|
|
||||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Regular.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Libre Baskerville';
|
|
||||||
src: url('/webui/fonts/LibreBaskerville/LibreBaskerville-Italic.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NotoSerifSC */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Noto Serif SC';
|
|
||||||
src: url('/webui/fonts/NotoSerifSC-VariableFont_wght.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outfit */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Outfit';
|
|
||||||
src: url('/webui/fonts/Outfit-VariableFont_wght.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FiraCode */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Fira Code';
|
|
||||||
src: url('/webui/fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
@@ -6,35 +6,14 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
|
'Aa偷吃可爱长大的',
|
||||||
PingFang SC,
|
PingFang SC,
|
||||||
'Harmony',
|
|
||||||
Helvetica Neue,
|
Helvetica Neue,
|
||||||
Microsoft YaHei,
|
Microsoft YaHei,
|
||||||
sans-serif !important;
|
sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.hm-medium {
|
|
||||||
font-family:
|
|
||||||
PingFang SC,
|
|
||||||
'Harmony',
|
|
||||||
Helvetica Neue,
|
|
||||||
Microsoft YaHei,
|
|
||||||
sans-serif !important;
|
|
||||||
@apply font-bold;
|
|
||||||
}
|
|
||||||
.font-ubuntu {
|
|
||||||
font-family: 'Ubuntu', sans-serif;
|
|
||||||
}
|
|
||||||
.font-outfit {
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
}
|
|
||||||
.font-libre {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
}
|
|
||||||
.font-noto-serif {
|
|
||||||
font-family: 'Noto Serif SC', serif;
|
|
||||||
}
|
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
@@ -105,7 +84,7 @@ body {
|
|||||||
.context-view.monaco-menu-container * {
|
.context-view.monaco-menu-container * {
|
||||||
font-family:
|
font-family:
|
||||||
PingFang SC,
|
PingFang SC,
|
||||||
'Harmony',
|
'Aa偷吃可爱长大的',
|
||||||
Helvetica Neue,
|
Helvetica Neue,
|
||||||
Microsoft YaHei,
|
Microsoft YaHei,
|
||||||
sans-serif !important;
|
sans-serif !important;
|
||||||
@@ -117,3 +96,10 @@ body {
|
|||||||
.ql-editor img {
|
.ql-editor img {
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-smooth: always;
|
||||||
|
}
|
||||||
|
@@ -6,7 +6,11 @@ import { terminalManager } from '../terminal/terminal_manager';
|
|||||||
|
|
||||||
// 日志记录
|
// 日志记录
|
||||||
export const LogHandler: RequestHandler = async (req, res) => {
|
export const LogHandler: RequestHandler = async (req, res) => {
|
||||||
const filename = req.query.id as string;
|
const filename = req.query['id'];
|
||||||
|
if (!filename || typeof filename !== 'string') {
|
||||||
|
return sendError(res, 'ID不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
if (filename.includes('..')) {
|
if (filename.includes('..')) {
|
||||||
return sendError(res, 'ID不合法');
|
return sendError(res, 'ID不合法');
|
||||||
}
|
}
|
||||||
@@ -40,7 +44,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
|
|||||||
// 终端相关处理器
|
// 终端相关处理器
|
||||||
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = terminalManager.createTerminal();
|
const { cols, rows } = req.body;
|
||||||
|
const { id } = terminalManager.createTerminal(cols, rows);
|
||||||
return sendSuccess(res, { id });
|
return sendSuccess(res, { id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create terminal:', error);
|
console.error('Failed to create terminal:', error);
|
||||||
@@ -54,7 +59,10 @@ export const GetTerminalListHandler: RequestHandler = (_, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CloseTerminalHandler: RequestHandler = (req, res) => {
|
export const CloseTerminalHandler: RequestHandler = (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params['id'];
|
||||||
|
if (!id) {
|
||||||
|
return sendError(res, 'ID不能为空');
|
||||||
|
}
|
||||||
terminalManager.closeTerminal(id);
|
terminalManager.closeTerminal(id);
|
||||||
return sendSuccess(res, {});
|
return sendSuccess(res, {});
|
||||||
};
|
};
|
||||||
|
@@ -13,6 +13,8 @@ interface TerminalInstance {
|
|||||||
sockets: Set<WebSocket>;
|
sockets: Set<WebSocket>;
|
||||||
// 新增标识,用于防止重复关闭
|
// 新增标识,用于防止重复关闭
|
||||||
isClosing: boolean;
|
isClosing: boolean;
|
||||||
|
// 新增:存储终端历史输出
|
||||||
|
buffer: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TerminalManager {
|
class TerminalManager {
|
||||||
@@ -67,21 +69,24 @@ class TerminalManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataHandler = (data: string) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'output', data }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
instance.sockets.add(ws);
|
instance.sockets.add(ws);
|
||||||
instance.lastAccess = Date.now();
|
instance.lastAccess = Date.now();
|
||||||
|
|
||||||
|
// 新增:发送当前终端内容给新连接
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
|
||||||
|
}
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
const result = JSON.parse(data.toString());
|
const result = JSON.parse(data.toString());
|
||||||
if (result.type === 'input') {
|
if (result.type === 'input') {
|
||||||
instance.pty.write(result.data);
|
instance.pty.write(result.data);
|
||||||
}
|
}
|
||||||
|
// 新增:处理 resize 消息
|
||||||
|
if (result.type === 'resize') {
|
||||||
|
instance.pty.resize(result.cols, result.rows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,18 +108,17 @@ class TerminalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改:移除参数 id,使用 crypto.randomUUID 生成终端 id
|
// 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位
|
||||||
createTerminal() {
|
createTerminal(cols: number, rows: number) {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||||
const pty = ptySpawn(shell, [], {
|
const pty = ptySpawn(shell, [], {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 80,
|
cols, // 使用客户端传入的 cols
|
||||||
rows: 24,
|
rows, // 使用客户端传入的 rows
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
// 统一编码设置
|
|
||||||
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
|
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
},
|
},
|
||||||
@@ -125,9 +129,13 @@ class TerminalManager {
|
|||||||
lastAccess: Date.now(),
|
lastAccess: Date.now(),
|
||||||
sockets: new Set(),
|
sockets: new Set(),
|
||||||
isClosing: false,
|
isClosing: false,
|
||||||
|
buffer: '', // 初始化终端内容缓存
|
||||||
};
|
};
|
||||||
|
|
||||||
pty.onData((data: any) => {
|
pty.onData((data: any) => {
|
||||||
|
// 追加数据到 buffer
|
||||||
|
instance.buffer += data;
|
||||||
|
// 发送数据给已连接的 websocket
|
||||||
instance.sockets.forEach((ws) => {
|
instance.sockets.forEach((ws) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'output', data }));
|
ws.send(JSON.stringify({ type: 'output', data }));
|
||||||
|
Reference in New Issue
Block a user