Merge branch 'dev-terminal' of https://github.com/NapNeko/NapCatQQ into dev-terminal

This commit is contained in:
bietiaop
2025-02-01 13:44:18 +08:00
29 changed files with 1601 additions and 257 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Develop # Develop
node_modules/ node_modules/
package-lock.json package-lock.json
pnpm-lock.yaml
out/ out/
dist/ dist/
/src/core.lib/common/ /src/core.lib/common/
@@ -13,4 +14,4 @@ devconfig/*
# Build # Build
*.db *.db
checkVersion.sh checkVersion.sh
bun.lockb bun.lockb

View File

@@ -10,6 +10,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@heroui/avatar": "2.2.7", "@heroui/avatar": "2.2.7",
"@heroui/breadcrumbs": "2.2.7", "@heroui/breadcrumbs": "2.2.7",
"@heroui/button": "2.2.10", "@heroui/button": "2.2.10",
@@ -38,8 +41,8 @@
"@heroui/tooltip": "2.2.8", "@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0", "@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0", "@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "3.8.18", "@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.1",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"@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",
@@ -47,17 +50,17 @@
"@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",
"clsx": "2.1.1", "clsx": "^2.1.1",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"framer-motion": "^11.15.0", "framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"motion": "^11.15.0", "motion": "^12.0.6",
"qface": "^1.4.1", "qface": "^1.4.1",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"quill": "^2.0.3", "quill": "^2.0.3",
"react": "19.0.0", "react": "^19.0.0",
"react-dom": "19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
@@ -65,41 +68,41 @@
"react-markdown": "^9.0.3", "react-markdown": "^9.0.3",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-responsive": "^10.0.0", "react-responsive": "^10.0.0",
"react-router-dom": "7.1.0", "react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1", "react-use-websocket": "^4.11.1",
"react-window": "^1.8.11", "react-window": "^1.8.11",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"tailwind-variants": "0.3.0", "tailwind-variants": "^0.3.0",
"tailwindcss": "3.4.17", "tailwindcss": "^3.4.17",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0", "@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/event-source-polyfill": "^1.0.5", "@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9", "@types/fabric": "^5.3.9",
"@types/node": "22.10.2", "@types/node": "^22.12.0",
"@types/react": "19.0.2", "@types/react": "^19.0.8",
"@types/react-dom": "19.0.2", "@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "8.18.1", "@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "8.18.1", "@typescript-eslint/parser": "^8.22.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.17.0", "eslint": "^9.19.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.1", "eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-unused-imports": "4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.14.0", "globals": "^15.14.0",
"postcss": "8.4.49", "postcss": "^8.5.1",
"prettier": "3.4.2", "prettier": "^3.4.2",
"typescript": "5.7.2", "typescript": "^5.7.3",
"vite": "^6.0.5", "vite": "^6.0.5",
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"

View File

@@ -0,0 +1,146 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { useRef, useState } from 'react'
const springValues = {
damping: 30,
stiffness: 100,
mass: 2
}
export interface HoverTiltedCardProps {
imageSrc: string
altText?: string
captionText?: string
containerHeight?: string
containerWidth?: string
imageHeight?: string
imageWidth?: string
scaleOnHover?: number
rotateAmplitude?: number
showTooltip?: boolean
overlayContent?: React.ReactNode
displayOverlayContent?: boolean
}
export default function HoverTiltedCard({
imageSrc,
altText = 'NapCat',
captionText = 'NapCat',
containerHeight = '200px',
containerWidth = '100%',
imageHeight = '200px',
imageWidth = '200px',
scaleOnHover = 1.1,
rotateAmplitude = 14,
showTooltip = false,
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">
NapCat
</div>
),
displayOverlayContent = true
}: HoverTiltedCardProps) {
const ref = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const rotateX = useSpring(useMotionValue(0), springValues)
const rotateY = useSpring(useMotionValue(0), springValues)
const scale = useSpring(1, springValues)
const opacity = useSpring(0)
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1
})
const [lastY, setLastY] = useState(0)
function handleMouse(e: React.MouseEvent) {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const offsetX = e.clientX - rect.left - rect.width / 2
const offsetY = e.clientY - rect.top - rect.height / 2
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude
rotateX.set(rotationX)
rotateY.set(rotationY)
x.set(e.clientX - rect.left)
y.set(e.clientY - rect.top)
const velocityY = offsetY - lastY
rotateFigcaption.set(-velocityY * 0.6)
setLastY(offsetY)
}
function handleMouseEnter() {
scale.set(scaleOnHover)
opacity.set(1)
}
function handleMouseLeave() {
opacity.set(0)
scale.set(1)
rotateX.set(0)
rotateY.set(0)
rotateFigcaption.set(0)
}
return (
<figure
ref={ref}
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
style={{
height: containerHeight,
width: containerWidth
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none"
style={{
width: imageWidth,
height: imageHeight
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className="absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]">
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
)
}

View File

@@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="0ms" begin="0ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1197,7 +1197,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="800ms" begin="800ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1247,7 +1247,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="1600ms" begin="1600ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1297,7 +1297,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="2400ms" begin="2400ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1344,7 +1344,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="3200ms" begin="3200ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1399,7 +1399,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="0ms" begin="0ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1446,7 +1446,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="600ms" begin="600ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1496,7 +1496,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1200ms" begin="1200ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1543,7 +1543,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1800ms" begin="1800ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1590,7 +1590,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="2400ms" begin="2400ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1637,7 +1637,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3000ms" begin="3000ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1684,7 +1684,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3600ms" begin="3600ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1731,7 +1731,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="4200ms" begin="4200ms"
></animate> ></animate>
<animate <animate
attributeName="fill-opacity" attributeName="fillOpacity"
to="1" to="1"
dur="800ms" dur="800ms"
calcMode="linear" calcMode="linear"
@@ -1744,3 +1744,224 @@ export const BietiaopIcon = (props: IconSvgProps) => (
</svg> </svg>
</> </>
) )
export const FileIcon = (props: IconSvgProps) => (
<svg
version="1.1"
id="_x36_"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512"
xmlSpace="preserve"
{...props}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<g>
<path
style={{ fill: '#D4B476' }}
d="M441.853,393.794H70.147C31.566,393.794,0,362.228,0,323.647V106.969 c0-38.581,31.566-70.147,70.147-70.147h371.706c38.581,0,70.147,31.566,70.147,70.147v216.678 C512,362.228,480.434,393.794,441.853,393.794z"
></path>
<path
style={{ fill: '#D4B476' }}
d="M199.884,249.574H70.147C31.566,249.574,0,218.008,0,179.427V70.147C0,31.566,31.566,0,70.147,0 h129.737c38.581,0,70.147,31.566,70.147,70.147v109.28C270.031,218.008,238.465,249.574,199.884,249.574z"
></path>
<polygon
style={{ fill: '#F0EFEF' }}
points="485.439,329.388 87.357,347.774 78.653,130.095 476.734,111.709 "
></polygon>
<defs>
<filter
id="Adobe_OpacityMaskFilter"
filterUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
>
<feFlood
style={{
floodColor: 'white',
floodOpacity: 1
}}
result="back"
></feFlood>
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
</filter>
</defs>
<mask
maskUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
id="SVGID_1_"
>
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter)' }}>
<defs>
<filter
id="Adobe_OpacityMaskFilter_1_"
filterUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
>
<feFlood
style={{ floodColor: 'white', floodOpacity: 1 }}
result="back"
></feFlood>
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
</filter>
</defs>
<mask
maskUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
id="SVGID_1_"
>
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter_1_)' }}> </g>
</mask>
<linearGradient
id="SVGID_2_"
gradientUnits="userSpaceOnUse"
x1="34.3814"
y1="189.9944"
x2="451.061"
y2="189.9944"
>
<stop offset="0.57" style={{ stopColor: '#F6F6F6' }}></stop>
<stop offset="0.6039" style={{ stopColor: '#F6F6F6' }}></stop>
</linearGradient>
<polygon
style={{ mask: 'url(#SVGID_1_)', fill: 'url(#SVGID_2_)' }}
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
></polygon>
</g>
</mask>
<linearGradient
id="SVGID_3_"
gradientUnits="userSpaceOnUse"
x1="34.3814"
y1="189.9944"
x2="451.061"
y2="189.9944"
>
<stop offset="0.57" style={{ stopColor: '#FFFFFF' }}></stop>
<stop offset="0.6039" style={{ stopColor: '#F0F0F0' }}></stop>
</linearGradient>
<polygon
style={{ fill: 'url(#SVGID_3_)' }}
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
></polygon>
<path
style={{ fill: '#69A092' }}
d="M441.853,417.32H70.147C31.566,417.32,0,385.754,0,347.173V168.515h512v178.658 C512,385.754,480.434,417.32,441.853,417.32z"
></path>
<path
style={{ fill: '#D4B476' }}
d="M441.853,429.594H70.147C31.566,429.594,0,398.028,0,359.447V189.995h512v169.453 C512,398.028,480.434,429.594,441.853,429.594z"
></path>
<g>
<g>
<path
style={{ fill: '#CBBC89' }}
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
></path>
</g>
<g>
<path
style={{ fill: '#98806E' }}
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
></path>
<path
style={{ fill: '#98806E' }}
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
></path>
<path
style={{ fill: '#98806E' }}
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
></path>
<path
style={{ fill: '#98806E' }}
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
></path>
</g>
</g>
<polygon
style={{ fill: '#BBAF98' }}
points="276.167,208.741 0,302.069 0,186.053 512,186.053 512,302.069 "
></polygon>
</g>
</g>
</svg>
)
export const LogIcon = (props: IconSvgProps) => (
<svg
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<rect width="48" height="48" fill="white" fillOpacity="0.01"></rect>
<rect
x="13"
y="10"
width="28"
height="34"
fill="#2F88FF"
stroke="#000000"
strokeWidth="4"
strokeLinejoin="round"
></rect>
<path
d="M35 10V4H8C7.44772 4 7 4.44772 7 5V38H13"
stroke="#000000"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M21 22H33"
stroke="white"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M21 30H33"
stroke="white"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</g>
</svg>
)

View File

@@ -13,7 +13,6 @@ import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png' import logo from '@/assets/images/logo.png'
import type { MenuItem } from '@/config/site' import type { MenuItem } from '@/config/site'
import { title } from '../primitives'
import Menus from './menus' import Menus from './menus'
interface SideBarProps { interface SideBarProps {
@@ -49,18 +48,14 @@ const SideBar: React.FC<SideBarProps> = (props) => {
> >
<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 mt-2 gap-2">
<Image 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 hm-medium',
title({ '!text-2xl shiny-text'
shadow: true,
color: isDark ? 'violet' : 'pink'
}),
'!text-2xl'
)} )}
> >
WebUI NapCat
</div> </div>
</div> </div>
<div className="overflow-y-auto flex flex-col flex-1 px-4"> <div className="overflow-y-auto flex flex-col flex-1 px-4">

View File

@@ -0,0 +1,74 @@
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>
)
}

View File

@@ -0,0 +1,83 @@
import clsx from 'clsx'
import { type ReactNode, createContext, forwardRef, useContext } from 'react'
interface TabsContextValue {
activeKey: string
onChange: (key: string) => void
}
const TabsContext = createContext<TabsContextValue>({
activeKey: '',
onChange: () => {}
})
interface TabsProps {
activeKey: string
onChange: (key: string) => void
children: ReactNode
className?: string
}
export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
return (
<TabsContext.Provider value={{ activeKey, onChange }}>
<div className={clsx('flex flex-col gap-2', className)}>{children}</div>
</TabsContext.Provider>
)
}
interface TabListProps {
children: ReactNode
className?: string
}
export function TabList({ children, className }: TabListProps) {
return (
<div className={clsx('flex items-center gap-1', className)}>{children}</div>
)
}
interface TabProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string
className?: string
children: ReactNode
}
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
({ value, className, children, ...props }, ref) => {
const { activeKey, onChange } = useContext(TabsContext)
return (
<button
ref={ref}
onClick={() => onChange(value)}
className={clsx(
'px-4 py-2 rounded-t transition-colors',
activeKey === value
? 'bg-primary text-white'
: 'hover:bg-default-100',
className
)}
{...props}
>
{children}
</button>
)
}
)
Tab.displayName = 'Tab'
interface TabPanelProps {
value: string
children: ReactNode
className?: string
}
export function TabPanel({ value, children, className }: TabPanelProps) {
const { activeKey } = useContext(TabsContext)
if (value !== activeKey) return null
return <div className={clsx('flex-1', className)}>{children}</div>
}

View File

@@ -19,7 +19,7 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
p: ({ node, ...props }) => <p className="m-0" {...props} />, p: ({ node, ...props }) => <p className="m-0" {...props} />,
a: ({ node, ...props }) => ( a: ({ node, ...props }) => (
<a <a
className="text-blue-500 hover:underline" className="text-primary-500 inline-block hover:underline"
target="_blank" target="_blank"
{...props} {...props}
/> />
@@ -32,12 +32,12 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
), ),
blockquote: ({ node, ...props }) => ( blockquote: ({ node, ...props }) => (
<blockquote <blockquote
className="border-l-4 border-gray-300 pl-4 italic" className="border-l-4 border-default-300 pl-4 italic"
{...props} {...props}
/> />
), ),
code: ({ node, ...props }) => ( code: ({ node, ...props }) => (
<code className="bg-gray-100 p-1 rounded" {...props} /> <code className="bg-default-100 p-1 rounded text-xs" {...props} />
) )
}} }}
> >

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react'
import WebUIManager from '@/controllers/webui_manager'
import XTerm, { XTermRef } from '../xterm'
interface TerminalInstanceProps {
id: string
}
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)
}
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')
}
return () => {
ws.close()
}
}, [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')
}
}
return <XTerm ref={termRef} onInput={handleInput} className="h-full" />
}

View File

@@ -0,0 +1,12 @@
export default function UnderConstruction() {
return (
<div className="flex flex-col items-center justify-center h-full pt-4">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="text-6xl font-bold text-gray-500">🚧</div>
<div className="text-2xl font-bold text-gray-500">
Under Construction
</div>
</div>
</div>
)
}

View File

@@ -22,132 +22,141 @@ export type XTermRef = {
clear: () => void clear: () => void
} }
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>( export interface XTermProps
(props, ref) => { extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
const domRef = useRef<HTMLDivElement>(null) onInput?: (data: string) => void
const terminalRef = useRef<Terminal | null>(null) }
const { className, ...rest } = props
const { theme } = useTheme() const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
useEffect(() => { const domRef = useRef<HTMLDivElement>(null)
if (!domRef.current) { const terminalRef = useRef<Terminal | null>(null)
return const { className, onInput, ...rest } = props
} const { theme } = useTheme()
const terminal = new Terminal({ useEffect(() => {
allowTransparency: true, if (!domRef.current) {
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', return
cursorInactiveStyle: 'outline', }
drawBoldTextInBrightColors: false, const terminal = new Terminal({
letterSpacing: 0, allowTransparency: true,
lineHeight: 1.0 fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false,
letterSpacing: 0,
lineHeight: 1.0
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey) {
window.open(uri, '_blank')
}
}) })
terminalRef.current = terminal )
const fitAddon = new FitAddon() terminal.loadAddon(fitAddon)
terminal.loadAddon( terminal.loadAddon(new WebglAddon())
new WebLinksAddon((event, uri) => { terminal.open(domRef.current)
if (event.ctrlKey) {
window.open(uri, '_blank') terminal.writeln(
} gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
terminal.onData((data) => {
if (onInput) {
onInput(data)
}
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
// 字体加载完成后重新调整终端大小
document.fonts.ready.then(() => {
fitAddon.fit()
resizeObserver.observe(domRef.current!)
})
return () => {
resizeObserver.disconnect()
setTimeout(() => {
terminal.dispose()
}, 0)
}
}, [])
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = {
background: theme === 'dark' ? '#00000000' : '#ffffff00',
foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground:
theme === 'dark'
? 'rgba(179, 0, 0, 0.3)'
: 'rgba(255, 167, 167, 0.3)',
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
}
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
}
}, [theme])
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
}) })
) },
terminal.loadAddon(fitAddon) writeln: (...args) => {
terminal.loadAddon(new WebglAddon()) return terminalRef.current?.writeln(...args)
terminal.open(domRef.current) },
writelnAsync: async (data) => {
terminal.writeln( return new Promise((resolve) => {
gradientText( terminalRef.current?.writeln(data, resolve)
'Welcome to NapCat WebUI', })
[255, 0, 0], },
[0, 255, 0], clear: () => {
true, terminalRef.current?.clear()
true,
true
)
)
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
// 字体加载完成后重新调整终端大小
document.fonts.ready.then(() => {
fitAddon.fit()
resizeObserver.observe(domRef.current!)
})
return () => {
resizeObserver.disconnect()
setTimeout(() => {
terminal.dispose()
}, 0)
} }
}, []) }),
[]
)
useEffect(() => { return (
if (terminalRef.current) { <div
terminalRef.current.options.theme = { className={clsx(
background: theme === 'dark' ? '#00000000' : '#ffffff00', 'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
foreground: theme === 'dark' ? '#fff' : '#000', theme === 'dark' ? 'bg-black' : 'bg-white',
selectionBackground: className
theme === 'dark' )}
? 'rgba(179, 0, 0, 0.3)' {...rest}
: 'rgba(255, 167, 167, 0.3)', >
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
}
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
}
}, [theme])
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
})
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}),
[]
)
return (
<div <div
className={clsx( style={{
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm', width: '100%',
theme === 'dark' ? 'bg-black' : 'bg-white', height: '100%'
className }}
)} ref={domRef}
{...rest} ></div>
> </div>
<div )
style={{ })
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
}
)
export default XTerm export default XTerm

View File

@@ -1,6 +1,8 @@
import { import {
BugIcon2, BugIcon2,
FileIcon,
InfoIcon, InfoIcon,
LogIcon,
RouteIcon, RouteIcon,
SettingsIcon, SettingsIcon,
SignalTowerIcon, SignalTowerIcon,
@@ -49,10 +51,10 @@ export const siteConfig = {
href: '/config' href: '/config'
}, },
{ {
label: '系统日志', label: 'NapCat日志',
icon: ( icon: (
<div className="w-5 h-5"> <div className="w-5 h-5">
<TerminalIcon /> <LogIcon />
</div> </div>
), ),
href: '/logs' href: '/logs'
@@ -75,6 +77,24 @@ export const siteConfig = {
} }
] ]
}, },
{
label: '文件管理',
icon: (
<div className="w-5 h-5">
<FileIcon />
</div>
),
href: '/file_manager'
},
{
label: '系统终端',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
</div>
),
href: '/terminal'
},
{ {
label: '关于我们', label: '关于我们',
icon: ( icon: (

View File

@@ -9,6 +9,14 @@ export interface Log {
message: string message: string
} }
export interface TerminalSession {
id: string
}
export interface TerminalInfo {
id: string
}
export default class WebUIManager { export default class WebUIManager {
public static async checkWebUiLogined() { public static async checkWebUiLogined() {
const { data } = const { data } =
@@ -130,4 +138,94 @@ export default class WebUIManager {
return eventSource return eventSource
} }
public static async createTerminal(
cols: number,
rows: number
): Promise<TerminalSession> {
const { data } = await serverRequest.post<ServerResponse<TerminalSession>>(
'/Log/terminal/create',
{ cols, rows }
)
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`)
}
public static async getTerminalList(): Promise<TerminalInfo[]> {
const { data } =
await serverRequest.get<ServerResponse<TerminalInfo[]>>(
'/Log/terminal/list'
)
return data.data
}
public static connectTerminal(
id: string,
onData: (data: string) => void
): WebSocket {
const token = localStorage.getItem('token')
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}`
)
ws.onmessage = (event) => {
try {
const { data } = JSON.parse(event.data)
onData(data)
} catch (error) {
console.error(error)
}
}
ws.onerror = (error) => {
console.error('WebSocket连接出错:', error)
}
ws.onclose = () => {
console.log('WebSocket连接关闭')
}
return ws
}
} }

View File

@@ -14,10 +14,12 @@ const useConfig = () => {
key: T, key: T,
value: OneBotConfig['network'][T][0] value: OneBotConfig['network'][T][0]
) => { ) => {
if ( const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
value.name && const _key = key as keyof OneBotConfig['network']
config.network[key].some((item) => item.name === value.name) return acc.concat(config.network[_key].map((item) => item.name))
) { }, [] as string[])
if (value.name && allNetworkNames.includes(value.name)) {
throw new Error('已经存在相同的配置项名') throw new Error('已经存在相同的配置项名')
} }

View File

@@ -4,28 +4,17 @@ import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks' import { useRequest } from 'ahooks'
import clsx from 'clsx' import clsx from 'clsx'
import { BietiaopIcon, WebUIIcon } from '@/components/icons' import HoverTiltedCard from '@/components/hover_titled_card'
import NapCatRepoInfo from '@/components/napcat_repo_info' import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives' import { title } from '@/components/primitives'
import logo from '@/assets/images/logo.png' import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager' import WebUIManager from '@/controllers/webui_manager'
import packageJson from '../../../package.json'
function VersionInfo() { function VersionInfo() {
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo) const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
return ( return (
<div className="flex items-center gap-2 mb-5"> <div className="flex items-center gap-2 mb-5">
<Chip
startContent={
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
WebUI
</Chip>
}
>
{packageJson.version}
</Chip>
<Chip <Chip
startContent={ startContent={
<Chip color="warning" size="sm" className="-ml-0.5 select-none"> <Chip color="warning" size="sm" className="-ml-0.5 select-none">
@@ -51,21 +40,8 @@ export default function AboutPage() {
<title> NapCat WebUI</title> <title> NapCat WebUI</title>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10"> <section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center"> <div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
<div className="flex flex-col md:flex-row items-center"> <div className="flex flex-col md:flex-row items-center mb-6">
<Image <HoverTiltedCard imageSrc={logo} />
alt="logo"
className="flex-shrink-0 w-52 md:w-48 mr-2"
src={logo}
/>
<div className="flex -mt-9 md:mt-0">
<WebUIIcon />
</div>
</div>
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
Created By
<div className="flex scale-80 -ml-5 -mr-5">
<BietiaopIcon />
</div>
</div> </div>
<VersionInfo /> <VersionInfo />
<div className="mb-6 flex flex-col items-center gap-4"> <div className="mb-6 flex flex-col items-center gap-4">

View File

@@ -0,0 +1,122 @@
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy
} from '@dnd-kit/sortable'
import { Button } from '@heroui/button'
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 { TerminalInstance } from '@/components/terminal/terminal-instance'
import WebUIManager from '@/controllers/webui_manager'
interface TerminalTab {
id: string
title: string
}
export default function TerminalPage() {
const [tabs, setTabs] = useState<TerminalTab[]>([])
const [selectedTab, setSelectedTab] = useState<string>('')
useEffect(() => {
// 获取已存在的终端列表
WebUIManager.getTerminalList().then((terminals) => {
if (terminals.length === 0) return
const newTabs = terminals.map((terminal, index) => ({
id: terminal.id,
title: `Terminal ${index + 1}`
}))
setTabs(newTabs)
setSelectedTab(newTabs[0].id)
})
}, [])
const createNewTerminal = async () => {
try {
const { id } = await WebUIManager.createTerminal(80, 24)
const newTab = {
id,
title: `Terminal ${tabs.length + 1}`
}
setTabs((prev) => [...prev, newTab])
setSelectedTab(id)
} catch (error) {
console.error('Failed to create terminal:', error)
toast.error('创建终端失败')
}
}
const closeTerminal = async (id: string) => {
try {
await WebUIManager.closeTerminal(id)
setTabs((prev) => prev.filter((tab) => tab.id !== id))
if (selectedTab === id) {
setSelectedTab(tabs[0]?.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)
})
}
}
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">
<SortableContext
items={tabs}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableTab key={tab.id} id={tab.id} value={tab.id}>
{tab.title}
<Button
isIconOnly
variant="flat"
size="sm"
className="ml-2"
onPress={() => closeTerminal(tab.id)}
>
<IoClose />
</Button>
</SortableTab>
))}
</SortableContext>
</TabList>
<Button
isIconOnly
onPress={createNewTerminal}
startContent={<IoAdd />}
/>
</div>
{tabs.map((tab) => (
<TabPanel key={tab.id} value={tab.id} className="flex-1">
<TerminalInstance id={tab.id} />
</TabPanel>
))}
</Tabs>
</DndContext>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import UnderConstruction from '@/components/under_construction'
import DefaultLayout from '@/layouts/default' import DefaultLayout from '@/layouts/default'
import DashboardIndexPage from './dashboard' import DashboardIndexPage from './dashboard'
@@ -11,6 +13,7 @@ import HttpDebug from './dashboard/debug/http'
import WSDebug from './dashboard/debug/websocket' import WSDebug from './dashboard/debug/websocket'
import LogsPage from './dashboard/logs' import LogsPage from './dashboard/logs'
import NetworkPage from './dashboard/network' import NetworkPage from './dashboard/network'
import TerminalPage from './dashboard/terminal'
export default function IndexPage() { export default function IndexPage() {
const location = useLocation() const location = useLocation()
@@ -33,6 +36,8 @@ export default function IndexPage() {
<Route path="ws" element={<WSDebug />} /> <Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} /> <Route path="http" element={<HttpDebug />} />
</Route> </Route>
<Route element={<UnderConstruction />} path="/file_manager" />
<Route element={<TerminalPage />} path="/terminal" />
<Route element={<AboutPage />} path="/about" /> <Route element={<AboutPage />} path="/about" />
</Routes> </Routes>
</motion.div> </motion.div>

View File

@@ -1,15 +1,26 @@
@import url("./fonts.css"); @import url('./fonts.css');
@import url('./text.css');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { body {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important; font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
} }
@layer components { @layer components {
.hm-medium { .hm-medium {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important; font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
@apply font-bold; @apply font-bold;
} }
.font-ubuntu { .font-ubuntu {
@@ -51,7 +62,8 @@ body {
overflow: hidden !important; overflow: hidden !important;
} }
.monaco-editor, .monaco-editor-background { .monaco-editor,
.monaco-editor-background {
background-color: transparent !important; background-color: transparent !important;
} }
@@ -77,7 +89,12 @@ body {
} }
.context-view.monaco-menu-container * { .context-view.monaco-menu-container * {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important; font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
} }
.ql-hidden { .ql-hidden {
@@ -86,15 +103,3 @@ body {
.ql-editor img { .ql-editor img {
@apply inline-block; @apply inline-block;
} }
/* input.ql-image {
@apply hidden;
}
.ql-image svg {
fill: none;
}
.ql-fill {
fill: currentColor;
}
.ql-stroke {
stroke: currentColor;
} */

