feat: 添加项目规则、环境配置示例及开发文档

This commit is contained in:
锦麟 王
2026-02-04 18:49:38 +08:00
commit df76882178
88 changed files with 13150 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
"""Web TUI 模块
提供 Web 终端界面、SSH 连接管理
"""
from minenasai.webtui.auth import AuthManager, AuthToken, get_auth_manager
from minenasai.webtui.ssh_manager import SSHManager, SSHSession, get_ssh_manager
__all__ = [
"AuthManager",
"AuthToken",
"get_auth_manager",
"SSHManager",
"SSHSession",
"get_ssh_manager",
]

View File

@@ -0,0 +1,236 @@
"""用户认证模块
提供 Token 认证和会话管理
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass, field
from typing import Any
from minenasai.core import get_logger, get_settings
logger = get_logger(__name__)
@dataclass
class AuthToken:
"""认证令牌"""
token: str
user_id: str
created_at: float
expires_at: float
metadata: dict[str, Any] = field(default_factory=dict)
@property
def is_expired(self) -> bool:
"""是否已过期"""
return time.time() > self.expires_at
@property
def remaining_time(self) -> float:
"""剩余有效时间(秒)"""
return max(0, self.expires_at - time.time())
class AuthManager:
"""认证管理器"""
def __init__(self, secret_key: str | None = None) -> None:
"""初始化认证管理器
Args:
secret_key: 签名密钥
"""
self.settings = get_settings()
self.secret_key = secret_key or self.settings.webtui_secret_key
self._tokens: dict[str, AuthToken] = {}
self._user_sessions: dict[str, set[str]] = {} # user_id -> set of tokens
def generate_token(
self,
user_id: str,
expires_in: int | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
"""生成访问令牌
Args:
user_id: 用户 ID
expires_in: 有效期(秒),默认使用配置
metadata: 额外元数据
Returns:
访问令牌
"""
# 生成随机令牌
random_part = secrets.token_urlsafe(24)
timestamp = str(int(time.time()))
# 签名
signature = self._sign(f"{random_part}:{timestamp}:{user_id}")
token = f"{random_part}.{timestamp}.{signature[:16]}"
# 有效期
if expires_in is None:
expires_in = self.settings.webtui.session_timeout
now = time.time()
auth_token = AuthToken(
token=token,
user_id=user_id,
created_at=now,
expires_at=now + expires_in,
metadata=metadata or {},
)
# 存储
self._tokens[token] = auth_token
# 记录用户会话
if user_id not in self._user_sessions:
self._user_sessions[user_id] = set()
self._user_sessions[user_id].add(token)
logger.info("生成令牌", user_id=user_id, expires_in=expires_in)
return token
def verify_token(self, token: str) -> AuthToken | None:
"""验证令牌
Args:
token: 访问令牌
Returns:
AuthToken 或 None无效时
"""
auth_token = self._tokens.get(token)
if auth_token is None:
logger.debug("令牌不存在", token=token[:20] + "...")
return None
if auth_token.is_expired:
logger.debug("令牌已过期", token=token[:20] + "...")
self.revoke_token(token)
return None
return auth_token
def revoke_token(self, token: str) -> bool:
"""撤销令牌
Args:
token: 访问令牌
Returns:
是否成功撤销
"""
auth_token = self._tokens.pop(token, None)
if auth_token:
# 从用户会话中移除
if auth_token.user_id in self._user_sessions:
self._user_sessions[auth_token.user_id].discard(token)
logger.info("撤销令牌", user_id=auth_token.user_id)
return True
return False
def revoke_user_tokens(self, user_id: str) -> int:
"""撤销用户的所有令牌
Args:
user_id: 用户 ID
Returns:
撤销的令牌数量
"""
tokens = self._user_sessions.pop(user_id, set())
for token in tokens:
self._tokens.pop(token, None)
if tokens:
logger.info("撤销用户所有令牌", user_id=user_id, count=len(tokens))
return len(tokens)
def refresh_token(self, token: str, extends_by: int | None = None) -> str | None:
"""刷新令牌
Args:
token: 当前令牌
extends_by: 延长时间(秒)
Returns:
新令牌或 None失败时
"""
auth_token = self.verify_token(token)
if auth_token is None:
return None
# 生成新令牌
new_token = self.generate_token(
user_id=auth_token.user_id,
expires_in=extends_by,
metadata=auth_token.metadata,
)
# 撤销旧令牌
self.revoke_token(token)
return new_token
def cleanup_expired(self) -> int:
"""清理过期令牌
Returns:
清理的令牌数量
"""
now = time.time()
expired = [
token for token, auth_token in self._tokens.items()
if auth_token.expires_at < now
]
for token in expired:
self.revoke_token(token)
if expired:
logger.info("清理过期令牌", count=len(expired))
return len(expired)
def _sign(self, data: str) -> str:
"""生成签名"""
return hmac.new(
self.secret_key.encode(),
data.encode(),
hashlib.sha256
).hexdigest()
def get_stats(self) -> dict[str, Any]:
"""获取统计信息"""
return {
"total_tokens": len(self._tokens),
"total_users": len(self._user_sessions),
"tokens_per_user": {
user_id: len(tokens)
for user_id, tokens in self._user_sessions.items()
},
}
# 全局认证管理器
_auth_manager: AuthManager | None = None
def get_auth_manager() -> AuthManager:
"""获取全局认证管理器"""
global _auth_manager
if _auth_manager is None:
_auth_manager = AuthManager()
return _auth_manager

View File

@@ -0,0 +1,314 @@
"""Web TUI 服务器
提供 Web 终端界面和 WebSocket 终端通信
"""
from __future__ import annotations
import asyncio
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from minenasai.core import get_logger, get_settings, setup_logging
from minenasai.webtui.auth import get_auth_manager
from minenasai.webtui.ssh_manager import SSHSession, get_ssh_manager
logger = get_logger(__name__)
# 静态文件目录
STATIC_DIR = Path(__file__).parent / "static"
class TerminalConnection:
"""终端连接"""
def __init__(
self,
websocket: WebSocket,
session_id: str,
user_id: str,
) -> None:
self.websocket = websocket
self.session_id = session_id
self.user_id = user_id
self.ssh_session: SSHSession | None = None
self.authenticated = False
async def send_json(self, data: dict[str, Any]) -> None:
"""发送 JSON 消息"""
try:
await self.websocket.send_json(data)
except Exception as e:
logger.error("发送消息失败", error=str(e))
async def send_output(self, data: bytes) -> None:
"""发送终端输出"""
try:
await self.websocket.send_json({
"type": "output",
"data": data.decode("utf-8", errors="replace"),
})
except Exception as e:
logger.error("发送输出失败", error=str(e))
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self) -> None:
self.connections: dict[str, TerminalConnection] = {}
async def connect(
self,
websocket: WebSocket,
session_id: str,
user_id: str,
) -> TerminalConnection:
"""接受新连接"""
await websocket.accept()
conn = TerminalConnection(websocket, session_id, user_id)
self.connections[session_id] = conn
logger.info("终端连接建立", session_id=session_id)
return conn
async def disconnect(self, session_id: str) -> None:
"""断开连接"""
conn = self.connections.pop(session_id, None)
if conn and conn.ssh_session:
ssh_manager = get_ssh_manager()
await ssh_manager.close_session(session_id)
logger.info("终端连接断开", session_id=session_id)
def get_connection(self, session_id: str) -> TerminalConnection | None:
"""获取连接"""
return self.connections.get(session_id)
manager = ConnectionManager()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理"""
settings = get_settings()
setup_logging(settings.logging)
logger.info("Web TUI 服务启动", port=settings.webtui.port)
yield
# 清理所有连接
ssh_manager = get_ssh_manager()
await ssh_manager.close_all()
logger.info("Web TUI 服务关闭")
app = FastAPI(
title="MineNASAI Web TUI",
description="Web 终端界面",
version="0.1.0",
lifespan=lifespan,
)
# CORS 配置
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/")
async def index() -> HTMLResponse:
"""首页"""
index_file = STATIC_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
return HTMLResponse(content="<h1>MineNASAI Web TUI</h1>")
@app.get("/health")
async def health() -> dict[str, str]:
"""健康检查"""
return {"status": "healthy"}
@app.get("/api/stats")
async def stats() -> dict[str, Any]:
"""获取统计信息"""
ssh_manager = get_ssh_manager()
auth_manager = get_auth_manager()
return {
"connections": len(manager.connections),
"ssh": ssh_manager.get_stats(),
"auth": auth_manager.get_stats(),
}
@app.post("/api/token")
async def generate_token(
user_id: str = "anonymous",
expires_in: int = 3600,
) -> dict[str, Any]:
"""生成访问令牌(用于测试)"""
auth_manager = get_auth_manager()
token = auth_manager.generate_token(user_id, expires_in)
return {
"token": token,
"user_id": user_id,
"expires_in": expires_in,
}
@app.websocket("/ws/terminal")
async def terminal_websocket(websocket: WebSocket) -> None:
"""终端 WebSocket 端点"""
session_id = str(uuid.uuid4())
conn: TerminalConnection | None = None
try:
conn = await manager.connect(websocket, session_id, "")
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "auth":
await handle_auth(conn, data)
elif msg_type == "input":
await handle_input(conn, data)
elif msg_type == "resize":
await handle_resize(conn, data)
elif msg_type == "pong":
pass # 心跳响应
else:
await conn.send_json({
"type": "error",
"message": f"未知消息类型: {msg_type}",
})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error("WebSocket 错误", error=str(e), session_id=session_id)
finally:
if conn:
await manager.disconnect(session_id)
async def handle_auth(conn: TerminalConnection, data: dict[str, Any]) -> None:
"""处理认证"""
token = data.get("token", "")
auth_manager = get_auth_manager()
# 允许匿名访问(开发模式)
if token == "anonymous":
conn.authenticated = True
conn.user_id = "anonymous"
await conn.send_json({
"type": "auth_ok",
"session_id": conn.session_id,
"user_id": conn.user_id,
})
# 创建 SSH 会话
await create_ssh_session(conn)
return
# 验证令牌
auth_token = auth_manager.verify_token(token)
if auth_token is None:
await conn.send_json({
"type": "auth_error",
"message": "无效的令牌",
})
return
conn.authenticated = True
conn.user_id = auth_token.user_id
await conn.send_json({
"type": "auth_ok",
"session_id": conn.session_id,
"user_id": conn.user_id,
})
# 创建 SSH 会话
await create_ssh_session(conn)
async def create_ssh_session(conn: TerminalConnection) -> None:
"""创建 SSH 会话"""
ssh_manager = get_ssh_manager()
session = await ssh_manager.create_session(conn.session_id)
if session is None:
await conn.send_json({
"type": "error",
"message": "SSH 连接失败",
})
return
conn.ssh_session = session
# 设置输出回调
def on_output(data: bytes) -> None:
asyncio.create_task(conn.send_output(data))
session.set_output_callback(on_output)
# 开始读取输出
await session.start_reading()
async def handle_input(conn: TerminalConnection, data: dict[str, Any]) -> None:
"""处理终端输入"""
if not conn.authenticated:
await conn.send_json({
"type": "error",
"message": "未认证",
})
return
if conn.ssh_session is None:
await conn.send_json({
"type": "error",
"message": "SSH 会话未建立",
})
return
input_data = data.get("data", "")
await conn.ssh_session.write(input_data)
async def handle_resize(conn: TerminalConnection, data: dict[str, Any]) -> None:
"""处理终端大小调整"""
if conn.ssh_session is None:
return
cols = data.get("cols", 80)
rows = data.get("rows", 24)
await conn.ssh_session.resize(cols, rows)
await conn.send_json({
"type": "resize_ok",
"cols": cols,
"rows": rows,
})

