dev: terminal

This commit is contained in:
bietiaop
2025-02-01 13:41:20 +08:00
parent e6968f2d80
commit 0176fa75ef
15 changed files with 1078 additions and 143 deletions

View File

@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"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/breadcrumbs": "2.2.7",
"@heroui/button": "2.2.10",

View File

@@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="0ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1197,7 +1197,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="800ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1247,7 +1247,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="1600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1297,7 +1297,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="2400ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1344,7 +1344,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="3200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1399,7 +1399,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="0ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1446,7 +1446,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1496,7 +1496,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1543,7 +1543,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1800ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1590,7 +1590,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="2400ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1637,7 +1637,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3000ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1684,7 +1684,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1731,7 +1731,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="4200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1744,3 +1744,224 @@ export const BietiaopIcon = (props: IconSvgProps) => (
</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

@@ -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

@@ -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
}
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false,
letterSpacing: 0,
lineHeight: 1.0
export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
onInput?: (data: string) => void
}
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
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(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey) {
window.open(uri, '_blank')
}
)
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
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)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
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)
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}, [])
}),
[]
)
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)
})
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}),
[]
)
return (
return (
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
}
)
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
})
export default XTerm

View File

@@ -1,6 +1,8 @@
import {
BugIcon2,
FileIcon,
InfoIcon,
LogIcon,
RouteIcon,
SettingsIcon,
SignalTowerIcon,
@@ -49,10 +51,10 @@ export const siteConfig = {
href: '/config'
},
{
label: '系统日志',
label: 'NapCat日志',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
<LogIcon />
</div>
),
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: '关于我们',
icon: (

View File

@@ -9,6 +9,14 @@ export interface Log {
message: string
}
export interface TerminalSession {
id: string
}
export interface TerminalInfo {
id: string
}
export default class WebUIManager {
public static async checkWebUiLogined() {
const { data } =
@@ -130,4 +138,94 @@ export default class WebUIManager {
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

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

View File

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

View File

@@ -2,6 +2,7 @@ import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import { WebUiConfigWrapper } from '../helper/config';
import { logSubscription } from '@/common/log';
import { terminalManager } from '../terminal/terminal_manager';
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
@@ -35,3 +36,65 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
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,13 +1,23 @@
import { Router } from 'express';
import { LogHandler, LogListHandler, LogRealTimeHandler } from '../api/Log';
import {
LogHandler,
LogListHandler,
LogRealTimeHandler,
CreateTerminalHandler,
GetTerminalListHandler,
CloseTerminalHandler,
} from '../api/Log';
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('/terminal/list', GetTerminalListHandler);
router.post('/terminal/create', CreateTerminalHandler);
router.post('/terminal/:id/close', CloseTerminalHandler);
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();