View File

@@ -0,0 +1,34 @@
@layer base {
.shiny-text {
@apply text-pink-400 text-opacity-60;
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
animation: shine 5s linear infinite;
}
.shiny-text {
background-image: linear-gradient(
120deg,
rgba(255, 50, 50, 0) 40%,
rgba(255, 76, 76, 0.8) 50%,
rgba(255, 50, 50, 0) 60%
);
}
.dark .shiny-text {
background-image: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 40%,
rgba(206, 21, 21, 0.8) 50%,
rgba(255, 255, 255, 0) 60%
);
}
@keyframes shine {
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
}
}

View File

@@ -61,8 +61,5 @@
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"silk-wasm": "^3.6.1", "silk-wasm": "^3.6.1",
"ws": "^8.18.0" "ws": "^8.18.0"
},
"overrides": {
"peek-readable": "5.3.1"
} }
} }

190
src/common/store.ts Normal file
View File

@@ -0,0 +1,190 @@
export type StoreValueType = string | number | boolean | object | null;
export type StoreValue<T extends StoreValueType = StoreValueType> = {
value: T;
expiresAt?: number;
};
class Store {
// 使用Map存储键值对
private store: Map<string, StoreValue>;
// 定时清理器
private cleanerTimer: NodeJS.Timeout;
// 用于分批次扫描的游标
private scanCursor: number = 0;
/**
* Store
* @param cleanInterval 清理间隔
* @param scanLimit 扫描限制(每次最多检查的键数)
*/
constructor(
cleanInterval: number = 1000, // 默认1秒执行一次
private scanLimit: number = 100 // 每次最多检查100个键
) {
this.store = new Map();
this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
}
/**
* 设置键值对
* @param key 键
* @param value 值
* @param ttl 过期时间
* @returns void
* @example store.set('key', 'value', 60)
*/
set<T extends StoreValueType>(key: string, value: T, ttl?: number): void {
if (ttl && ttl <= 0) {
this.del(key);
return;
}
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
}
/**
* 清理过期键
*/
private cleanupExpired(): void {
const now = Date.now();
const keys = Array.from(this.store.keys());
let scanned = 0;
// 分批次扫描
while (scanned < this.scanLimit && this.scanCursor < keys.length) {
const key = keys[this.scanCursor++];
const entry = this.store.get(key)!;
if (entry.expiresAt && entry.expiresAt < now) {
this.store.delete(key);
}
scanned++;
}
// 重置游标(环形扫描)
if (this.scanCursor >= keys.length) {
this.scanCursor = 0;
}
}
/**
* 获取键值
* @param key 键
* @returns T | null
* @example store.get('key')
*/
get<T extends StoreValueType>(key: string): T | null {
this.checkKeyExpiry(key); // 每次访问都检查
const entry = this.store.get(key);
return entry ? (entry.value as T) : null;
}
/**
* 检查键是否过期
* @param key 键
*/
private checkKeyExpiry(key: string): void {
const entry = this.store.get(key);
if (entry?.expiresAt && entry.expiresAt < Date.now()) {
this.store.delete(key);
}
}
/**
* 检查键是否存在
* @param keys 键
* @returns number
* @example store.exists('key1', 'key2')
*/
exists(...keys: string[]): number {
return keys.filter((key) => {
this.checkKeyExpiry(key);
return this.store.has(key);
}).length;
}
/**
* 关闭存储器
*/
shutdown(): void {
clearInterval(this.cleanerTimer);
this.store.clear();
}
/**
* 删除键
* @param keys 键
* @returns number
* @example store.del('key1', 'key2')
*/
del(...keys: string[]): number {
return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
}
/**
* 设置键的过期时间
* @param key 键
* @param seconds 过期时间(秒)
* @returns boolean
* @example store.expire('key', 60)
*/
expire(key: string, seconds: number): boolean {
const entry = this.store.get(key);
if (!entry) return false;
entry.expiresAt = Date.now() + seconds * 1000;
return true;
}
/**
* 获取键的过期时间
* @param key 键
* @returns number | null
* @example store.ttl('key')
*/
ttl(key: string): number | null {
const entry = this.store.get(key);
if (!entry) return null;
if (!entry.expiresAt) return -1;
const remaining = entry.expiresAt - Date.now();
return remaining > 0 ? Math.floor(remaining / 1000) : -2;
}
/**
* 键值数字递增
* @param key 键
* @returns number
* @example store.incr('key')
*/
incr(key: string): number {
const current = this.get<StoreValueType>(key);
if (current === null) {
this.set(key, 1);
return 1;
}
let numericValue: number;
if (typeof current === 'number') {
numericValue = current;
} else if (typeof current === 'string') {
if (!/^-?\d+$/.test(current)) {
throw new Error('ERR value is not an integer');
}
numericValue = parseInt(current, 10);
} else {
throw new Error('ERR value is not an integer');
}
const newValue = numericValue + 1;
this.set(key, newValue);
return newValue;
}
}
const store = new Store();
export default store;