View File

@@ -0,0 +1,313 @@
"""SSH 连接管理
使用 paramiko 管理 SSH 连接和 PTY
"""
from __future__ import annotations
import asyncio
import os
import time
from pathlib import Path
from typing import Any, Callable
import paramiko
from minenasai.core import get_logger, get_settings
from minenasai.core.config import expand_path
logger = get_logger(__name__)
class SSHSession:
"""SSH 会话"""
def __init__(
self,
session_id: str,
host: str = "localhost",
port: int = 22,
username: str | None = None,
key_path: str | None = None,
password: str | None = None,
) -> None:
"""初始化 SSH 会话
Args:
session_id: 会话 ID
host: SSH 主机
port: SSH 端口
username: 用户名
key_path: 私钥路径
password: 密码(不推荐)
"""
self.session_id = session_id
self.host = host
self.port = port
self.username = username or os.getenv("USER") or os.getenv("USERNAME") or "root"
self.key_path = key_path
self.password = password
self.client: paramiko.SSHClient | None = None
self.channel: paramiko.Channel | None = None
self.connected = False
self.created_at = time.time()
self.last_activity = time.time()
self._output_callback: Callable[[bytes], None] | None = None
self._read_task: asyncio.Task[None] | None = None
async def connect(self) -> bool:
"""建立 SSH 连接"""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# 连接参数
connect_kwargs: dict[str, Any] = {
"hostname": self.host,
"port": self.port,
"username": self.username,
"timeout": 10,
}
# 优先使用密钥认证
if self.key_path:
key_file = expand_path(self.key_path)
if key_file.exists():
connect_kwargs["key_filename"] = str(key_file)
else:
logger.warning("密钥文件不存在", path=str(key_file))
# 密码认证
if self.password:
connect_kwargs["password"] = self.password
# 尝试使用默认密钥
if "key_filename" not in connect_kwargs and "password" not in connect_kwargs:
connect_kwargs["allow_agent"] = True
connect_kwargs["look_for_keys"] = True
# 在线程池中执行连接(避免阻塞)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.client.connect(**connect_kwargs)
)
# 打开交互式 Shell
self.channel = self.client.invoke_shell(
term="xterm-256color",
width=80,
height=24,
)
self.channel.setblocking(False)
self.connected = True
logger.info(
"SSH 连接成功",
session_id=self.session_id,
host=self.host,
username=self.username,
)
return True
except paramiko.AuthenticationException as e:
logger.error("SSH 认证失败", error=str(e))
return False
except paramiko.SSHException as e:
logger.error("SSH 连接错误", error=str(e))
return False
except Exception as e:
logger.error("SSH 连接异常", error=str(e))
return False
async def disconnect(self) -> None:
"""断开 SSH 连接"""
self.connected = False
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
if self.channel:
self.channel.close()
self.channel = None
if self.client:
self.client.close()
self.client = None
logger.info("SSH 连接已断开", session_id=self.session_id)
def set_output_callback(self, callback: Callable[[bytes], None]) -> None:
"""设置输出回调"""
self._output_callback = callback
async def start_reading(self) -> None:
"""开始读取输出"""
self._read_task = asyncio.create_task(self._read_loop())
async def _read_loop(self) -> None:
"""读取循环"""
while self.connected and self.channel:
try:
# 检查是否有数据可读
if self.channel.recv_ready():
data = self.channel.recv(4096)
if data and self._output_callback:
self._output_callback(data)
self.last_activity = time.time()
else:
await asyncio.sleep(0.01) # 短暂等待
except Exception as e:
if self.connected:
logger.error("SSH 读取错误", error=str(e))
break
async def write(self, data: str | bytes) -> None:
"""写入数据到 SSH 通道"""
if not self.connected or not self.channel:
return
if isinstance(data, str):
data = data.encode("utf-8")
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.channel.send(data)
)
self.last_activity = time.time()
except Exception as e:
logger.error("SSH 写入错误", error=str(e))
async def resize(self, cols: int, rows: int) -> None:
"""调整终端大小"""
if self.channel:
try:
self.channel.resize_pty(width=cols, height=rows)
except Exception as e:
logger.error("SSH 调整大小错误", error=str(e))
class SSHManager:
"""SSH 连接池管理器"""
def __init__(self, max_sessions: int = 50) -> None:
"""初始化管理器
Args:
max_sessions: 最大会话数
"""
self.settings = get_settings()
self.max_sessions = max_sessions
self._sessions: dict[str, SSHSession] = {}
async def create_session(
self,
session_id: str,
host: str | None = None,
port: int | None = None,
username: str | None = None,
) -> SSHSession | None:
"""创建新会话
Args:
session_id: 会话 ID
host: SSH 主机(默认使用配置)
port: SSH 端口
username: 用户名
Returns:
SSHSession 或 None失败时
"""
# 检查会话数量限制
if len(self._sessions) >= self.max_sessions:
# 清理过期会话
await self._cleanup_sessions()
if len(self._sessions) >= self.max_sessions:
logger.warning("会话数量已达上限", max=self.max_sessions)
return None
# 使用配置的默认值
ssh_config = self.settings.webtui.ssh
session = SSHSession(
session_id=session_id,
host=host or ssh_config.host,
port=port or ssh_config.port,
username=username or self.settings.ssh_username,
key_path=self.settings.ssh_key_path,
)
if await session.connect():
self._sessions[session_id] = session
return session
return None
def get_session(self, session_id: str) -> SSHSession | None:
"""获取会话"""
return self._sessions.get(session_id)
async def close_session(self, session_id: str) -> None:
"""关闭会话"""
session = self._sessions.pop(session_id, None)
if session:
await session.disconnect()
async def _cleanup_sessions(self, max_idle: float = 3600) -> None:
"""清理空闲会话
Args:
max_idle: 最大空闲时间(秒)
"""
now = time.time()
expired = [
sid for sid, session in self._sessions.items()
if now - session.last_activity > max_idle
]
for sid in expired:
await self.close_session(sid)
logger.info("清理空闲会话", session_id=sid)
async def close_all(self) -> None:
"""关闭所有会话"""
for session_id in list(self._sessions.keys()):
await self.close_session(session_id)
def get_stats(self) -> dict[str, Any]:
"""获取统计信息"""
return {
"active_sessions": len(self._sessions),
"max_sessions": self.max_sessions,
"sessions": [
{
"id": s.session_id,
"host": s.host,
"connected": s.connected,
"created_at": s.created_at,
"last_activity": s.last_activity,
}
for s in self._sessions.values()
],
}
# 全局 SSH 管理器
_ssh_manager: SSHManager | None = None
def get_ssh_manager() -> SSHManager:
"""获取全局 SSH 管理器"""
global _ssh_manager
if _ssh_manager is None:
_ssh_manager = SSHManager()
return _ssh_manager

