feat: add dashboard, LLM config, logs viewer, proxy config, scheduler, and terminal components

This commit is contained in:
锦麟 王
2026-02-05 17:33:56 +08:00
parent 950303ced5
commit a0c610f798
14 changed files with 2870 additions and 9 deletions

View File

@@ -1,11 +1,12 @@
"""Web TUI 服务器
提供 Web 终端界面和 WebSocket 终端通信
提供 Web 终端界面、WebUI 配置界面和 WebSocket 终端通信
"""
from __future__ import annotations
import asyncio
import time
import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
@@ -14,10 +15,12 @@ from typing import Any
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from minenasai.core import get_logger, get_settings, setup_logging
from minenasai.core.config import Settings, save_config
from minenasai.webtui.auth import get_auth_manager
from minenasai.webtui.ssh_manager import SSHSession, get_ssh_manager
@@ -25,6 +28,10 @@ logger = get_logger(__name__)
# 静态文件目录
STATIC_DIR = Path(__file__).parent / "static"
WEBUI_DIR = STATIC_DIR / "webui"
# 服务启动时间(用于计算运行时长)
_start_time: float = 0.0
class TerminalConnection:
@@ -102,6 +109,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
Args:
_app: FastAPI 应用实例lifespan 标准签名要求)
"""
global _start_time
_start_time = time.time()
settings = get_settings()
setup_logging(settings.logging)
logger.info("Web TUI 服务启动", port=settings.webtui.port)
@@ -134,16 +144,40 @@ app.add_middleware(
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# WebUI 静态文件
if WEBUI_DIR.exists():
app.mount("/webui/static", StaticFiles(directory=str(WEBUI_DIR)), name="webui_static")
@app.get("/")
async def index() -> HTMLResponse:
"""首页"""
"""首页 - 重定向到 WebUI"""
return HTMLResponse(content="""
<html>
<head><meta http-equiv="refresh" content="0; url=/webui"></head>
<body><a href="/webui">跳转到控制台</a></body>
</html>
""")
@app.get("/terminal")
async def terminal_page() -> 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("/webui")
async def webui_index() -> HTMLResponse:
"""WebUI 控制台首页"""
index_file = WEBUI_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
return HTMLResponse(content="<h1>MineNASAI WebUI</h1><p>WebUI 文件未找到</p>")
@app.get("/health")
async def health() -> dict[str, str]:
"""健康检查"""
@@ -155,14 +189,447 @@ async def stats() -> dict[str, Any]:
"""获取统计信息"""
ssh_manager = get_ssh_manager()
auth_manager = get_auth_manager()
settings = get_settings()
# 计算运行时长
uptime_seconds = time.time() - _start_time if _start_time > 0 else 0
hours = int(uptime_seconds // 3600)
minutes = int((uptime_seconds % 3600) // 60)
uptime = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
return {
"status": "running",
"connections": len(manager.connections),
"agents": len(settings.agents.items),
"tasks": 0, # TODO: 从调度器获取
"uptime": uptime,
"ssh": ssh_manager.get_stats(),
"auth": auth_manager.get_stats(),
}
# ==================== 配置管理 API ====================
class LLMConfigUpdate(BaseModel):
"""LLM 配置更新"""
default_provider: str | None = None
default_model: str | None = None
anthropic_api_key: str | None = None
openai_api_key: str | None = None
deepseek_api_key: str | None = None
zhipu_api_key: str | None = None
minimax_api_key: str | None = None
minimax_group_id: str | None = None
moonshot_api_key: str | None = None
gemini_api_key: str | None = None
class ChannelConfigUpdate(BaseModel):
"""通讯渠道配置更新"""
wework: dict[str, Any] | None = None
feishu: dict[str, Any] | None = None
class ProxyConfigUpdate(BaseModel):
"""代理配置更新"""
enabled: bool | None = None
http: str | None = None
https: str | None = None
no_proxy: list[str] | None = None
auto_detect: bool | None = None
@app.get("/api/config")
async def get_config() -> dict[str, Any]:
"""获取配置(隐藏敏感信息)"""
settings = get_settings()
# 构建安全的配置响应
return {
"llm": {
"default_provider": settings.llm.default_provider,
"default_model": settings.llm.default_model,
# API Key 只显示是否已配置
"anthropic_api_key": "***" if settings.llm.anthropic_api_key else "",
"openai_api_key": "***" if settings.llm.openai_api_key else "",
"deepseek_api_key": "***" if settings.llm.deepseek_api_key else "",
"zhipu_api_key": "***" if settings.llm.zhipu_api_key else "",
"minimax_api_key": "***" if settings.llm.minimax_api_key else "",
"minimax_group_id": settings.llm.minimax_group_id or "",
"moonshot_api_key": "***" if settings.llm.moonshot_api_key else "",
"gemini_api_key": "***" if settings.llm.gemini_api_key else "",
},
"channels": {
"wework": {
"enabled": settings.channels.wework.enabled,
"corp_id": settings.wework_corp_id or "",
"agent_id": settings.wework_agent_id or "",
"secret": "***" if settings.wework_secret else "",
"token": settings.wework_token or "",
"encoding_aes_key": "***" if settings.wework_encoding_aes_key else "",
},
"feishu": {
"enabled": settings.channels.feishu.enabled,
"app_id": settings.feishu_app_id or "",
"app_secret": "***" if settings.feishu_app_secret else "",
"verification_token": settings.feishu_verification_token or "",
"encrypt_key": "***" if settings.feishu_encrypt_key else "",
}
},
"proxy": {
"enabled": settings.proxy.enabled,
"http": settings.proxy.http,
"https": settings.proxy.https,
"no_proxy": settings.proxy.no_proxy,
"auto_detect": settings.proxy.auto_detect,
}
}
@app.put("/api/config/llm")
async def update_llm_config(data: LLMConfigUpdate) -> dict[str, Any]:
"""更新 LLM 配置"""
settings = get_settings()
# 更新非空字段
if data.default_provider is not None:
settings.llm.default_provider = data.default_provider
if data.default_model is not None:
settings.llm.default_model = data.default_model
if data.anthropic_api_key and data.anthropic_api_key != "***":
settings.llm.anthropic_api_key = data.anthropic_api_key
if data.openai_api_key and data.openai_api_key != "***":
settings.llm.openai_api_key = data.openai_api_key
if data.deepseek_api_key and data.deepseek_api_key != "***":
settings.llm.deepseek_api_key = data.deepseek_api_key
if data.zhipu_api_key and data.zhipu_api_key != "***":
settings.llm.zhipu_api_key = data.zhipu_api_key
if data.minimax_api_key and data.minimax_api_key != "***":
settings.llm.minimax_api_key = data.minimax_api_key
if data.minimax_group_id:
settings.llm.minimax_group_id = data.minimax_group_id
if data.moonshot_api_key and data.moonshot_api_key != "***":
settings.llm.moonshot_api_key = data.moonshot_api_key
if data.gemini_api_key and data.gemini_api_key != "***":
settings.llm.gemini_api_key = data.gemini_api_key
# 保存配置
save_config(settings)
logger.info("LLM 配置已更新")
return {"success": True, "message": "LLM 配置已保存"}
@app.put("/api/config/channels")
async def update_channels_config(data: ChannelConfigUpdate) -> dict[str, Any]:
"""更新通讯渠道配置"""
settings = get_settings()
if data.wework:
if "enabled" in data.wework:
settings.channels.wework.enabled = data.wework["enabled"]
if data.wework.get("corp_id"):
settings.wework_corp_id = data.wework["corp_id"]
if data.wework.get("agent_id"):
settings.wework_agent_id = data.wework["agent_id"]
if data.wework.get("secret") and data.wework["secret"] != "***":
settings.wework_secret = data.wework["secret"]
if data.wework.get("token"):
settings.wework_token = data.wework["token"]
if data.wework.get("encoding_aes_key") and data.wework["encoding_aes_key"] != "***":
settings.wework_encoding_aes_key = data.wework["encoding_aes_key"]
if data.feishu:
if "enabled" in data.feishu:
settings.channels.feishu.enabled = data.feishu["enabled"]
if data.feishu.get("app_id"):
settings.feishu_app_id = data.feishu["app_id"]
if data.feishu.get("app_secret") and data.feishu["app_secret"] != "***":
settings.feishu_app_secret = data.feishu["app_secret"]
if data.feishu.get("verification_token"):
settings.feishu_verification_token = data.feishu["verification_token"]
if data.feishu.get("encrypt_key") and data.feishu["encrypt_key"] != "***":
settings.feishu_encrypt_key = data.feishu["encrypt_key"]
save_config(settings)
logger.info("通讯渠道配置已更新")
return {"success": True, "message": "通讯渠道配置已保存"}
@app.put("/api/config/proxy")
async def update_proxy_config(data: ProxyConfigUpdate) -> dict[str, Any]:
"""更新代理配置"""
settings = get_settings()
if data.enabled is not None:
settings.proxy.enabled = data.enabled
if data.http is not None:
settings.proxy.http = data.http
if data.https is not None:
settings.proxy.https = data.https
if data.no_proxy is not None:
settings.proxy.no_proxy = data.no_proxy
if data.auto_detect is not None:
settings.proxy.auto_detect = data.auto_detect
save_config(settings)
logger.info("代理配置已更新")
return {"success": True, "message": "代理配置已保存"}
@app.post("/api/llm/test/{provider}")
async def test_llm_connection(provider: str) -> dict[str, Any]:
"""测试 LLM 连接"""
try:
from minenasai.llm import get_llm_manager
manager = get_llm_manager()
# 发送一个简单的测试消息
response = await manager.chat(
messages=[{"role": "user", "content": "Hello"}],
provider=provider,
max_tokens=10
)
return {"success": True, "message": "连接成功"}
except Exception as e:
return {"success": False, "error": str(e)}
@app.post("/api/proxy/test")
async def test_proxy() -> dict[str, Any]:
"""测试代理连接"""
import httpx
settings = get_settings()
if not settings.proxy.enabled:
return {"success": False, "error": "代理未启用"}
try:
proxy_url = settings.proxy.http or settings.proxy.https
async with httpx.AsyncClient(proxy=proxy_url, timeout=10) as client:
response = await client.get("https://httpbin.org/ip")
return {"success": True, "ip": response.json().get("origin")}
except Exception as e:
return {"success": False, "error": str(e)}
@app.post("/api/proxy/detect")
async def detect_proxy() -> dict[str, Any]:
"""检测本地代理"""
import socket
common_ports = [7890, 7891, 1080, 1087, 10808]
for port in common_ports:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(('127.0.0.1', port))
sock.close()
if result == 0:
proxy_url = f"http://127.0.0.1:{port}"
return {
"detected": True,
"http": proxy_url,
"https": proxy_url,
"port": port
}
except Exception:
continue
return {"detected": False}
# ==================== Agent 管理 API ====================
class AgentCreate(BaseModel):
"""Agent 创建/更新"""
id: str
name: str
workspace_path: str = "~/.config/minenasai/workspace"
model: str = "claude-sonnet-4-20250514"
temperature: float = 0.7
tools: dict[str, list[str]] | None = None
sandbox: dict[str, Any] | None = None
@app.get("/api/agents")
async def list_agents() -> dict[str, Any]:
"""列出所有 Agent"""
settings = get_settings()
agents = []
for agent in settings.agents.items:
agents.append({
"id": agent.id,
"name": agent.name,
"workspace_path": agent.workspace_path,
"model": settings.agents.default_model,
"temperature": settings.agents.temperature,
"tools": {
"allow": agent.tools.allow,
"deny": agent.tools.deny,
},
"sandbox": {
"mode": agent.sandbox.mode,
},
"status": "idle"
})
return {"agents": agents}
@app.post("/api/agents")
async def create_agent(data: AgentCreate) -> dict[str, Any]:
"""创建 Agent"""
from minenasai.core.config import AgentConfig, AgentToolsConfig, AgentSandboxConfig
settings = get_settings()
# 检查 ID 是否已存在
for agent in settings.agents.items:
if agent.id == data.id:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Agent ID 已存在"}
)
# 创建新 Agent
tools_config = AgentToolsConfig(
allow=data.tools.get("allow", []) if data.tools else [],
deny=data.tools.get("deny", []) if data.tools else []
)
sandbox_config = AgentSandboxConfig(
mode=data.sandbox.get("mode", "workspace") if data.sandbox else "workspace"
)
new_agent = AgentConfig(
id=data.id,
name=data.name,
workspace_path=data.workspace_path,
tools=tools_config,
sandbox=sandbox_config
)
settings.agents.items.append(new_agent)
save_config(settings)
logger.info("Agent 已创建", agent_id=data.id)
return {"success": True, "message": "Agent 创建成功"}
@app.put("/api/agents/{agent_id}")
async def update_agent(agent_id: str, data: AgentCreate) -> dict[str, Any]:
"""更新 Agent"""
settings = get_settings()
for i, agent in enumerate(settings.agents.items):
if agent.id == agent_id:
agent.name = data.name
agent.workspace_path = data.workspace_path
if data.tools:
agent.tools.allow = data.tools.get("allow", [])
agent.tools.deny = data.tools.get("deny", [])
if data.sandbox:
agent.sandbox.mode = data.sandbox.get("mode", "workspace")
save_config(settings)
logger.info("Agent 已更新", agent_id=agent_id)
return {"success": True, "message": "Agent 更新成功"}
return JSONResponse(
status_code=404,
content={"success": False, "error": "Agent 不存在"}
)
@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str) -> dict[str, Any]:
"""删除 Agent"""
settings = get_settings()
for i, agent in enumerate(settings.agents.items):
if agent.id == agent_id:
settings.agents.items.pop(i)
save_config(settings)
logger.info("Agent 已删除", agent_id=agent_id)
return {"success": True, "message": "Agent 删除成功"}
return JSONResponse(
status_code=404,
content={"success": False, "error": "Agent 不存在"}
)
# ==================== 定时任务 API ====================
class CronJobCreate(BaseModel):
"""定时任务创建/更新"""
name: str
agent_id: str = "main"
schedule: str
task: str
enabled: bool = True
@app.get("/api/cron-jobs")
async def list_cron_jobs() -> dict[str, Any]:
"""列出所有定时任务"""
# TODO: 从数据库获取
return {"jobs": []}
@app.post("/api/cron-jobs")
async def create_cron_job(data: CronJobCreate) -> dict[str, Any]:
"""创建定时任务"""
# TODO: 保存到数据库
logger.info("定时任务已创建", name=data.name)
return {"success": True, "message": "定时任务创建成功"}
@app.put("/api/cron-jobs/{job_id}")
async def update_cron_job(job_id: str, data: CronJobCreate) -> dict[str, Any]:
"""更新定时任务"""
# TODO: 更新数据库
return {"success": True, "message": "定时任务更新成功"}
@app.delete("/api/cron-jobs/{job_id}")
async def delete_cron_job(job_id: str) -> dict[str, Any]:
"""删除定时任务"""
# TODO: 从数据库删除
return {"success": True, "message": "定时任务删除成功"}
@app.post("/api/cron-jobs/{job_id}/toggle")
async def toggle_cron_job(job_id: str) -> dict[str, Any]:
"""切换定时任务启用状态"""
# TODO: 更新数据库
return {"success": True, "message": "状态已切换"}
@app.post("/api/cron-jobs/{job_id}/run")
async def run_cron_job(job_id: str) -> dict[str, Any]:
"""立即执行定时任务"""
# TODO: 触发任务执行
return {"success": True, "message": "任务已触发执行"}
# ==================== 日志 API ====================
@app.get("/api/logs")
async def get_logs(limit: int = 100) -> dict[str, Any]:
"""获取最近日志"""
# TODO: 从日志文件读取
return {"logs": []}
@app.post("/api/token")
async def generate_token(
user_id: str = "anonymous",

View File

@@ -4,8 +4,9 @@
<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">
<!-- xterm.js - 使用 unpkg 备用 CDN -->
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css"
onerror="this.href='https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css'">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
@@ -119,10 +120,19 @@
</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>
<!-- Scripts - 使用多 CDN 备选 -->
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://unpkg.com/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script>
// 检查 xterm.js 加载状态,如果失败则尝试备用 CDN
if (typeof Terminal === 'undefined') {
console.log('主 CDN 加载失败,尝试备用 CDN...');
document.write('<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"><\/script>');
document.write('<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"><\/script>');
document.write('<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"><\/script>');
}
</script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -13,8 +13,29 @@ class WebTerminal {
this.maxReconnectAttempts = 5;
this.settings = this.loadSettings();
// 检查 xterm.js 是否已加载
if (typeof Terminal === 'undefined') {
console.error('xterm.js 未加载,尝试重新加载...');
this.showLoadError();
return;
}
this.init();
}
showLoadError() {
const container = document.getElementById('terminal');
if (container) {
container.innerHTML = `
<div style="padding: 20px; color: #f7768e; text-align: center;">
<h3>终端加载失败</h3>
<p>xterm.js 库未能正确加载,请检查网络连接或刷新页面重试。</p>
<button onclick="location.reload()" style="margin-top: 10px; padding: 8px 16px; background: #7aa2f7; border: none; border-radius: 4px; color: #1a1b26; cursor: pointer;">刷新页面</button>
</div>
`;
}
}
loadSettings() {
const defaults = {

View File

@@ -0,0 +1,382 @@
/* MineNASAI WebUI 样式 */
: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;
}
* {
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 {
height: 100vh;
}
/* 侧边栏 */
.sidebar {
background-color: var(--bg-primary);
border-right: 1px solid var(--border-color);
transition: width 0.3s;
overflow: hidden;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.logo-icon {
font-size: 24px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: var(--accent-primary);
}
.el-menu {
border-right: none !important;
}
.el-menu-item.is-active {
background-color: var(--bg-secondary) !important;
}
.el-menu-item:hover, .el-sub-menu__title:hover {
background-color: var(--bg-tertiary) !important;
}
/* 头部 */
.header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* 主内容区 */
.main-content {
background-color: var(--bg-tertiary);
padding: 20px;
overflow-y: auto;
}
/* 卡片样式 */
.config-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 20px;
}
.config-card .el-card__header {
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
padding: 15px 20px;
}
.config-card .el-card__body {
padding: 20px;
}
/* 统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
}
.stat-card-title {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-card-value {
font-size: 28px;
font-weight: 600;
color: var(--accent-primary);
}
.stat-card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
/* 表单样式 */
.el-form-item__label {
color: var(--text-secondary) !important;
}
.el-input__inner, .el-textarea__inner, .el-select .el-input__inner {
background-color: var(--bg-tertiary) !important;
border-color: var(--border-color) !important;
color: var(--text-primary) !important;
}
.el-input__inner:focus, .el-textarea__inner:focus {
border-color: var(--accent-primary) !important;
}
.el-select-dropdown {
background-color: var(--bg-secondary) !important;
border-color: var(--border-color) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
}
.el-select-dropdown__item.hover, .el-select-dropdown__item:hover {
background-color: var(--bg-tertiary) !important;
}
/* 表格样式 */
.el-table {
--el-table-bg-color: var(--bg-secondary);
--el-table-header-bg-color: var(--bg-tertiary);
--el-table-tr-bg-color: var(--bg-secondary);
--el-table-row-hover-bg-color: var(--bg-tertiary);
--el-table-text-color: var(--text-primary);
--el-table-header-text-color: var(--text-secondary);
--el-table-border-color: var(--border-color);
}
/* 终端容器 */
.terminal-container {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
height: calc(100vh - 180px);
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.terminal-title {
display: flex;
align-items: center;
gap: 8px;
}
.terminal-status {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent-error);
}
.terminal-status.connected {
background-color: var(--accent-success);
}
.terminal-body {
height: calc(100% - 45px);
padding: 8px;
}
/* 日志容器 */
.logs-container {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
height: calc(100vh - 180px);
overflow: hidden;
}
.logs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.logs-body {
height: calc(100% - 45px);
overflow-y: auto;
padding: 10px 15px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 10px;
}
.log-time {
color: var(--text-secondary);
flex-shrink: 0;
}
.log-level {
flex-shrink: 0;
padding: 0 6px;
border-radius: 3px;
font-size: 10px;
text-transform: uppercase;
}
.log-level.info {
background-color: rgba(122, 162, 247, 0.2);
color: var(--accent-primary);
}
.log-level.warn {
background-color: rgba(224, 175, 104, 0.2);
color: var(--accent-warning);
}
.log-level.error {
background-color: rgba(247, 118, 142, 0.2);
color: var(--accent-error);
}
.log-message {
flex: 1;
word-break: break-all;
}
/* 按钮样式 */
.el-button--primary {
--el-button-bg-color: var(--accent-primary);
--el-button-border-color: var(--accent-primary);
--el-button-hover-bg-color: #89b4fa;
--el-button-hover-border-color: #89b4fa;
}
/* 对话框样式 */
.el-dialog {
--el-dialog-bg-color: var(--bg-secondary);
--el-dialog-border-radius: 8px;
}
.el-dialog__header {
border-bottom: 1px solid var(--border-color);
}
.el-dialog__title {
color: var(--text-primary) !important;
}
/* 标签页 */
.el-tabs__item {
color: var(--text-secondary) !important;
}
.el-tabs__item.is-active {
color: var(--accent-primary) !important;
}
/* Provider 卡片 */
.provider-card {
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
transition: border-color 0.3s;
}
.provider-card:hover {
border-color: var(--accent-primary);
}
.provider-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.provider-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.provider-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background-color: rgba(122, 162, 247, 0.2);
color: var(--accent-primary);
}
.provider-badge.domestic {
background-color: rgba(158, 206, 106, 0.2);
color: var(--accent-success);
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 100;
left: -220px;
}
.sidebar.open {
left: 0;
}
}

View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MineNASAI - 控制台</title>
<!-- Element Plus CSS -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<!-- xterm.js CSS -->
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
<!-- 自定义样式 -->
<link rel="stylesheet" href="./css/webui.css">
</head>
<body>
<div id="app">
<el-config-provider :locale="zhCn">
<el-container class="app-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '220px'" class="sidebar">
<div class="logo" @click="isCollapsed = !isCollapsed">
<span class="logo-icon">🤖</span>
<span v-show="!isCollapsed" class="logo-text">MineNASAI</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapsed"
:collapse-transition="false"
background-color="#1a1b26"
text-color="#c0caf5"
active-text-color="#7aa2f7"
@select="handleMenuSelect"
>
<el-menu-item index="dashboard">
<el-icon><Monitor /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-sub-menu index="config">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</template>
<el-menu-item index="llm">LLM 接口</el-menu-item>
<el-menu-item index="channels">通讯渠道</el-menu-item>
<el-menu-item index="proxy">代理设置</el-menu-item>
</el-sub-menu>
<el-menu-item index="agents">
<el-icon><User /></el-icon>
<span>Agent 管理</span>
</el-menu-item>
<el-menu-item index="scheduler">
<el-icon><Clock /></el-icon>
<span>定时任务</span>
</el-menu-item>
<el-menu-item index="terminal">
<el-icon><Monitor /></el-icon>
<span>终端</span>
</el-menu-item>
<el-menu-item index="logs">
<el-icon><Document /></el-icon>
<span>系统日志</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-tag :type="systemStatus === 'running' ? 'success' : 'danger'" size="small">
{{ systemStatus === 'running' ? '运行中' : '异常' }}
</el-tag>
<el-dropdown>
<el-button text>
<el-icon><User /></el-icon>
管理员
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<!-- 仪表盘 -->
<dashboard-page v-if="activeMenu === 'dashboard'" :stats="systemStats"></dashboard-page>
<!-- LLM 配置 -->
<llm-config-page v-if="activeMenu === 'llm'" :config="config.llm" @save="saveLLMConfig"></llm-config-page>
<!-- 通讯渠道配置 -->
<channels-config-page v-if="activeMenu === 'channels'" :config="config.channels" @save="saveChannelsConfig"></channels-config-page>
<!-- 代理配置 -->
<proxy-config-page v-if="activeMenu === 'proxy'" :config="config.proxy" @save="saveProxyConfig"></proxy-config-page>
<!-- Agent 管理 -->
<agents-page v-if="activeMenu === 'agents'" :agents="agents" @refresh="loadAgents"></agents-page>
<!-- 定时任务 -->
<scheduler-page v-if="activeMenu === 'scheduler'" :jobs="cronJobs" @refresh="loadCronJobs"></scheduler-page>
<!-- 终端 -->
<terminal-page v-if="activeMenu === 'terminal'" ref="terminalPage"></terminal-page>
<!-- 日志 -->
<logs-page v-if="activeMenu === 'logs'"></logs-page>
</el-main>
</el-container>
</el-container>
</el-config-provider>
</div>
<!-- Vue 3 + Element Plus -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/element-plus/dist/locale/zh-cn.min.js"></script>
<!-- xterm.js -->
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<!-- 组件 -->
<script src="./js/components/dashboard.js"></script>
<script src="./js/components/llm-config.js"></script>
<script src="./js/components/channels-config.js"></script>
<script src="./js/components/proxy-config.js"></script>
<script src="./js/components/agents.js"></script>
<script src="./js/components/scheduler.js"></script>
<script src="./js/components/terminal.js"></script>
<script src="./js/components/logs.js"></script>
<!-- 主应用 -->
<script src="./js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,247 @@
/**
* MineNASAI WebUI 主应用
*/
const { createApp, ref, reactive, computed, onMounted, watch } = Vue;
// API 基础路径
const API_BASE = '/api';
// API 请求封装
const api = {
async get(url) {
const res = await fetch(API_BASE + url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async post(url, data) {
const res = await fetch(API_BASE + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async put(url, data) {
const res = await fetch(API_BASE + url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async delete(url) {
const res = await fetch(API_BASE + url, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
};
// 创建 Vue 应用
const app = createApp({
setup() {
// 状态
const isCollapsed = ref(false);
const activeMenu = ref('dashboard');
const systemStatus = ref('running');
const systemStats = reactive({
connections: 0,
agents: 0,
tasks: 0,
uptime: '0h'
});
// 配置数据
const config = reactive({
llm: {
default_provider: 'anthropic',
default_model: 'claude-sonnet-4-20250514',
anthropic_api_key: '',
openai_api_key: '',
deepseek_api_key: '',
zhipu_api_key: '',
minimax_api_key: '',
minimax_group_id: '',
moonshot_api_key: '',
gemini_api_key: ''
},
channels: {
wework: {
enabled: false,
corp_id: '',
agent_id: '',
secret: '',
token: '',
encoding_aes_key: ''
},
feishu: {
enabled: false,
app_id: '',
app_secret: '',
verification_token: '',
encrypt_key: ''
}
},
proxy: {
enabled: false,
http: '',
https: '',
no_proxy: [],
auto_detect: true
}
});
// Agent 列表
const agents = ref([]);
// 定时任务列表
const cronJobs = ref([]);
// 当前页面标题
const currentPageTitle = computed(() => {
const titles = {
'dashboard': '仪表盘',
'llm': 'LLM 接口配置',
'channels': '通讯渠道配置',
'proxy': '代理设置',
'agents': 'Agent 管理',
'scheduler': '定时任务',
'terminal': '终端',
'logs': '系统日志'
};
return titles[activeMenu.value] || 'MineNASAI';
});
// 加载配置
async function loadConfig() {
try {
const data = await api.get('/config');
Object.assign(config.llm, data.llm || {});
Object.assign(config.channels, data.channels || {});
Object.assign(config.proxy, data.proxy || {});
} catch (e) {
console.error('加载配置失败:', e);
}
}
// 加载系统状态
async function loadStats() {
try {
const data = await api.get('/stats');
Object.assign(systemStats, data);
systemStatus.value = data.status || 'running';
} catch (e) {
console.error('加载状态失败:', e);
systemStatus.value = 'error';
}
}
// 加载 Agent 列表
async function loadAgents() {
try {
const data = await api.get('/agents');
agents.value = data.agents || [];
} catch (e) {
console.error('加载 Agent 失败:', e);
}
}
// 加载定时任务
async function loadCronJobs() {
try {
const data = await api.get('/cron-jobs');
cronJobs.value = data.jobs || [];
} catch (e) {
console.error('加载定时任务失败:', e);
}
}
// 保存 LLM 配置
async function saveLLMConfig(data) {
try {
await api.put('/config/llm', data);
Object.assign(config.llm, data);
ElementPlus.ElMessage.success('LLM 配置保存成功');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 保存通讯渠道配置
async function saveChannelsConfig(data) {
try {
await api.put('/config/channels', data);
Object.assign(config.channels, data);
ElementPlus.ElMessage.success('通讯渠道配置保存成功');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 保存代理配置
async function saveProxyConfig(data) {
try {
await api.put('/config/proxy', data);
Object.assign(config.proxy, data);
ElementPlus.ElMessage.success('代理配置保存成功');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 菜单选择
function handleMenuSelect(index) {
activeMenu.value = index;
}
// 退出登录
function handleLogout() {
if (confirm('确定要退出登录吗?')) {
window.location.href = '/';
}
}
// 初始化
onMounted(() => {
loadConfig();
loadStats();
loadAgents();
loadCronJobs();
// 定时刷新状态
setInterval(loadStats, 30000);
});
return {
isCollapsed,
activeMenu,
systemStatus,
systemStats,
config,
agents,
cronJobs,
currentPageTitle,
handleMenuSelect,
handleLogout,
loadAgents,
loadCronJobs,
saveLLMConfig,
saveChannelsConfig,
saveProxyConfig,
zhCn: ElementPlusLocaleZhCn
};
}
});
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 使用 Element Plus
app.use(ElementPlus);
// 挂载应用
app.mount('#app');

View File

@@ -0,0 +1,244 @@
/**
* Agent 管理组件
*/
app.component('agents-page', {
props: ['agents'],
emits: ['refresh'],
setup(props, { emit }) {
const { ref, reactive, computed } = Vue;
// 对话框状态
const dialogVisible = ref(false);
const dialogTitle = ref('新建 Agent');
const editingId = ref(null);
// 表单数据
const form = reactive({
id: '',
name: '',
workspace_path: '~/.config/minenasai/workspace',
model: 'claude-sonnet-4-20250514',
temperature: 0.7,
tools_allow: [],
tools_deny: [],
sandbox_mode: 'workspace'
});
// 可用工具列表
const availableTools = [
{ value: 'read', label: '读取文件' },
{ value: 'write', label: '写入文件' },
{ value: 'python', label: 'Python 执行' },
{ value: 'exec', label: '执行命令' },
{ value: 'web_search', label: '网页搜索' },
{ value: 'delete', label: '删除文件' },
{ value: 'docker', label: 'Docker 操作' },
{ value: 'system_config', label: '系统配置' }
];
// 模型列表
const models = [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'gpt-4o',
'deepseek-chat',
'glm-4-flash',
'moonshot-v1-8k'
];
// 沙箱模式
const sandboxModes = [
{ value: 'workspace', label: '工作目录限制' },
{ value: 'docker', label: 'Docker 容器' },
{ value: 'none', label: '无限制(危险)' }
];
// 打开新建对话框
function handleAdd() {
editingId.value = null;
dialogTitle.value = '新建 Agent';
Object.assign(form, {
id: '',
name: '',
workspace_path: '~/.config/minenasai/workspace',
model: 'claude-sonnet-4-20250514',
temperature: 0.7,
tools_allow: ['read', 'write', 'python'],
tools_deny: ['delete', 'system_config'],
sandbox_mode: 'workspace'
});
dialogVisible.value = true;
}
// 打开编辑对话框
function handleEdit(agent) {
editingId.value = agent.id;
dialogTitle.value = '编辑 Agent';
Object.assign(form, {
id: agent.id,
name: agent.name,
workspace_path: agent.workspace_path || '~/.config/minenasai/workspace',
model: agent.model || 'claude-sonnet-4-20250514',
temperature: agent.temperature || 0.7,
tools_allow: agent.tools?.allow || [],
tools_deny: agent.tools?.deny || [],
sandbox_mode: agent.sandbox?.mode || 'workspace'
});
dialogVisible.value = true;
}
// 删除 Agent
async function handleDelete(agent) {
try {
await ElementPlus.ElMessageBox.confirm(
`确定要删除 Agent "${agent.name}" 吗?`,
'删除确认',
{ type: 'warning' }
);
await fetch(`/api/agents/${agent.id}`, { method: 'DELETE' });
ElementPlus.ElMessage.success('删除成功');
emit('refresh');
} catch (e) {
if (e !== 'cancel') {
ElementPlus.ElMessage.error('删除失败: ' + e.message);
}
}
}
// 保存 Agent
async function handleSave() {
try {
const data = {
id: form.id,
name: form.name,
workspace_path: form.workspace_path,
model: form.model,
temperature: form.temperature,
tools: {
allow: form.tools_allow,
deny: form.tools_deny
},
sandbox: {
mode: form.sandbox_mode
}
};
if (editingId.value) {
await fetch(`/api/agents/${editingId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
dialogVisible.value = false;
ElementPlus.ElMessage.success('保存成功');
emit('refresh');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
return {
dialogVisible,
dialogTitle,
form,
availableTools,
models,
sandboxModes,
handleAdd,
handleEdit,
handleDelete,
handleSave
};
},
template: `
<div class="agents-page">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Agent 列表</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新建 Agent
</el-button>
</div>
</template>
<el-table :data="agents" border stripe>
<el-table-column prop="id" label="ID" width="120" />
<el-table-column prop="name" label="名称" width="150" />
<el-table-column prop="model" label="模型" width="200">
<template #default="{ row }">
<el-tag size="small">{{ row.model || 'claude-sonnet-4-20250514' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="workspace_path" label="工作目录" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '活跃' : '空闲' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button text type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!agents || agents.length === 0" description="暂无 Agent" />
</el-card>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" label-width="120px">
<el-form-item label="Agent ID" required>
<el-input v-model="form.id" :disabled="!!editingId" placeholder="唯一标识,如 main" />
</el-form-item>
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="Agent 显示名称" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="form.model" style="width: 100%">
<el-option v-for="m in models" :key="m" :value="m" :label="m" />
</el-select>
</el-form-item>
<el-form-item label="温度">
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="工作目录">
<el-input v-model="form.workspace_path" placeholder="~/.config/minenasai/workspace" />
</el-form-item>
<el-form-item label="允许的工具">
<el-select v-model="form.tools_allow" multiple style="width: 100%">
<el-option v-for="t in availableTools" :key="t.value" :value="t.value" :label="t.label" />
</el-select>
</el-form-item>
<el-form-item label="禁止的工具">
<el-select v-model="form.tools_deny" multiple style="width: 100%">
<el-option v-for="t in availableTools" :key="t.value" :value="t.value" :label="t.label" />
</el-select>
</el-form-item>
<el-form-item label="沙箱模式">
<el-select v-model="form.sandbox_mode" style="width: 100%">
<el-option v-for="m in sandboxModes" :key="m.value" :value="m.value" :label="m.label" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
`
});

View File

@@ -0,0 +1,162 @@
/**
* 通讯渠道配置组件
*/
app.component('channels-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch } = Vue;
const activeTab = ref('wework');
// 表单数据
const form = reactive({
wework: {
enabled: false,
corp_id: '',
agent_id: '',
secret: '',
token: '',
encoding_aes_key: ''
},
feishu: {
enabled: false,
app_id: '',
app_secret: '',
verification_token: '',
encrypt_key: ''
}
});
// 监听 props 变化
watch(() => props.config, (newVal) => {
if (newVal) {
if (newVal.wework) Object.assign(form.wework, newVal.wework);
if (newVal.feishu) Object.assign(form.feishu, newVal.feishu);
}
}, { immediate: true, deep: true });
// 保存配置
function handleSave() {
emit('save', { ...form });
}
return {
activeTab,
form,
handleSave
};
},
template: `
<div class="channels-config">
<el-tabs v-model="activeTab" type="border-card">
<!-- 企业微信 -->
<el-tab-pane label="企业微信" name="wework">
<el-card class="config-card" shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>企业微信配置</span>
<el-switch v-model="form.wework.enabled" active-text="启用" inactive-text="禁用" />
</div>
</template>
<el-alert
title="配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<template #default>
<ol style="margin: 8px 0 0 20px; padding: 0;">
<li>登录企业微信管理后台 → 应用管理 → 创建应用</li>
<li>获取 CorpID、AgentID、Secret</li>
<li>在「接收消息」中配置 URL、Token、EncodingAESKey</li>
<li>Webhook URL: <code>https://your-domain.com/webhook/wework</code></li>
</ol>
</template>
</el-alert>
<el-form :model="form.wework" label-width="160px" :disabled="!form.wework.enabled">
<el-form-item label="企业 ID (CorpID)">
<el-input v-model="form.wework.corp_id" placeholder="ww..." style="width: 400px" />
</el-form-item>
<el-form-item label="应用 ID (AgentID)">
<el-input v-model="form.wework.agent_id" placeholder="1000001" style="width: 400px" />
</el-form-item>
<el-form-item label="应用 Secret">
<el-input v-model="form.wework.secret" type="password" show-password style="width: 400px" />
</el-form-item>
<el-form-item label="Token">
<el-input v-model="form.wework.token" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">用于验证消息签名</span>
</template>
</el-form-item>
<el-form-item label="EncodingAESKey">
<el-input v-model="form.wework.encoding_aes_key" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">43位字符用于消息加解密</span>
</template>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<!-- 飞书 -->
<el-tab-pane label="飞书" name="feishu">
<el-card class="config-card" shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>飞书配置</span>
<el-switch v-model="form.feishu.enabled" active-text="启用" inactive-text="禁用" />
</div>
</template>
<el-alert
title="配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<template #default>
<ol style="margin: 8px 0 0 20px; padding: 0;">
<li>登录飞书开放平台 → 创建企业自建应用</li>
<li>获取 App ID 和 App Secret</li>
<li>在「事件订阅」中配置请求地址和 Token</li>
<li>Webhook URL: <code>https://your-domain.com/webhook/feishu</code></li>
</ol>
</template>
</el-alert>
<el-form :model="form.feishu" label-width="160px" :disabled="!form.feishu.enabled">
<el-form-item label="App ID">
<el-input v-model="form.feishu.app_id" placeholder="cli_xxx" style="width: 400px" />
</el-form-item>
<el-form-item label="App Secret">
<el-input v-model="form.feishu.app_secret" type="password" show-password style="width: 400px" />
</el-form-item>
<el-form-item label="Verification Token">
<el-input v-model="form.feishu.verification_token" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">用于验证事件来源</span>
</template>
</el-form-item>
<el-form-item label="Encrypt Key">
<el-input v-model="form.feishu.encrypt_key" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">可选,用于消息加密</span>
</template>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
<div style="margin-top: 20px;">
<el-button type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
`
});

View File

@@ -0,0 +1,69 @@
/**
* 仪表盘组件
*/
app.component('dashboard-page', {
props: ['stats'],
template: `
<div class="dashboard">
<div class="stat-cards">
<div class="stat-card">
<div class="stat-card-title">活跃连接</div>
<div class="stat-card-value">{{ stats.connections || 0 }}</div>
<div class="stat-card-sub">WebSocket 连接数</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Agent 数量</div>
<div class="stat-card-value">{{ stats.agents || 0 }}</div>
<div class="stat-card-sub">已配置的 Agent</div>
</div>
<div class="stat-card">
<div class="stat-card-title">定时任务</div>
<div class="stat-card-value">{{ stats.tasks || 0 }}</div>
<div class="stat-card-sub">活跃的 Cron 任务</div>
</div>
<div class="stat-card">
<div class="stat-card-title">运行时间</div>
<div class="stat-card-value">{{ stats.uptime || '0h' }}</div>
<div class="stat-card-sub">服务运行时长</div>
</div>
</div>
<el-row :gutter="20">
<el-col :span="12">
<el-card class="config-card">
<template #header>
<span>快速操作</span>
</template>
<el-space direction="vertical" :size="12" fill style="width: 100%">
<el-button type="primary" style="width: 100%" @click="$emit('navigate', 'llm')">
<el-icon><Setting /></el-icon>
配置 LLM 接口
</el-button>
<el-button style="width: 100%" @click="$emit('navigate', 'channels')">
<el-icon><ChatDotRound /></el-icon>
配置通讯渠道
</el-button>
<el-button style="width: 100%" @click="$emit('navigate', 'terminal')">
<el-icon><Monitor /></el-icon>
打开终端
</el-button>
</el-space>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="config-card">
<template #header>
<span>系统信息</span>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="版本">v0.1.0</el-descriptions-item>
<el-descriptions-item label="Python">3.11+</el-descriptions-item>
<el-descriptions-item label="Gateway 端口">8000</el-descriptions-item>
<el-descriptions-item label="WebUI 端口">8080</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
`
});

View File

@@ -0,0 +1,232 @@
/**
* LLM 配置组件
*/
app.component('llm-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch } = Vue;
// 表单数据
const form = reactive({
default_provider: '',
default_model: '',
anthropic_api_key: '',
openai_api_key: '',
deepseek_api_key: '',
zhipu_api_key: '',
minimax_api_key: '',
minimax_group_id: '',
moonshot_api_key: '',
gemini_api_key: ''
});
// 监听 props 变化
watch(() => props.config, (newVal) => {
if (newVal) {
Object.assign(form, newVal);
}
}, { immediate: true, deep: true });
// 提供商列表
const providers = [
{ value: 'anthropic', label: 'Anthropic (Claude)', region: 'overseas', models: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-opus-20240229'] },
{ value: 'openai', label: 'OpenAI (GPT)', region: 'overseas', models: ['gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
{ value: 'gemini', label: 'Google Gemini', region: 'overseas', models: ['gemini-2.0-flash', 'gemini-1.5-pro'] },
{ value: 'deepseek', label: 'DeepSeek', region: 'domestic', models: ['deepseek-chat', 'deepseek-coder'] },
{ value: 'zhipu', label: '智谱 (GLM)', region: 'domestic', models: ['glm-4-flash', 'glm-4', 'glm-3-turbo'] },
{ value: 'minimax', label: 'MiniMax', region: 'domestic', models: ['abab6.5s-chat', 'abab5.5-chat'] },
{ value: 'moonshot', label: 'Moonshot (Kimi)', region: 'domestic', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] }
];
// 当前选中提供商的模型列表
const currentModels = Vue.computed(() => {
const provider = providers.find(p => p.value === form.default_provider);
return provider ? provider.models : [];
});
// 测试连接
const testing = ref(null);
async function testConnection(provider) {
testing.value = provider;
try {
const res = await fetch(`/api/llm/test/${provider}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
ElementPlus.ElMessage.success(`${provider} 连接成功`);
} else {
ElementPlus.ElMessage.error(`${provider} 连接失败: ${data.error}`);
}
} catch (e) {
ElementPlus.ElMessage.error(`测试失败: ${e.message}`);
}
testing.value = null;
}
// 保存配置
function handleSave() {
emit('save', { ...form });
}
return {
form,
providers,
currentModels,
testing,
testConnection,
handleSave
};
},
template: `
<div class="llm-config">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>默认提供商</span>
</div>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="默认提供商">
<el-select v-model="form.default_provider" style="width: 300px">
<el-option
v-for="p in providers"
:key="p.value"
:value="p.value"
:label="p.label"
>
<span>{{ p.label }}</span>
<el-tag size="small" :type="p.region === 'domestic' ? 'success' : ''" style="margin-left: 8px">
{{ p.region === 'domestic' ? '国内' : '境外' }}
</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="默认模型">
<el-select v-model="form.default_model" style="width: 300px">
<el-option v-for="m in currentModels" :key="m" :value="m" :label="m" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- 境外服务 -->
<el-card class="config-card">
<template #header>
<span>境外服务 <el-tag size="small" type="warning">需要代理</el-tag></span>
</template>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🤖</span>
<span>Anthropic (Claude)</span>
</div>
<el-button size="small" :loading="testing === 'anthropic'" @click="testConnection('anthropic')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.anthropic_api_key" type="password" show-password placeholder="sk-ant-xxx" style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>💬</span>
<span>OpenAI (GPT)</span>
</div>
<el-button size="small" :loading="testing === 'openai'" @click="testConnection('openai')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.openai_api_key" type="password" show-password placeholder="sk-xxx" style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>✨</span>
<span>Google Gemini</span>
</div>
<el-button size="small" :loading="testing === 'gemini'" @click="testConnection('gemini')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.gemini_api_key" type="password" show-password placeholder="AIzaSy-xxx" style="width: 400px" />
</el-form-item>
</div>
</el-card>
<!-- 国内服务 -->
<el-card class="config-card">
<template #header>
<span>国内服务 <el-tag size="small" type="success">无需代理</el-tag></span>
</template>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🔍</span>
<span>DeepSeek</span>
<span class="provider-badge domestic">推荐</span>
</div>
<el-button size="small" :loading="testing === 'deepseek'" @click="testConnection('deepseek')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.deepseek_api_key" type="password" show-password placeholder="sk-xxx" style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🧠</span>
<span>智谱 (GLM)</span>
</div>
<el-button size="small" :loading="testing === 'zhipu'" @click="testConnection('zhipu')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.zhipu_api_key" type="password" show-password style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🎯</span>
<span>MiniMax</span>
</div>
<el-button size="small" :loading="testing === 'minimax'" @click="testConnection('minimax')">测试连接</el-button>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.minimax_api_key" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Group ID" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.minimax_group_id" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🌙</span>
<span>Moonshot (Kimi)</span>
</div>
<el-button size="small" :loading="testing === 'moonshot'" @click="testConnection('moonshot')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.moonshot_api_key" type="password" show-password style="width: 400px" />
</el-form-item>
</div>
</el-card>
<div style="margin-top: 20px;">
<el-button type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
`
});

View File

@@ -0,0 +1,217 @@
/**
* 日志查看组件
*/
app.component('logs-page', {
setup() {
const { ref, onMounted, onUnmounted } = Vue;
const logs = ref([]);
const autoScroll = ref(true);
const levelFilter = ref('all');
const searchText = ref('');
const socket = ref(null);
const isConnected = ref(false);
// 日志级别选项
const levels = [
{ value: 'all', label: '全部' },
{ value: 'info', label: 'INFO' },
{ value: 'warn', label: 'WARN' },
{ value: 'error', label: 'ERROR' },
{ value: 'debug', label: 'DEBUG' }
];
// 过滤后的日志
const filteredLogs = Vue.computed(() => {
return logs.value.filter(log => {
if (levelFilter.value !== 'all' && log.level !== levelFilter.value) {
return false;
}
if (searchText.value && !log.message.toLowerCase().includes(searchText.value.toLowerCase())) {
return false;
}
return true;
});
});
// 连接日志 WebSocket
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
try {
socket.value = new WebSocket(wsUrl);
socket.value.onopen = () => {
isConnected.value = true;
addLog('info', '已连接到日志服务');
};
socket.value.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
addLogEntry(log);
} catch {
// 纯文本日志
addLog('info', event.data);
}
};
socket.value.onclose = () => {
isConnected.value = false;
addLog('warn', '日志连接已断开');
};
socket.value.onerror = () => {
addLog('error', '日志连接错误');
};
} catch (e) {
addLog('error', '无法连接日志服务: ' + e.message);
}
}
// 添加日志条目
function addLogEntry(log) {
logs.value.push({
id: Date.now() + Math.random(),
time: log.timestamp || new Date().toISOString(),
level: log.level || 'info',
message: log.message || log.event || JSON.stringify(log),
source: log.logger || log.source || ''
});
// 限制日志数量
if (logs.value.length > 1000) {
logs.value = logs.value.slice(-500);
}
// 自动滚动
if (autoScroll.value) {
scrollToBottom();
}
}
// 添加简单日志
function addLog(level, message) {
addLogEntry({
level,
message,
timestamp: new Date().toISOString()
});
}
// 滚动到底部
function scrollToBottom() {
Vue.nextTick(() => {
const container = document.querySelector('.logs-body');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
// 清空日志
function clearLogs() {
logs.value = [];
}
// 导出日志
function exportLogs() {
const data = filteredLogs.value.map(log =>
`[${log.time}] [${log.level.toUpperCase()}] ${log.message}`
).join('\n');
const blob = new Blob([data], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `minenasai-logs-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// 格式化时间
function formatTime(time) {
try {
return new Date(time).toLocaleString('zh-CN');
} catch {
return time;
}
}
// 加载历史日志
async function loadHistory() {
try {
const res = await fetch('/api/logs?limit=100');
const data = await res.json();
if (data.logs) {
data.logs.forEach(log => addLogEntry(log));
}
} catch (e) {
console.error('加载历史日志失败:', e);
}
}
// 生命周期
onMounted(() => {
loadHistory();
connect();
});
onUnmounted(() => {
if (socket.value) {
socket.value.close();
}
});
return {
logs,
filteredLogs,
autoScroll,
levelFilter,
searchText,
isConnected,
levels,
clearLogs,
exportLogs,
formatTime
};
},
template: `
<div class="logs-page">
<div class="logs-container">
<div class="logs-header">
<div style="display: flex; align-items: center; gap: 12px;">
<el-select v-model="levelFilter" size="small" style="width: 100px">
<el-option v-for="l in levels" :key="l.value" :value="l.value" :label="l.label" />
</el-select>
<el-input
v-model="searchText"
size="small"
placeholder="搜索日志..."
style="width: 200px"
clearable
/>
<el-tag :type="isConnected ? 'success' : 'danger'" size="small">
{{ isConnected ? '实时' : '离线' }}
</el-tag>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<el-checkbox v-model="autoScroll" size="small">自动滚动</el-checkbox>
<el-button size="small" @click="clearLogs">清空</el-button>
<el-button size="small" @click="exportLogs">导出</el-button>
</div>
</div>
<div class="logs-body">
<div v-for="log in filteredLogs" :key="log.id" class="log-entry">
<span class="log-time">{{ formatTime(log.time) }}</span>
<span class="log-level" :class="log.level">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
<el-empty v-if="filteredLogs.length === 0" description="暂无日志" />
</div>
</div>
</div>
`
});

View File

@@ -0,0 +1,176 @@
/**
* 代理配置组件
*/
app.component('proxy-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch } = Vue;
// 表单数据
const form = reactive({
enabled: false,
http: '',
https: '',
no_proxy: [],
auto_detect: true
});
// 新的排除项
const newNoProxy = ref('');
// 监听 props 变化
watch(() => props.config, (newVal) => {
if (newVal) {
Object.assign(form, {
...newVal,
no_proxy: newVal.no_proxy || []
});
}
}, { immediate: true, deep: true });
// 添加排除项
function addNoProxy() {
if (newNoProxy.value && !form.no_proxy.includes(newNoProxy.value)) {
form.no_proxy.push(newNoProxy.value);
newNoProxy.value = '';
}
}
// 删除排除项
function removeNoProxy(item) {
const idx = form.no_proxy.indexOf(item);
if (idx > -1) {
form.no_proxy.splice(idx, 1);
}
}
// 测试代理
const testing = ref(false);
async function testProxy() {
testing.value = true;
try {
const res = await fetch('/api/proxy/test', { method: 'POST' });
const data = await res.json();
if (data.success) {
ElementPlus.ElMessage.success('代理连接正常');
} else {
ElementPlus.ElMessage.error('代理连接失败: ' + data.error);
}
} catch (e) {
ElementPlus.ElMessage.error('测试失败: ' + e.message);
}
testing.value = false;
}
// 自动检测代理
async function detectProxy() {
try {
const res = await fetch('/api/proxy/detect', { method: 'POST' });
const data = await res.json();
if (data.detected) {
form.http = data.http || '';
form.https = data.https || '';
ElementPlus.ElMessage.success('检测到代理: ' + (data.http || data.https));
} else {
ElementPlus.ElMessage.warning('未检测到可用代理');
}
} catch (e) {
ElementPlus.ElMessage.error('检测失败: ' + e.message);
}
}
// 保存配置
function handleSave() {
emit('save', { ...form });
}
return {
form,
newNoProxy,
testing,
addNoProxy,
removeNoProxy,
testProxy,
detectProxy,
handleSave
};
},
template: `
<div class="proxy-config">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>代理设置</span>
<el-switch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</div>
</template>
<el-alert
title="代理用途说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
境外 LLM 服务Anthropic、OpenAI、Gemini需要通过代理访问。
国内服务DeepSeek、智谱、MiniMax、Moonshot无需代理。
</el-alert>
<el-form :model="form" label-width="140px" :disabled="!form.enabled">
<el-form-item label="HTTP 代理">
<el-input v-model="form.http" placeholder="http://127.0.0.1:7890" style="width: 400px" />
</el-form-item>
<el-form-item label="HTTPS 代理">
<el-input v-model="form.https" placeholder="http://127.0.0.1:7890" style="width: 400px" />
</el-form-item>
<el-form-item label="自动检测">
<el-switch v-model="form.auto_detect" />
<el-button style="margin-left: 16px" @click="detectProxy" :disabled="!form.enabled">
检测本地代理
</el-button>
</el-form-item>
<el-form-item label="排除列表">
<div style="margin-bottom: 8px;">
<el-tag
v-for="item in form.no_proxy"
:key="item"
closable
@close="removeNoProxy(item)"
style="margin-right: 8px; margin-bottom: 4px;"
>
{{ item }}
</el-tag>
</div>
<el-input
v-model="newNoProxy"
placeholder="localhost"
style="width: 200px"
@keyup.enter="addNoProxy"
>
<template #append>
<el-button @click="addNoProxy">添加</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
</el-card>
<el-card class="config-card" v-if="form.enabled">
<template #header>
<span>连接测试</span>
</template>
<p style="margin-bottom: 16px; color: #909399;">
点击下方按钮测试代理是否能正常访问境外服务。
</p>
<el-button type="primary" :loading="testing" @click="testProxy">
测试代理连接
</el-button>
</el-card>
<div style="margin-top: 20px;">
<el-button type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
`
});

View File

@@ -0,0 +1,247 @@
/**
* 定时任务管理组件
*/
app.component('scheduler-page', {
props: ['jobs'],
emits: ['refresh'],
setup(props, { emit }) {
const { ref, reactive } = Vue;
// 对话框状态
const dialogVisible = ref(false);
const dialogTitle = ref('新建定时任务');
const editingId = ref(null);
// 表单数据
const form = reactive({
name: '',
agent_id: 'main',
schedule: '',
task: '',
enabled: true
});
// 预定义的调度表达式
const presets = [
{ value: '@hourly', label: '每小时' },
{ value: '@daily', label: '每天0点' },
{ value: '@weekly', label: '每周一0点' },
{ value: '@monthly', label: '每月1日0点' },
{ value: '0 9 * * *', label: '每天上午9点' },
{ value: '0 18 * * *', label: '每天下午6点' },
{ value: '0 9 * * 1-5', label: '工作日上午9点' },
{ value: '*/30 * * * *', label: '每30分钟' },
{ value: '0 */2 * * *', label: '每2小时' }
];
// 打开新建对话框
function handleAdd() {
editingId.value = null;
dialogTitle.value = '新建定时任务';
Object.assign(form, {
name: '',
agent_id: 'main',
schedule: '@daily',
task: '',
enabled: true
});
dialogVisible.value = true;
}
// 打开编辑对话框
function handleEdit(job) {
editingId.value = job.id;
dialogTitle.value = '编辑定时任务';
Object.assign(form, {
name: job.name,
agent_id: job.agent_id || 'main',
schedule: job.schedule,
task: job.task,
enabled: job.enabled !== false
});
dialogVisible.value = true;
}
// 删除任务
async function handleDelete(job) {
try {
await ElementPlus.ElMessageBox.confirm(
`确定要删除任务 "${job.name}" 吗?`,
'删除确认',
{ type: 'warning' }
);
await fetch(`/api/cron-jobs/${job.id}`, { method: 'DELETE' });
ElementPlus.ElMessage.success('删除成功');
emit('refresh');
} catch (e) {
if (e !== 'cancel') {
ElementPlus.ElMessage.error('删除失败: ' + e.message);
}
}
}
// 切换启用状态
async function handleToggle(job) {
try {
await fetch(`/api/cron-jobs/${job.id}/toggle`, { method: 'POST' });
emit('refresh');
} catch (e) {
ElementPlus.ElMessage.error('操作失败: ' + e.message);
}
}
// 立即执行
async function handleRun(job) {
try {
await fetch(`/api/cron-jobs/${job.id}/run`, { method: 'POST' });
ElementPlus.ElMessage.success('任务已触发执行');
} catch (e) {
ElementPlus.ElMessage.error('执行失败: ' + e.message);
}
}
// 保存任务
async function handleSave() {
try {
const data = { ...form };
if (editingId.value) {
await fetch(`/api/cron-jobs/${editingId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
await fetch('/api/cron-jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
dialogVisible.value = false;
ElementPlus.ElMessage.success('保存成功');
emit('refresh');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 格式化时间
function formatTime(timestamp) {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString('zh-CN');
}
return {
dialogVisible,
dialogTitle,
form,
presets,
handleAdd,
handleEdit,
handleDelete,
handleToggle,
handleRun,
handleSave,
formatTime
};
},
template: `
<div class="scheduler-page">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>定时任务列表</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
定时任务使用 Cron 表达式进行调度,支持预定义表达式如 @daily、@hourly 等。
</el-alert>
<el-table :data="jobs" border stripe>
<el-table-column prop="name" label="任务名称" width="150" />
<el-table-column prop="schedule" label="调度表达式" width="140">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.schedule }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="task" label="任务内容" show-overflow-tooltip />
<el-table-column label="上次执行" width="160">
<template #default="{ row }">
{{ formatTime(row.last_run) }}
</template>
</el-table-column>
<el-table-column label="下次执行" width="160">
<template #default="{ row }">
{{ formatTime(row.next_run) }}
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-switch
:model-value="row.enabled !== false"
@change="handleToggle(row)"
size="small"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button text type="success" @click="handleRun(row)">执行</el-button>
<el-button text type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!jobs || jobs.length === 0" description="暂无定时任务" />
</el-card>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" label-width="120px">
<el-form-item label="任务名称" required>
<el-input v-model="form.name" placeholder="例如:每日数据备份" />
</el-form-item>
<el-form-item label="执行 Agent">
<el-input v-model="form.agent_id" placeholder="main" />
</el-form-item>
<el-form-item label="调度表达式" required>
<el-select v-model="form.schedule" allow-create filterable style="width: 100%">
<el-option v-for="p in presets" :key="p.value" :value="p.value" :label="p.value + ' - ' + p.label" />
</el-select>
<div style="margin-top: 4px; font-size: 12px; color: #909399;">
格式: 分 时 日 月 周,或使用 @daily, @hourly 等预定义表达式
</div>
</el-form-item>
<el-form-item label="任务内容" required>
<el-input
v-model="form.task"
type="textarea"
:rows="4"
placeholder="要执行的任务描述Agent 将根据此描述执行任务"
/>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
`
});

View File

@@ -0,0 +1,246 @@
/**
* 终端组件 - 集成 xterm.js
*/
app.component('terminal-page', {
setup() {
const { ref, onMounted, onUnmounted } = Vue;
const terminal = ref(null);
const fitAddon = ref(null);
const socket = ref(null);
const isConnected = ref(false);
const sessionId = ref('');
// 初始化终端
function initTerminal() {
if (typeof Terminal === 'undefined') {
console.error('xterm.js 未加载');
return;
}
terminal.value = new Terminal({
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace',
cursorBlink: true,
cursorStyle: 'block',
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
selection: 'rgba(122, 162, 247, 0.3)',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6'
},
scrollback: 10000
});
fitAddon.value = new FitAddon.FitAddon();
terminal.value.loadAddon(fitAddon.value);
const container = document.getElementById('webui-terminal');
if (container) {
terminal.value.open(container);
fitAddon.value.fit();
// 显示欢迎信息
showWelcome();
}
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
// 监听终端输入
terminal.value.onData(data => {
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
}
// 显示欢迎信息
function showWelcome() {
const lines = [
'\x1b[1;34m',
' __ __ _ _ _ _ ____ _ ___ ',
' | \\/ (_)_ __ ___| \\ | | / \\ / ___| / \\ |_ _|',
' | |\\/| | | \'_ \\ / _ \\ \\| | / _ \\ \\___ \\ / _ \\ | | ',
' | | | | | | | | __/ |\\ |/ ___ \\ ___) / ___ \\ | | ',
' |_| |_|_|_| |_|\\___|_| \\_/_/ \\_\\____/_/ \\_\\___|',
'\x1b[0m',
'',
'\x1b[33mWeb Terminal - 集成在 WebUI 中\x1b[0m',
'',
'点击 \x1b[1;32m连接\x1b[0m 按钮开始使用',
''
];
lines.forEach(line => terminal.value.writeln(line));
}
// 处理窗口大小变化
function handleResize() {
if (fitAddon.value) {
fitAddon.value.fit();
sendResize();
}
}
// 发送终端大小
function sendResize() {
if (socket.value && socket.value.readyState === WebSocket.OPEN && fitAddon.value) {
const dims = fitAddon.value.proposeDimensions();
if (dims) {
socket.value.send(JSON.stringify({
type: 'resize',
cols: dims.cols,
rows: dims.rows
}));
}
}
}
// 连接到终端
function connect() {
if (isConnected.value) return;
terminal.value.writeln('\x1b[33m正在连接...\x1b[0m');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/terminal`;
try {
socket.value = new WebSocket(wsUrl);
socket.value.onopen = () => {
// 发送认证(使用匿名模式)
socket.value.send(JSON.stringify({
type: 'auth',
token: 'anonymous'
}));
};
socket.value.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleMessage(msg);
};
socket.value.onclose = (event) => {
isConnected.value = false;
if (event.code !== 1000) {
terminal.value.writeln(`\x1b[31m连接已断开 (${event.code})\x1b[0m`);
}
};
socket.value.onerror = () => {
terminal.value.writeln('\x1b[31m连接错误\x1b[0m');
};
} catch (e) {
terminal.value.writeln(`\x1b[31m连接失败: ${e.message}\x1b[0m`);
}
}
// 处理消息
function handleMessage(msg) {
switch (msg.type) {
case 'auth_ok':
isConnected.value = true;
sessionId.value = msg.session_id;
terminal.value.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
sendResize();
break;
case 'auth_error':
terminal.value.writeln(`\x1b[31m认证失败: ${msg.message}\x1b[0m`);
break;
case 'output':
terminal.value.write(msg.data);
break;
case 'error':
terminal.value.writeln(`\x1b[31m错误: ${msg.message}\x1b[0m`);
break;
case 'ping':
socket.value.send(JSON.stringify({ type: 'pong' }));
break;
}
}
// 断开连接
function disconnect() {
if (socket.value) {
socket.value.close(1000, 'User disconnect');
socket.value = null;
}
isConnected.value = false;
terminal.value.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
}
// 清屏
function clear() {
if (terminal.value) {
terminal.value.clear();
}
}
// 生命周期
onMounted(() => {
// 延迟初始化,确保 DOM 已渲染
setTimeout(initTerminal, 100);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (socket.value) {
socket.value.close();
}
if (terminal.value) {
terminal.value.dispose();
}
});
return {
isConnected,
sessionId,
connect,
disconnect,
clear
};
},
template: `
<div class="terminal-page">
<div class="terminal-container">
<div class="terminal-header">
<div class="terminal-title">
<span class="terminal-status" :class="{ connected: isConnected }"></span>
<span>终端</span>
<span v-if="sessionId" style="margin-left: 8px; font-size: 12px; color: #565f89;">
{{ sessionId.slice(0, 8) }}
</span>
</div>
<div>
<el-button size="small" @click="clear">清屏</el-button>
<el-button
size="small"
:type="isConnected ? 'danger' : 'primary'"
@click="isConnected ? disconnect() : connect()"
>
{{ isConnected ? '断开' : '连接' }}
</el-button>
</div>
</div>
<div id="webui-terminal" class="terminal-body"></div>
</div>
</div>
`
});