View File

@@ -11,6 +11,7 @@ import { cors } from '@webapi/middleware/cors';
import { createUrl } from '@webapi/utils/url'; import { createUrl } from '@webapi/utils/url';
import { sendSuccess } from '@webapi/utils/response'; import { sendSuccess } from '@webapi/utils/response';
import { join } from 'node:path'; import { join } from 'node:path';
import { terminalManager } from '@webapi/terminal/terminal_manager';
// 实例化Express // 实例化Express
const app = express(); const app = express();
@@ -45,6 +46,8 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------挂载路由------------ // ------------挂载路由------------
// 挂载静态路由(前端),路径为 [/前缀]/webui // 挂载静态路由(前端),路径为 [/前缀]/webui
app.use('/webui', express.static(pathWrapper.staticPath)); app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器
terminalManager.initialize(app);
// 挂载API接口 // 挂载API接口
app.use('/api', ALLRouter); app.use('/api', ALLRouter);
// 所有剩下的请求都转到静态页面 // 所有剩下的请求都转到静态页面

View File

@@ -13,12 +13,15 @@ export const LoginHandler: RequestHandler = async (req, res) => {
const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求体中的token // 获取请求体中的token
const { token } = req.body; const { token } = req.body;
// 获取客户端IP
const clientIP = req.ip || req.socket.remoteAddress || '';
// 如果token为空返回错误信息 // 如果token为空返回错误信息
if (isEmpty(token)) { if (isEmpty(token)) {
return sendError(res, 'token is empty'); return sendError(res, 'token is empty');
} }
// 检查登录频率 // 检查登录频率
if (!WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate)) { if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit'); return sendError(res, 'login rate limit');
} }
//验证config.token是否等于token //验证config.token是否等于token
@@ -26,7 +29,7 @@ export const LoginHandler: RequestHandler = async (req, res) => {
return sendError(res, 'token is invalid'); return sendError(res, 'token is invalid');
} }
// 签发凭证 // 签发凭证
const signCredential = Buffer.from(JSON.stringify(await AuthHelper.signCredential(WebUiConfigData.token))).toString( const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString(
'base64' 'base64'
); );
// 返回成功信息 // 返回成功信息
@@ -36,9 +39,16 @@ export const LoginHandler: RequestHandler = async (req, res) => {
}; };
// 退出登录 // 退出登录
export const LogoutHandler: RequestHandler = (_, res) => { export const LogoutHandler: RequestHandler = async (req, res) => {
// TODO: 这玩意无状态销毁个灯 得想想办法 const authorization = req.headers.authorization;
return sendSuccess(res, null); try {
const CredentialBase64: string = authorization?.split(' ')[1] as string;
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
return sendSuccess(res, 'Logged out successfully');
} catch (e) {
return sendError(res, 'Logout failed');
}
}; };
// 检查登录状态 // 检查登录状态
@@ -53,25 +63,41 @@ export const checkHandler: RequestHandler = async (req, res) => {
const CredentialBase64: string = authorization?.split(' ')[1] as string; const CredentialBase64: string = authorization?.split(' ')[1] as string;
// 解析凭证 // 解析凭证
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString()); const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
// 检查凭证是否已被注销
if (AuthHelper.isCredentialRevoked(Credential)) {
return sendError(res, 'Token has been revoked');
}
// 验证凭证是否在一小时内有效 // 验证凭证是否在一小时内有效
await AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential); const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
// 返回成功信息 // 返回成功信息
return sendSuccess(res, null); if (valid) return sendSuccess(res, null);
// 返回错误信息
return sendError(res, 'Authorization Failed');
} catch (e) { } catch (e) {
// 返回错误信息 // 返回错误信息
return sendError(res, 'Authorization Faild'); return sendError(res, 'Authorization Failed');
} }
}; };
// 修改密码token // 修改密码token
export const UpdateTokenHandler: RequestHandler = async (req, res) => { export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken } = req.body; const { oldToken, newToken } = req.body;
const authorization = req.headers.authorization;
if (isEmpty(oldToken) || isEmpty(newToken)) { if (isEmpty(oldToken) || isEmpty(newToken)) {
return sendError(res, 'oldToken or newToken is empty'); return sendError(res, 'oldToken or newToken is empty');
} }
try { try {
// 注销当前的Token
if (authorization) {
const CredentialBase64: string = authorization.split(' ')[1];
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
}
await WebUiConfig.UpdateToken(oldToken, newToken); await WebUiConfig.UpdateToken(oldToken, newToken);
return sendSuccess(res, 'Token updated successfully'); return sendSuccess(res, 'Token updated successfully');
} catch (e: any) { } catch (e: any) {

View File

@@ -2,6 +2,7 @@ import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response'; import { sendError, sendSuccess } from '../utils/response';
import { WebUiConfigWrapper } from '../helper/config'; import { WebUiConfigWrapper } from '../helper/config';
import { logSubscription } from '@/common/log'; import { logSubscription } from '@/common/log';
import { terminalManager } from '../terminal/terminal_manager';
// 日志记录 // 日志记录
export const LogHandler: RequestHandler = async (req, res) => { export const LogHandler: RequestHandler = async (req, res) => {
@@ -35,3 +36,65 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
logSubscription.unsubscribe(listener); logSubscription.unsubscribe(listener);
}); });
}; };
// 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
try {
const id = Math.random().toString(36).substring(2);
terminalManager.createTerminal(id);
return sendSuccess(res, { id });
} catch (error) {
console.error('Failed to create terminal:', error);
return sendError(res, '创建终端失败');
}
};
export const GetTerminalListHandler: RequestHandler = (req, res) => {
const list = terminalManager.getTerminalList();
return sendSuccess(res, list);
};
export const CloseTerminalHandler: RequestHandler = (req, res) => {
const id = req.params.id;
terminalManager.closeTerminal(id);
return sendSuccess(res, {});
};
// 终端数据交换
export const TerminalHandler: RequestHandler = (req, res) => {
const id = req.params.id;
if (!terminalManager.getTerminal(id)) {
return sendError(res, '终端不存在');
}
if (req.body.input) {
terminalManager.writeTerminal(id, req.body.input);
}
return sendSuccess(res, {});
};
// 终端数据流SSE
export const TerminalStreamHandler: RequestHandler = (req, res) => {
const id = req.params.id;
const instance = terminalManager.getTerminal(id);
if (!instance) {
return sendError(res, '终端不存在');
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
const dataHandler = (data: string) => {
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ type: 'output', data })}\n\n`);
}
};
const dispose = terminalManager.onTerminalData(id, dataHandler);
req.on('close', () => {
dispose();
});
};

View File

@@ -1,5 +1,7 @@
import type { LoginRuntimeType } from '../types/data'; import type { LoginRuntimeType } from '../types/data';
import packageJson from '../../../../package.json'; import packageJson from '../../../../package.json';
import store from '@/common/store';
const LoginRuntime: LoginRuntimeType = { const LoginRuntime: LoginRuntimeType = {
LoginCurrentTime: Date.now(), LoginCurrentTime: Date.now(),
LoginCurrentRate: 0, LoginCurrentRate: 0,
@@ -26,15 +28,22 @@ const LoginRuntime: LoginRuntimeType = {
}; };
export const WebUiDataRuntime = { export const WebUiDataRuntime = {
checkLoginRate(RateLimit: number): boolean { checkLoginRate(ip: string, RateLimit: number): boolean {
LoginRuntime.LoginCurrentRate++; const key = `login_rate:${ip}`;
//console.log(RateLimit, LoginRuntime.LoginCurrentRate, Date.now() - LoginRuntime.LoginCurrentTime); const count = store.get<number>(key) || 0;
if (Date.now() - LoginRuntime.LoginCurrentTime > 1000 * 60) {
LoginRuntime.LoginCurrentRate = 0; //超出时间重置限速 if (count === 0) {
LoginRuntime.LoginCurrentTime = Date.now(); // 第一次访问设置计数器为1并设置60秒过期
store.set(key, 1, 60);
return true; return true;
} }
return LoginRuntime.LoginCurrentRate <= RateLimit;
if (count >= RateLimit) {
return false;
}
store.incr(key);
return true;
}, },
getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] { getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] {
@@ -108,5 +117,5 @@ export const WebUiDataRuntime = {
getQQVersion() { getQQVersion() {
return LoginRuntime.QQVersion; return LoginRuntime.QQVersion;
} },
}; };

View File

@@ -1,5 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import store from '@/common/store';
export class AuthHelper { export class AuthHelper {
private static readonly secretKey = Math.random().toString(36).slice(2); private static readonly secretKey = Math.random().toString(36).slice(2);
@@ -8,7 +8,7 @@ export class AuthHelper {
* @param token 待签名的凭证字符串。 * @param token 待签名的凭证字符串。
* @returns 签名后的凭证对象。 * @returns 签名后的凭证对象。
*/ */
public static async signCredential(token: string): Promise<WebUiCredentialJson> { public static signCredential(token: string): WebUiCredentialJson {
const innerJson: WebUiCredentialInnerJson = { const innerJson: WebUiCredentialInnerJson = {
CreatedTime: Date.now(), CreatedTime: Date.now(),
TokenEncoded: token, TokenEncoded: token,
@@ -23,7 +23,7 @@ export class AuthHelper {
* @param credentialJson 凭证的JSON对象。 * @param credentialJson 凭证的JSON对象。
* @returns 布尔值,表示凭证是否有效。 * @returns 布尔值,表示凭证是否有效。
*/ */
public static async checkCredential(credentialJson: WebUiCredentialJson): Promise<boolean> { public static checkCredential(credentialJson: WebUiCredentialJson): boolean {
try { try {
const jsonString = JSON.stringify(credentialJson.Data); const jsonString = JSON.stringify(credentialJson.Data);
const calculatedHmac = crypto const calculatedHmac = crypto
@@ -42,19 +42,47 @@ export class AuthHelper {
* @param credentialJson 已签名的凭证JSON对象。 * @param credentialJson 已签名的凭证JSON对象。
* @returns 布尔值表示凭证是否有效且token匹配。 * @returns 布尔值表示凭证是否有效且token匹配。
*/ */
public static async validateCredentialWithinOneHour( public static validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): boolean {
token: string, // 首先检查凭证是否被篡改
credentialJson: WebUiCredentialJson const isValid = AuthHelper.checkCredential(credentialJson);
): Promise<boolean> {
const isValid = await AuthHelper.checkCredential(credentialJson);
if (!isValid) { if (!isValid) {
return false; return false;
} }
// 检查凭证是否在黑名单中
if (AuthHelper.isCredentialRevoked(credentialJson)) {
return false;
}
const currentTime = Date.now() / 1000; const currentTime = Date.now() / 1000;
const createdTime = credentialJson.Data.CreatedTime; const createdTime = credentialJson.Data.CreatedTime;
const timeDifference = currentTime - createdTime; const timeDifference = currentTime - createdTime;
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token; return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
} }
/**
* 注销指定的Token凭证
* @param credentialJson 凭证JSON对象
* @returns void
*/
public static revokeCredential(credentialJson: WebUiCredentialJson): void {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
// 将已注销的凭证添加到黑名单中有效期1小时
store.set(`revoked:${hmac}`, true, 3600);
}
/**
* 检查凭证是否已被注销
* @param credentialJson 凭证JSON对象
* @returns 布尔值,表示凭证是否已被注销
*/
public static isCredentialRevoked(credentialJson: WebUiCredentialJson): boolean {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return store.exists(`revoked:${hmac}`) > 0;
}
} }

View File

@@ -32,7 +32,7 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
// 获取配置 // 获取配置
const config = await WebUiConfig.GetWebUIConfig(); const config = await WebUiConfig.GetWebUIConfig();
// 验证凭证在1小时内有效且token与原始token相同 // 验证凭证在1小时内有效且token与原始token相同
const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential); const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (credentialJson) { if (credentialJson) {
// 通过验证 // 通过验证
return next(); return next();

View File

@@ -1,13 +1,23 @@
import { Router } from 'express'; import { Router } from 'express';
import { LogHandler, LogListHandler, LogRealTimeHandler } from '../api/Log'; import {
LogHandler,
LogListHandler,
LogRealTimeHandler,
CreateTerminalHandler,
GetTerminalListHandler,
CloseTerminalHandler,
} from '../api/Log';
const router = Router(); const router = Router();
// router:读取日志内容
router.get('/GetLog', LogHandler);
// router:读取日志列表
router.get('/GetLogList', LogListHandler);
// router:实时日志 // 日志相关路由
router.get('/GetLog', LogHandler);
router.get('/GetLogList', LogListHandler);
router.get('/GetLogRealTime', LogRealTimeHandler); router.get('/GetLogRealTime', LogRealTimeHandler);
// 终端相关路由
router.get('/terminal/list', GetTerminalListHandler);
router.post('/terminal/create', CreateTerminalHandler);
router.post('/terminal/:id/close', CloseTerminalHandler);
export { router as LogRouter }; export { router as LogRouter };

View File

@@ -0,0 +1,155 @@
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '../helper/SignToken';
import { spawn, type ChildProcess } from 'child_process';
import * as os from 'os';
import { WebSocket, WebSocketServer } from 'ws';
interface TerminalInstance {
process: ChildProcess;
lastAccess: number;
dataHandlers: Set<(data: string) => void>;
}
class TerminalManager {
private terminals: Map<string, TerminalInstance> = new Map();
private wss: WebSocketServer | null = null;
initialize(server: any) {
this.wss = new WebSocketServer({
server,
path: '/api/ws/terminal',
});
this.wss.on('connection', async (ws, req) => {
try {
const url = new URL(req.url || '', 'ws://localhost');
const token = url.searchParams.get('token');
const terminalId = url.searchParams.get('id');
if (!token || !terminalId) {
ws.close();
return;
}
// 验证 token
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
ws.close();
return;
}
const config = await WebUiConfig.GetWebUIConfig();
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (!validate) {
ws.close();
return;
}
const instance = this.terminals.get(terminalId);
if (!instance) {
ws.close();
return;
}
const dataHandler = (data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
};
instance.dataHandlers.add(dataHandler);
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
if (data.type === 'input') {
this.writeTerminal(terminalId, data.data);
}
} catch (error) {
console.error('Failed to process terminal input:', error);
}
});
ws.on('close', () => {
instance.dataHandlers.delete(dataHandler);
});
} catch (err) {
console.error('WebSocket authentication failed:', err);
ws.close();
}
});
}
createTerminal(id: string) {
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellProcess = spawn(shell, [], {
env: process.env,
shell: true,
});
const instance: TerminalInstance = {
process: shellProcess,
lastAccess: Date.now(),
dataHandlers: new Set(),
};
// 修改这里,使用 shellProcess 而不是 process
shellProcess.stdout.on('data', (data) => {
const str = data.toString();
instance.dataHandlers.forEach((handler) => handler(str));
});
shellProcess.stderr.on('data', (data) => {
const str = data.toString();
instance.dataHandlers.forEach((handler) => handler(str));
});
this.terminals.set(id, instance);
return instance;
}
getTerminal(id: string) {
return this.terminals.get(id);
}
closeTerminal(id: string) {
const instance = this.terminals.get(id);
if (instance) {
instance.process.kill();
this.terminals.delete(id);
}
}
onTerminalData(id: string, handler: (data: string) => void) {
const instance = this.terminals.get(id);
if (instance) {
instance.dataHandlers.add(handler);
return () => {
instance.dataHandlers.delete(handler);
};
}
return () => {};
}
writeTerminal(id: string, data: string) {
const instance = this.terminals.get(id);
if (instance && instance.process.stdin) {
instance.process.stdin.write(data, (error) => {
if (error) {
console.error('Failed to write to terminal:', error);
}
});
}
}
getTerminalList() {
return Array.from(this.terminals.keys()).map((id) => ({
id,
lastAccess: this.terminals.get(id)!.lastAccess,
}));
}
}
export const terminalManager = new TerminalManager();