View File

@@ -0,0 +1,377 @@
/* MineNASAI Web Terminal Styles */
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #1f2335;
--text-primary: #c0caf5;
--text-secondary: #565f89;
--accent-primary: #7aa2f7;
--accent-success: #9ece6a;
--accent-warning: #e0af68;
--accent-error: #f7768e;
--border-color: #3b4261;
--header-height: 48px;
--footer-height: 24px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
.header {
height: var(--header-height);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.logo {
font-size: 18px;
font-weight: 600;
color: var(--accent-primary);
}
.version {
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
}
.header-center {
display: flex;
align-items: center;
}
.connection-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent-error);
}
.status-dot.connected {
background-color: var(--accent-success);
}
.status-dot.connecting {
background-color: var(--accent-warning);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Buttons */
.btn {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn:hover {
background-color: var(--bg-secondary);
border-color: var(--accent-primary);
}
.btn-primary {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover {
background-color: #89b4fa;
}
.btn-icon {
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.terminal-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--bg-tertiary);
}
.terminal-header {
height: 36px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
}
.terminal-tabs {
display: flex;
align-items: center;
gap: 4px;
}
.terminal-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: var(--bg-tertiary);
border-radius: 4px 4px 0 0;
font-size: 12px;
cursor: pointer;
}
.terminal-tab.active {
background-color: var(--bg-primary);
}
.tab-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
padding: 0;
line-height: 1;
}
.tab-close:hover {
color: var(--accent-error);
}
.btn-new-tab {
background: none;
border: 1px dashed var(--border-color);
color: var(--text-secondary);
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.btn-new-tab:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.terminal-actions {
display: flex;
gap: 4px;
}
.terminal {
flex: 1;
padding: 8px;
}
/* Footer */
.footer {
height: var(--footer-height);
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 11px;
color: var(--text-secondary);
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 400px;
max-width: 90%;
}
.modal-small {
width: 320px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 16px;
font-weight: 500;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
}
.modal-close:hover {
color: var(--accent-error);
}
.modal-body {
padding: 16px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px;
border-top: 1px solid var(--border-color);
}
/* Form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 13px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-primary);
}
.form-group input[type="checkbox"] {
margin-right: 8px;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 8px;
}
/* Fullscreen */
.fullscreen .header,
.fullscreen .footer {
display: none;
}
.fullscreen .terminal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
}
/* Responsive */
@media (max-width: 768px) {
.header-center {
display: none;
}
.terminal-actions {
display: none;
}
}

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MineNASAI - Web Terminal</title>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="app-container">
<!-- 顶部导航栏 -->
<header class="header">
<div class="header-left">
<h1 class="logo">MineNASAI</h1>
<span class="version">v0.1.0</span>
</div>
<div class="header-center">
<span class="connection-status" id="connectionStatus">
<span class="status-dot disconnected"></span>
<span class="status-text">未连接</span>
</span>
</div>
<div class="header-right">
<button class="btn btn-icon" id="settingsBtn" title="设置">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
<button class="btn btn-primary" id="connectBtn">连接</button>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
<!-- 终端容器 -->
<div class="terminal-container">
<div class="terminal-header">
<div class="terminal-tabs">
<div class="terminal-tab active" data-tab="main">
<span>主终端</span>
<button class="tab-close" title="关闭">&times;</button>
</div>
<button class="btn-new-tab" id="newTabBtn" title="新建终端">+</button>
</div>
<div class="terminal-actions">
<button class="btn btn-small" id="clearBtn" title="清屏">清屏</button>
<button class="btn btn-small" id="fullscreenBtn" title="全屏">全屏</button>
</div>
</div>
<div id="terminal" class="terminal"></div>
</div>
</main>
<!-- 底部状态栏 -->
<footer class="footer">
<div class="footer-left">
<span id="sessionInfo">会话: -</span>
</div>
<div class="footer-right">
<span id="terminalSize">-</span>
</div>
</footer>
</div>
<!-- 设置模态框 -->
<div class="modal" id="settingsModal">
<div class="modal-content">
<div class="modal-header">
<h2>设置</h2>
<button class="modal-close" id="closeSettingsBtn">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>字体大小</label>
<input type="number" id="fontSizeInput" value="14" min="10" max="24">
</div>
<div class="form-group">
<label>主题</label>
<select id="themeSelect">
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="monokai">Monokai</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="cursorBlinkCheck" checked>
光标闪烁
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn" id="resetSettingsBtn">重置</button>
<button class="btn btn-primary" id="saveSettingsBtn">保存</button>
</div>
</div>
</div>
<!-- 登录模态框 -->
<div class="modal" id="loginModal">
<div class="modal-content modal-small">
<div class="modal-header">
<h2>连接到终端</h2>
</div>
<div class="modal-body">
<div class="form-group">
<label>Token</label>
<input type="password" id="tokenInput" placeholder="输入访问令牌">
</div>
<p class="hint">提示:令牌可从通讯工具消息中获取</p>
</div>
<div class="modal-footer">
<button class="btn" id="cancelLoginBtn">取消</button>
<button class="btn btn-primary" id="doConnectBtn">连接</button>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,393 @@
/**
* MineNASAI Web Terminal
*/
class WebTerminal {
constructor() {
this.terminal = null;
this.fitAddon = null;
this.socket = null;
this.sessionId = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.settings = this.loadSettings();
this.init();
}
loadSettings() {
const defaults = {
fontSize: 14,
theme: 'dark',
cursorBlink: true
};
try {
const saved = localStorage.getItem('terminal_settings');
return saved ? { ...defaults, ...JSON.parse(saved) } : defaults;
} catch {
return defaults;
}
}
saveSettings() {
localStorage.setItem('terminal_settings', JSON.stringify(this.settings));
}
getTheme(name) {
const themes = {
dark: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selection: 'rgba(122, 162, 247, 0.3)',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
},
light: {
background: '#f5f5f5',
foreground: '#1a1b26',
cursor: '#1a1b26',
cursorAccent: '#f5f5f5',
selection: 'rgba(122, 162, 247, 0.3)'
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#272822',
selection: 'rgba(73, 72, 62, 0.5)',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2'
}
};
return themes[name] || themes.dark;
}
init() {
// 初始化终端
this.terminal = new Terminal({
fontSize: this.settings.fontSize,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace',
cursorBlink: this.settings.cursorBlink,
cursorStyle: 'block',
theme: this.getTheme(this.settings.theme),
allowTransparency: true,
scrollback: 10000
});
// 添加插件
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
// 挂载终端
const container = document.getElementById('terminal');
this.terminal.open(container);
this.fitAddon.fit();
// 显示欢迎信息
this.showWelcome();
// 绑定事件
this.bindEvents();
// 窗口大小调整
window.addEventListener('resize', () => {
this.fitAddon.fit();
this.sendResize();
});
// 更新设置UI
this.updateSettingsUI();
}
showWelcome() {
const welcome = [
'\x1b[1;34m',
' __ __ _ _ _ _ ____ _ ___ ',
' | \\/ (_)_ __ ___| \\ | | / \\ / ___| / \\ |_ _|',
' | |\\/| | | \'_ \\ / _ \\ \\| | / _ \\ \\___ \\ / _ \\ | | ',
' | | | | | | | | __/ |\\ |/ ___ \\ ___) / ___ \\ | | ',
' |_| |_|_|_| |_|\\___|_| \\_/_/ \\_\\____/_/ \\_\\___|',
'\x1b[0m',
'',
'\x1b[33mWeb Terminal v0.1.0\x1b[0m',
'',
'点击右上角 \x1b[1;32m连接\x1b[0m 按钮开始使用',
''
];
welcome.forEach(line => this.terminal.writeln(line));
}
bindEvents() {
// 连接按钮
document.getElementById('connectBtn').addEventListener('click', () => {
if (this.isConnected) {
this.disconnect();
} else {
this.showLoginModal();
}
});
// 登录模态框
document.getElementById('doConnectBtn').addEventListener('click', () => {
this.connect();
});
document.getElementById('cancelLoginBtn').addEventListener('click', () => {
this.hideLoginModal();
});
document.getElementById('tokenInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.connect();
});
// 设置按钮
document.getElementById('settingsBtn').addEventListener('click', () => {
document.getElementById('settingsModal').classList.add('active');
});
document.getElementById('closeSettingsBtn').addEventListener('click', () => {
document.getElementById('settingsModal').classList.remove('active');
});
document.getElementById('saveSettingsBtn').addEventListener('click', () => {
this.applySettings();
document.getElementById('settingsModal').classList.remove('active');
});
document.getElementById('resetSettingsBtn').addEventListener('click', () => {
this.settings = { fontSize: 14, theme: 'dark', cursorBlink: true };
this.updateSettingsUI();
this.applySettings();
});
// 清屏
document.getElementById('clearBtn').addEventListener('click', () => {
this.terminal.clear();
});
// 全屏
document.getElementById('fullscreenBtn').addEventListener('click', () => {
document.body.classList.toggle('fullscreen');
setTimeout(() => this.fitAddon.fit(), 100);
});
// 终端输入
this.terminal.onData(data => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
// ESC 退出全屏
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('fullscreen')) {
document.body.classList.remove('fullscreen');
setTimeout(() => this.fitAddon.fit(), 100);
}
});
}
updateSettingsUI() {
document.getElementById('fontSizeInput').value = this.settings.fontSize;
document.getElementById('themeSelect').value = this.settings.theme;
document.getElementById('cursorBlinkCheck').checked = this.settings.cursorBlink;
}
applySettings() {
this.settings.fontSize = parseInt(document.getElementById('fontSizeInput').value);
this.settings.theme = document.getElementById('themeSelect').value;
this.settings.cursorBlink = document.getElementById('cursorBlinkCheck').checked;
this.terminal.options.fontSize = this.settings.fontSize;
this.terminal.options.cursorBlink = this.settings.cursorBlink;
this.terminal.options.theme = this.getTheme(this.settings.theme);
this.saveSettings();
this.fitAddon.fit();
}
showLoginModal() {
document.getElementById('loginModal').classList.add('active');
document.getElementById('tokenInput').focus();
}
hideLoginModal() {
document.getElementById('loginModal').classList.remove('active');
document.getElementById('tokenInput').value = '';
}
connect() {
const token = document.getElementById('tokenInput').value.trim();
this.hideLoginModal();
this.updateStatus('connecting', '连接中...');
this.terminal.writeln('\x1b[33m正在连接...\x1b[0m');
// 获取 WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/terminal`;
try {
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
// 发送认证
this.socket.send(JSON.stringify({
type: 'auth',
token: token || 'anonymous'
}));
};
this.socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};
this.socket.onclose = (event) => {
this.isConnected = false;
this.updateStatus('disconnected', '已断开');
this.updateConnectButton(false);
if (event.code !== 1000) {
this.terminal.writeln(`\x1b[31m连接已断开 (${event.code})\x1b[0m`);
this.tryReconnect();
}
};
this.socket.onerror = (error) => {
this.terminal.writeln('\x1b[31m连接错误\x1b[0m');
console.error('WebSocket error:', error);
};
} catch (e) {
this.terminal.writeln(`\x1b[31m连接失败: ${e.message}\x1b[0m`);
this.updateStatus('disconnected', '连接失败');
}
}
handleMessage(msg) {
switch (msg.type) {
case 'auth_ok':
this.isConnected = true;
this.sessionId = msg.session_id;
this.reconnectAttempts = 0;
this.updateStatus('connected', '已连接');
this.updateConnectButton(true);
this.terminal.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
document.getElementById('sessionInfo').textContent = `会话: ${this.sessionId.slice(0, 8)}`;
// 发送终端尺寸
this.sendResize();
break;
case 'auth_error':
this.terminal.writeln(`\x1b[31m认证失败: ${msg.message}\x1b[0m`);
this.updateStatus('disconnected', '认证失败');
this.socket.close();
break;
case 'output':
this.terminal.write(msg.data);
break;
case 'resize_ok':
const cols = msg.cols;
const rows = msg.rows;
document.getElementById('terminalSize').textContent = `${cols}x${rows}`;
break;
case 'error':
this.terminal.writeln(`\x1b[31m错误: ${msg.message}\x1b[0m`);
break;
case 'ping':
this.socket.send(JSON.stringify({ type: 'pong' }));
break;
}
}
sendResize() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
const dims = this.fitAddon.proposeDimensions();
if (dims) {
this.socket.send(JSON.stringify({
type: 'resize',
cols: dims.cols,
rows: dims.rows
}));
}
}
}
disconnect() {
if (this.socket) {
this.socket.close(1000, 'User disconnect');
this.socket = null;
}
this.isConnected = false;
this.terminal.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
this.updateStatus('disconnected', '已断开');
this.updateConnectButton(false);
}
tryReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.terminal.writeln(`\x1b[33m${delay/1000}秒后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})\x1b[0m`);
setTimeout(() => {
if (!this.isConnected) {
this.connect();
}
}, delay);
}
}
updateStatus(status, text) {
const statusEl = document.getElementById('connectionStatus');
const dot = statusEl.querySelector('.status-dot');
const textEl = statusEl.querySelector('.status-text');
dot.className = 'status-dot ' + status;
textEl.textContent = text;
}
updateConnectButton(connected) {
const btn = document.getElementById('connectBtn');
btn.textContent = connected ? '断开' : '连接';
btn.classList.toggle('btn-primary', !connected);
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
window.webTerminal = new WebTerminal();
});