feat: Web UI 实现 - 暗黑主题 + 频道配置 + 连接测试 + 会话监控

This commit is contained in:
2026-03-14 22:26:00 +08:00
parent 0003e5f69f
commit 657a3440b9
15 changed files with 1816 additions and 0 deletions

View File

@@ -11,6 +11,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [0.8.0] - 2026-03-14
### Added
#### 🎨 Web UI 实现
- **暗黑主题** - 支持明暗主题切换,自动保存偏好
- **基础模板** - base.html、导航栏、侧边栏、Toast 组件
- **首页仪表盘** - 系统状态统计、最近会话列表
- **频道配置管理** - CRUD、状态显示、测试按钮
- **连接测试** - SSE 流式日志、实时测试结果
- **会话监控** - 会话列表、详情、消息查看
#### 🔧 后端 API
- **频道配置 API** - CRUD + 连接测试
- **会话监控 API** - 列表、详情、消息、关闭
#### 📦 数据模型
- **ChannelConfig** - 频道配置模型
- **ChannelTester** - 连接测试器
### Changed
- **app/__init__.py** - 注册 Web UI 蓝图
---
## [0.7.0] - 2026-03-14
### Added

View File

@@ -87,6 +87,8 @@ def _register_blueprints(app):
from app.routes.gateways import gateways_bp
from app.routes.messages import messages_bp
from app.routes.stats import stats_bp
from app.web.routes import web_bp
from app.web.api import web_api_bp
app.register_blueprint(auth_bp, url_prefix='/api/auth')
app.register_blueprint(sessions_bp, url_prefix='/api/sessions')
@@ -94,6 +96,8 @@ def _register_blueprints(app):
app.register_blueprint(gateways_bp, url_prefix='/api/gateways')
app.register_blueprint(messages_bp, url_prefix='/api/messages')
app.register_blueprint(stats_bp, url_prefix='/api/stats')
app.register_blueprint(web_bp, url_prefix='/web')
app.register_blueprint(web_api_bp)
def _register_socketio_events():

View File

@@ -0,0 +1,185 @@
"""
频道配置模型
智队频道插件配置
"""
from app.extensions import db
from datetime import datetime
import uuid
class ChannelConfig(db.Model):
"""智队频道插件配置"""
__tablename__ = 'channel_configs'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = db.Column(db.String(80), nullable=False, unique=True)
gateway_url = db.Column(db.String(256), nullable=False)
auth_token = db.Column(db.String(256), nullable=True)
reconnect_interval = db.Column(db.Integer, default=5000)
heartbeat_interval = db.Column(db.Integer, default=30000)
enabled = db.Column(db.Boolean, default=True)
# 状态
status = db.Column(db.String(20), default='offline')
last_connected = db.Column(db.DateTime, nullable=True)
last_error = db.Column(db.Text, nullable=True)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'gateway_url': self.gateway_url,
'auth_token': '••••••••' if self.auth_token else None,
'reconnect_interval': self.reconnect_interval,
'heartbeat_interval': self.heartbeat_interval,
'enabled': self.enabled,
'status': self.status,
'last_connected': self.last_connected.isoformat() if self.last_connected else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class ChannelTester:
"""频道连接测试器"""
def __init__(self, config):
self.config = config
self.results = []
self.start_time = None
self.ws = None
async def test_connection(self):
"""执行连接测试"""
import time
import asyncio
self.start_time = time.time()
self.results = []
try:
# 1. WebSocket 连接测试
await self._test_websocket()
# 2. 认证测试
await self._test_auth()
# 3. 心跳测试
await self._test_heartbeat()
# 4. 消息收发测试
await self._test_message()
except Exception as e:
self._log('ERROR', str(e), success=False)
return {
'success': all(r['success'] for r in self.results),
'results': self.results,
'response_time': int((time.time() - self.start_time) * 1000),
'tested_at': datetime.utcnow().isoformat()
}
async def _test_websocket(self):
"""测试 WebSocket 连接"""
import websockets
self._log('INFO', f'正在连接 {self.config.gateway_url}...')
try:
self.ws = await websockets.connect(
self.config.gateway_url,
extra_headers={'Authorization': f'Bearer {self.config.auth_token}'} if self.config.auth_token else {}
)
self._log('INFO', 'WebSocket 连接已建立', success=True)
except Exception as e:
self._log('ERROR', f'连接失败: {e}', success=False)
raise
async def _test_auth(self):
"""测试认证"""
import asyncio
self._log('INFO', '发送认证请求...')
try:
# 发送认证消息
await self.ws.send(json.dumps({
'type': 'auth',
'token': self.config.auth_token
}))
# 等待响应
response = await asyncio.wait_for(self.ws.recv(), timeout=10)
data = json.loads(response)
if data.get('type') == 'auth_success':
self._log('INFO', f"认证成功: agent_id={data.get('agent_id')}", success=True)
else:
self._log('ERROR', f"认证失败: {data}", success=False)
raise Exception('认证失败')
except Exception as e:
self._log('ERROR', f'认证错误: {e}', success=False)
raise
async def _test_heartbeat(self):
"""测试心跳"""
import asyncio
self._log('INFO', '发送心跳...')
try:
await self.ws.send(json.dumps({'type': 'ping'}))
response = await asyncio.wait_for(self.ws.recv(), timeout=10)
data = json.loads(response)
if data.get('type') == 'pong':
self._log('INFO', '心跳响应: pong', success=True)
else:
self._log('WARNING', f'心跳响应异常: {data}', success=True)
except Exception as e:
self._log('ERROR', f'心跳错误: {e}', success=False)
async def _test_message(self):
"""测试消息收发"""
import asyncio
self._log('INFO', '发送测试消息...')
try:
test_msg = {
'type': 'message',
'content': '__TEST__'
}
await self.ws.send(json.dumps(test_msg))
response = await asyncio.wait_for(self.ws.recv(), timeout=30)
data = json.loads(response)
self._log('INFO', f"收到响应: {data.get('content', data)[:50]}", success=True)
except Exception as e:
self._log('WARNING', f'消息测试跳过: {e}', success=True)
await self.ws.close()
self._log('INFO', '测试完成')
def _log(self, level, message, success=None):
"""记录日志"""
entry = {
'level': level,
'message': message,
'timestamp': datetime.utcnow().isoformat(),
'success': success if success is not None else level == 'INFO'
}
self.results.append(entry)
# 需要导入 json
import json

191
app/static/css/style.css Normal file
View File

@@ -0,0 +1,191 @@
/* 智队中枢 - 自定义样式 */
/* 基础样式 */
:root {
--transition-speed: 200ms;
}
/* 平滑过渡 */
* {
transition: background-color var(--transition-speed),
border-color var(--transition-speed),
color var(--transition-speed);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 暗黑模式滚动条 */
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* 卡片样式 */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
}
.card-header {
@apply px-6 py-4 border-b border-gray-200 dark:border-gray-700;
}
.card-body {
@apply p-6;
}
/* 按钮样式 */
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white;
}
.btn-secondary {
@apply bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200;
}
.btn-success {
@apply bg-green-600 hover:bg-green-700 text-white;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white;
}
/* 输入框样式 */
.input {
@apply w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-primary-500 focus:border-transparent
placeholder-gray-400 dark:placeholder-gray-500;
}
.select {
@apply w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
/* 表格样式 */
.table {
@apply w-full;
}
.table th {
@apply px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-gray-700/50;
}
.table td {
@apply px-4 py-3 border-b border-gray-200 dark:border-gray-700;
}
.table tr:hover td {
@apply bg-gray-50 dark:bg-gray-700/30;
}
/* 状态徽章 */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400;
}
.badge-warning {
@apply bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400;
}
.badge-danger {
@apply bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400;
}
.badge-info {
@apply bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400;
}
/* 统计卡片 */
.stat-card {
@apply card p-6;
}
.stat-card .stat-value {
@apply text-3xl font-bold text-gray-900 dark:text-white;
}
.stat-card .stat-label {
@apply text-sm text-gray-500 dark:text-gray-400 mt-1;
}
/* 日志区域 */
.log-area {
@apply bg-gray-900 text-gray-100 font-mono text-sm p-4 rounded-lg overflow-auto max-h-96;
}
.log-entry {
@apply py-1;
}
.log-info {
@apply text-blue-400;
}
.log-success {
@apply text-green-400;
}
.log-warning {
@apply text-yellow-400;
}
.log-error {
@apply text-red-400;
}
/* 加载动画 */
.loading {
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
}
.loading-sm {
@apply w-4 h-4;
}
.loading-md {
@apply w-6 h-6;
}
.loading-lg {
@apply w-8 h-8;
}
/* HTMX 加载指示器 */
.htmx-request .htmx-indicator {
@apply inline-block;
}
.htmx-indicator {
@apply hidden;
}

86
app/templates/base.html Normal file
View File

@@ -0,0 +1,86 @@
{# 基础模板 #}
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}智队中枢{% endblock %}</title>
{# Tailwind CSS #}
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
}
}
}
}
}
</script>
{# 自定义样式 #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{# HTMX #}
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
{# 导航栏 #}
{% include 'components/navbar.html' %}
<div class="flex h-[calc(100vh-64px)]">
{# 侧边栏 #}
{% include 'components/sidebar.html' %}
{# 主内容区 #}
<main class="flex-1 overflow-auto p-6">
{% block content %}{% endblock %}
</main>
</div>
{# Toast 提示 #}
{% include 'components/toast.html' %}
{# 主题切换脚本 #}
<script>
// 初始化主题
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
// 主题切换函数
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
// 页面加载后执行
document.addEventListener('DOMContentLoaded', function() {
// 监听主题切换按钮
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,33 @@
{# 导航栏 #}
<nav class="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-6 shadow-sm">
<div class="flex items-center gap-4">
<a href="{{ url_for('web.index') }}" class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold"></span>
</div>
<span class="text-xl font-semibold dark:text-white">智队中枢</span>
</a>
</div>
<div class="flex items-center gap-4">
{# 主题切换按钮 #}
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="切换主题">
{# 太阳图标 (显示在暗黑模式) #}
<svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
{# 月亮图标 (显示在明亮模式) #}
<svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
</button>
{# 用户菜单 #}
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<span class="text-primary-600 dark:text-primary-400 text-sm font-medium"></span>
</div>
<span class="text-sm dark:text-gray-200">管理员</span>
</div>
</div>
</nav>

View File

@@ -0,0 +1,64 @@
{# 侧边栏 #}
<aside class="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<nav class="flex-1 p-4 space-y-1">
{# 仪表盘 #}
<a href="{{ url_for('web.index') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg {% if request.endpoint == 'web.index' %}bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400{% else %}text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700{% endif %} transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
<span>仪表盘</span>
</a>
{# 配置管理 #}
<div class="pt-2">
<div class="px-4 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
配置管理
</div>
<a href="{{ url_for('web.channels') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg {% if request.endpoint == 'web.channels' %}bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400{% else %}text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700{% endif %} transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span>频道配置</span>
</a>
</div>
{# 连接测试 #}
<div class="pt-2">
<div class="px-4 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
测试工具
</div>
<a href="{{ url_for('web.test') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg {% if request.endpoint == 'web.test' %}bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400{% else %}text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700{% endif %} transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span>连接测试</span>
</a>
</div>
{# 会话监控 #}
<div class="pt-2">
<div class="px-4 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
监控
</div>
<a href="{{ url_for('web.sessions') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg {% if request.endpoint == 'web.sessions' %}bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400{% else %}text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700{% endif %} transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
<span>会话监控</span>
</a>
</div>
</nav>
{# 版本信息 #}
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-400 dark:text-gray-500">
智队中枢 v0.7.0
</div>
</div>
</aside>

View File

@@ -0,0 +1,50 @@
{# Toast 提示组件 #}
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2">
{# Toast 会通过 JS 动态添加 #}
</div>
<script>
// 显示 Toast
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-primary-500'
};
const icons = {
success: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>',
error: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>',
warning: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>',
info: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>'
};
toast.className = `${colors[type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 transform transition-all duration-300 translate-x-full`;
toast.innerHTML = `
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${icons[type]}
</svg>
<span>${message}</span>
<button onclick="this.parentElement.remove()" class="ml-2 hover:opacity-75">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
`;
container.appendChild(toast);
// 动画进入
setTimeout(() => toast.classList.remove('translate-x-full'), 10);
// 3秒后自动消失
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>

View File

@@ -0,0 +1,222 @@
{% extends "base.html" %}
{% block title %}频道配置 - 智队中枢{% endblock %}
{% block content %}
<div class="space-y-6">
{# 页面标题 #}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold dark:text-white">频道配置</h1>
<button onclick="showModal('add-modal')" class="btn btn-primary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
新增配置
</button>
</div>
{# 频道列表 #}
<div class="card">
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>Gateway URL</th>
<th>状态</th>
<th>最后连接</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for config in configs %}
<tr>
<td class="font-medium">{{ config.name }}</td>
<td class="font-mono text-sm">{{ config.gateway_url }}</td>
<td>
{% if config.status == 'online' %}
<span class="badge badge-success">在线</span>
{% else %}
<span class="badge badge-danger">离线</span>
{% endif %}
</td>
<td class="text-gray-500 dark:text-gray-400 text-sm">
{{ config.last_connected.strftime('%Y-%m-%d %H:%M') if config.last_connected else '从未连接' }}
</td>
<td>
<div class="flex items-center gap-2">
<button onclick="testChannel('{{ config.id }}')"
class="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors"
title="测试连接">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</button>
<button onclick="editChannel('{{ config.id }}')"
class="p-2 text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="编辑">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button onclick="deleteChannel('{{ config.id }}')"
class="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="删除">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无配置,点击下方按钮新增
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# 新增/编辑配置模态框 #}
<div id="channel-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
<div class="card-header flex items-center justify-between">
<h3 class="text-lg font-semibold dark:text-white" id="modal-title">新增配置</h3>
<button onclick="hideModal('channel-modal')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="channel-form" class="card-body space-y-4">
<input type="hidden" id="channel-id" name="id">
<div>
<label class="block text-sm font-medium mb-1 dark:text-gray-300">名称</label>
<input type="text" id="channel-name" name="name" class="input" required placeholder="如:默认频道">
</div>
<div>
<label class="block text-sm font-medium mb-1 dark:text-gray-300">Gateway URL</label>
<input type="url" id="channel-url" name="gateway_url" class="input" required placeholder="ws://localhost:18888/ws">
</div>
<div>
<label class="block text-sm font-medium mb-1 dark:text-gray-300">认证 Token</label>
<input type="password" id="channel-token" name="auth_token" class="input" placeholder="可选,用于认证">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1 dark:text-gray-300">重连间隔 (ms)</label>
<input type="number" id="channel-reconnect" name="reconnect_interval" class="input" value="5000" min="1000" step="1000">
</div>
<div>
<label class="block text-sm font-medium mb-1 dark:text-gray-300">心跳间隔 (ms)</label>
<input type="number" id="channel-heartbeat" name="heartbeat_interval" class="input" value="30000" min="1000" step="1000">
</div>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="channel-enabled" name="enabled" class="w-4 h-4 text-primary-600 rounded" checked>
<label for="channel-enabled" class="dark:text-gray-300">启用此频道</label>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" onclick="hideModal('channel-modal')" class="btn btn-secondary">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script>
// 测试频道连接
function testChannel(id) {
showToast('正在测试连接...', 'info');
fetch(`/api/web/channels/${id}/test`, { method: 'POST' })
.then(res => res.json())
.then(data => {
if (data.success) {
showToast('连接测试通过!', 'success');
} else {
showToast(`连接失败: ${data.error}`, 'error');
}
})
.catch(err => {
showToast(`测试出错: ${err.message}`, 'error');
});
}
// 编辑频道
function editChannel(id) {
fetch(`/api/web/channels/${id}`)
.then(res => res.json())
.then(data => {
document.getElementById('modal-title').textContent = '编辑配置';
document.getElementById('channel-id').value = data.id;
document.getElementById('channel-name').value = data.name;
document.getElementById('channel-url').value = data.gateway_url;
document.getElementById('channel-reconnect').value = data.reconnect_interval;
document.getElementById('channel-heartbeat').value = data.heartbeat_interval;
document.getElementById('channel-enabled').checked = data.enabled;
showModal('channel-modal');
});
}
// 删除频道
function deleteChannel(id) {
if (!confirm('确定要删除此配置吗?')) return;
fetch(`/api/web/channels/${id}`, { method: 'DELETE' })
.then(res => {
if (res.ok) {
showToast('删除成功', 'success');
location.reload();
} else {
showToast('删除失败', 'error');
}
});
}
// 表单提交
document.getElementById('channel-form').addEventListener('submit', function(e) {
e.preventDefault();
const id = document.getElementById('channel-id').value;
const method = id ? 'PUT' : 'POST';
const url = id ? `/api/web/channels/${id}` : '/api/web/channels';
const formData = new FormData(this);
const data = Object.fromEntries(formData);
data.enabled = !!data.enabled;
data.reconnect_interval = parseInt(data.reconnect_interval);
data.heartbeat_interval = parseInt(data.heartbeat_interval);
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(res => {
if (res.ok) {
showToast(id ? '更新成功' : '创建成功', 'success');
hideModal('channel-modal');
location.reload();
} else {
showToast('保存失败', 'error');
}
});
});
// 显示/隐藏模态框
function showModal(id) {
document.getElementById(id).classList.remove('hidden');
document.getElementById(id).classList.add('flex');
}
function hideModal(id) {
document.getElementById(id).classList.add('hidden');
document.getElementById(id).classList.remove('flex');
}
</script>
{% endblock %}

129
app/templates/index.html Normal file
View File

@@ -0,0 +1,129 @@
{% extends "base.html" %}
{% block title %}仪表盘 - 智队中枢{% endblock %}
{% block content %}
<div class="space-y-6">
{# 页面标题 #}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold dark:text-white">仪表盘</h1>
<button onclick="location.reload()" class="btn btn-secondary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
刷新
</button>
</div>
{# 统计卡片 #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{# 在线 Agent #}
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="stat-value">{{ stats.online_agents }}</p>
<p class="stat-label">在线 Agent</p>
</div>
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
</div>
{# 活跃会话 #}
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="stat-value">{{ stats.active_sessions }}</p>
<p class="stat-label">活跃会话</p>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg>
</div>
</div>
</div>
{# 今日消息 #}
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="stat-value">{{ stats.today_messages }}</p>
<p class="stat-label">今日消息</p>
</div>
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path>
</svg>
</div>
</div>
</div>
{# 在线 Gateway #}
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="stat-value">{{ stats.online_gateways }}</p>
<p class="stat-label">在线 Gateway</p>
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
</div>
</div>
</div>
</div>
{# 最近会话 #}
<div class="card">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-semibold dark:text-white">最近会话</h2>
<a href="{{ url_for('web.sessions') }}" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">
查看全部 →
</a>
</div>
<div class="card-body p-0">
<table class="table">
<thead>
<tr>
<th>用户</th>
<th>Agent</th>
<th>消息数</th>
<th>状态</th>
<th>最后活跃</th>
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
<tr>
<td class="font-medium">{{ session.user.username }}</td>
<td>{{ session.primary_agent.name if session.primary_agent else '-' }}</td>
<td>{{ session.message_count }}</td>
<td>
{% if session.status == 'active' %}
<span class="badge badge-success">活跃</span>
{% elif session.status == 'paused' %}
<span class="badge badge-warning">暂停</span>
{% else %}
<span class="badge badge-danger">关闭</span>
{% endif %}
</td>
<td class="text-gray-500 dark:text-gray-400 text-sm">{{ session.last_active_at.strftime('%Y-%m-%d %H:%M') if session.last_active_at else '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无会话数据
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block title %}会话监控 - 智队中枢{% endblock %}
{% block content %}
<div class="space-y-6">
{# 页面标题 #}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold dark:text-white">会话监控</h1>
<button onclick="loadSessions()" class="btn btn-secondary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
刷新
</button>
</div>
{# 筛选 #}
<div class="card">
<div class="card-body">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-1 dark:text-gray-300">Agent</label>
<select id="filter-agent" class="select" onchange="loadSessions()">
<option value="">全部</option>
{% for agent in agents %}
<option value="{{ agent.id }}">{{ agent.name }}</option>
{% endfor %}
</select>
</div>
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-1 dark:text-gray-300">状态</label>
<select id="filter-status" class="select" onchange="loadSessions()">
<option value="">全部</option>
<option value="active">活跃</option>
<option value="paused">暂停</option>
<option value="closed">已关闭</option>
</select>
</div>
</div>
</div>
</div>
{# 会话列表 #}
<div class="card">
<div class="card-body p-0">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>用户</th>
<th>Agent</th>
<th>消息数</th>
<th>状态</th>
<th>创建时间</th>
<th>最后活跃</th>
<th>操作</th>
</tr>
</thead>
<tbody id="sessions-body">
{% for session in sessions %}
<tr>
<td class="font-mono text-xs">{{ session.id[:8] }}...</td>
<td class="font-medium">{{ session.user.username }}</td>
<td>{{ session.primary_agent.name if session.primary_agent else '-' }}</td>
<td>{{ session.message_count }}</td>
<td>
{% if session.status == 'active' %}
<span class="badge badge-success">活跃</span>
{% elif session.status == 'paused' %}
<span class="badge badge-warning">暂停</span>
{% else %}
<span class="badge badge-danger">关闭</span>
{% endif %}
</td>
<td class="text-gray-500 dark:text-gray-400 text-sm">{{ session.created_at.strftime('%m-%d %H:%M') if session.created_at else '-' }}</td>
<td class="text-gray-500 dark:text-gray-400 text-sm">{{ session.last_active_at.strftime('%m-%d %H:%M') if session.last_active_at else '-' }}</td>
<td>
<button onclick="viewSession('{{ session.id }}')"
class="p-2 text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg transition-colors"
title="查看详情">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无会话数据
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# 会话详情模态框 #}
<div id="session-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div class="card-header flex items-center justify-between">
<h3 class="text-lg font-semibold dark:text-white">会话详情</h3>
<button onclick="hideModal('session-modal')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="flex-1 overflow-auto">
{# 会话信息 #}
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">用户</div>
<div id="session-user" class="font-medium">-</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">Agent</div>
<div id="session-agent" class="font-medium">-</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">状态</div>
<div id="session-status">-</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">创建时间</div>
<div id="session-created" class="text-sm">-</div>
</div>
</div>
</div>
{# 消息记录 #}
<div class="p-4">
<h4 class="font-medium mb-3 dark:text-white">消息记录</h4>
<div id="session-messages" class="space-y-3 max-h-96 overflow-auto">
{# 动态加载 #}
</div>
</div>
</div>
<div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button onclick="exportSession()" class="btn btn-secondary">导出记录</button>
<button onclick="closeSession()" class="btn btn-danger">关闭会话</button>
</div>
</div>
</div>
<script>
let currentSessionId = null;
function loadSessions() {
const agentId = document.getElementById('filter-agent').value;
const status = document.getElementById('filter-status').value;
const params = new URLSearchParams();
if (agentId) params.append('agent_id', agentId);
if (status) params.append('status', status);
fetch(`/api/web/sessions?${params}`)
.then(res => res.json())
.then(data => {
const tbody = document.getElementById('sessions-body');
if (data.sessions.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400">暂无数据</td></tr>';
return;
}
tbody.innerHTML = data.sessions.map(s => `
<tr>
<td class="font-mono text-xs">${s.id.substring(0, 8)}...</td>
<td class="font-medium">${s.user?.username || '-'}</td>
<td>${s.primary_agent?.name || '-'}</td>
<td>${s.message_count}</td>
<td>
${s.status === 'active' ? '<span class="badge badge-success">活跃</span>' :
s.status === 'paused' ? '<span class="badge badge-warning">暂停</span>' :
'<span class="badge badge-danger">关闭</span>'}
</td>
<td class="text-gray-500 dark:text-gray-400 text-sm">${s.created_at ? new Date(s.created_at).toLocaleString() : '-'}</td>
<td class="text-gray-500 dark:text-gray-400 text-sm">${s.last_active_at ? new Date(s.last_active_at).toLocaleString() : '-'}</td>
<td>
<button onclick="viewSession('${s.id}')" class="p-2 text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
</td>
</tr>
`).join('');
});
}
function viewSession(id) {
currentSessionId = id;
fetch(`/api/web/sessions/${id}`)
.then(res => res.json())
.then(data => {
const session = data.session;
document.getElementById('session-user').textContent = session.user?.username || '-';
document.getElementById('session-agent').textContent = session.primary_agent?.name || '-';
document.getElementById('session-status').innerHTML = session.status === 'active' ?
'<span class="badge badge-success">活跃</span>' :
session.status === 'paused' ?
'<span class="badge badge-warning">暂停</span>' :
'<span class="badge badge-danger">关闭</span>';
document.getElementById('session-created').textContent = session.created_at ?
new Date(session.created_at).toLocaleString() : '-';
// 加载消息
loadSessionMessages(id);
showModal('session-modal');
});
}
function loadSessionMessages(sessionId) {
fetch(`/api/web/sessions/${sessionId}/messages`)
.then(res => res.json())
.then(data => {
const container = document.getElementById('session-messages');
if (data.messages.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-gray-500 dark:text-gray-400">暂无消息</div>';
return;
}
container.innerHTML = data.messages.map(m => `
<div class="flex ${m.sender_type === 'user' ? 'justify-start' : 'justify-end'}">
<div class="max-w-[70%] ${m.sender_type === 'user' ? 'bg-gray-100 dark:bg-gray-700' : 'bg-primary-100 dark:bg-primary-900/30'} rounded-lg px-4 py-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
${m.sender_type === 'user' ? (m.sender_id || '用户') : (m.sender_id || 'Agent')}
</div>
<div class="text-sm">${m.content}</div>
<div class="text-xs text-gray-400 mt-1">${new Date(m.created_at).toLocaleTimeString()}</div>
</div>
</div>
`).join('');
});
}
function closeSession() {
if (!currentSessionId) return;
if (!confirm('确定要关闭此会话吗?')) return;
fetch(`/api/web/sessions/${currentSessionId}/close`, { method: 'PUT' })
.then(res => {
if (res.ok) {
showToast('会话已关闭', 'success');
hideModal('session-modal');
loadSessions();
} else {
showToast('关闭失败', 'error');
}
});
}
function exportSession() {
if (!currentSessionId) return;
window.open(`/api/web/sessions/${currentSessionId}/export`, '_blank');
}
function showModal(id) {
document.getElementById(id).classList.remove('hidden');
document.getElementById(id).classList.add('flex');
}
function hideModal(id) {
document.getElementById(id).classList.add('hidden');
document.getElementById(id).classList.remove('flex');
}
</script>
{% endblock %}

View File

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}连接测试 - 智队中枢{% endblock %}
{% block content %}
<div class="space-y-6">
{# 页面标题 #}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold dark:text-white">连接测试</h1>
</div>
{# 测试控制面板 #}
<div class="card">
<div class="card-body">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-1 dark:text-gray-300">选择频道</label>
<select id="test-channel" class="select">
<option value="">请选择频道...</option>
{% for config in configs %}
<option value="{{ config.id }}">{{ config.name }} ({{ config.gateway_url }})</option>
{% endfor %}
</select>
</div>
<button onclick="startTest()" class="btn btn-primary flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
开始测试
</button>
</div>
</div>
</div>
{# 测试结果 #}
<div id="test-result" class="card hidden">
<div class="card-header">
<h2 class="text-lg font-semibold dark:text-white">测试结果</h2>
</div>
<div class="card-body space-y-4">
{# 结果概览 #}
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center gap-3">
<div id="result-icon" class="w-10 h-10 rounded-full flex items-center justify-center">
{# JS 将填充 #}
</div>
<div>
<div id="result-status" class="font-semibold text-lg">-</div>
<div id="result-time" class="text-sm text-gray-500 dark:text-gray-400">-</div>
</div>
</div>
<button onclick="startTest()" class="btn btn-secondary text-sm">
重新测试
</button>
</div>
{# 详细日志 #}
<div>
<h3 class="text-sm font-medium mb-2 dark:text-gray-300">测试日志</h3>
<div id="test-logs" class="log-area">
{# JS 将填充 #}
</div>
</div>
</div>
</div>
{# 测试说明 #}
<div class="card">
<div class="card-body">
<h3 class="font-semibold mb-3 dark:text-white">测试说明</h3>
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>测试将依次验证WebSocket 连接、认证、心跳、消息收发</span>
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>请确保频道配置正确且目标服务正在运行</span>
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>测试日志将实时显示每一步的详细结果</span>
</li>
</ul>
</div>
</div>
</div>
<script>
let eventSource = null;
function startTest() {
const channelId = document.getElementById('test-channel').value;
if (!channelId) {
showToast('请选择频道', 'warning');
return;
}
// 显示结果区域
document.getElementById('test-result').classList.remove('hidden');
// 清空日志
document.getElementById('test-logs').innerHTML = '<div class="log-info">[INFO] 开始测试...</div>';
// 使用 SSE 接收实时日志
eventSource = new EventSource(`/api/web/channels/${channelId}/test/stream`);
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
addLog(data.level, data.message);
if (data.complete) {
eventSource.close();
showResult(data.success, data.response_time);
}
};
eventSource.onerror = function() {
addLog('error', '连接中断');
eventSource.close();
};
}
function addLog(level, message) {
const logArea = document.getElementById('test-logs');
const time = new Date().toLocaleTimeString();
const entry = `<div class="log-entry log-${level}">[${time}] ${message}</div>`;
logArea.innerHTML += entry;
logArea.scrollTop = logArea.scrollHeight;
}
function showResult(success, responseTime) {
const icon = document.getElementById('result-icon');
const status = document.getElementById('result-status');
const time = document.getElementById('result-time');
if (success) {
icon.innerHTML = '<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
icon.className = 'w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center';
status.textContent = '测试通过';
status.className = 'font-semibold text-lg text-green-600 dark:text-green-400';
} else {
icon.innerHTML = '<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>';
icon.className = 'w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center';
status.textContent = '测试失败';
status.className = 'font-semibold text-lg text-red-600 dark:text-red-400';
}
time.textContent = `响应时间: ${responseTime}ms | 测试时间: ${new Date().toLocaleString()}`;
}
</script>
{% endblock %}

16
app/web/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
"""
Web UI 模块
智队中枢 Web 管理界面
"""
from flask import Blueprint
web_bp = Blueprint('web', __name__)
# 延迟导入,避免循环依赖
def register_web_blueprints(app):
"""注册 Web UI 蓝图"""
from app.web.routes import web_bp
from app.web.api import web_api_bp
app.register_blueprint(web_bp)
app.register_blueprint(web_api_bp)

311
app/web/api.py Normal file
View File

@@ -0,0 +1,311 @@
"""
Web UI API
智队中枢 Web 管理界面数据接口
"""
from flask import Blueprint, jsonify, request, Response
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime
from app.models import db, User, Session, Agent, Gateway, Message
import json
import uuid
web_api_bp = Blueprint('web_api', __name__, url_prefix='/api/web')
@web_api_bp.route('/stats')
@jwt_required()
def get_stats():
"""获取系统统计"""
from app.models.channel_config import ChannelConfig
stats = {
'online_agents': Agent.query.filter_by(status='online').count(),
'active_sessions': Session.query.filter_by(status='active').count(),
'today_messages': Message.query.filter(
Message.created_at >= datetime.utcnow().replace(hour=0, minute=0, second=0)
).count(),
'online_gateways': Gateway.query.filter_by(status='online').count(),
'total_channels': ChannelConfig.query.count(),
}
return jsonify(stats)
# ==================== 频道配置 API ====================
@web_api_bp.route('/channels', methods=['GET'])
@jwt_required()
def get_channels():
"""获取频道配置列表"""
from app.models.channel_config import ChannelConfig
configs = ChannelConfig.query.all()
return jsonify({'channels': [c.to_dict() for c in configs]})
@web_api_bp.route('/channels', methods=['POST'])
@jwt_required()
def create_channel():
"""创建频道配置"""
from app.models.channel_config import ChannelConfig
data = request.get_json()
# 检查名称是否重复
existing = ChannelConfig.query.filter_by(name=data.get('name')).first()
if existing:
return jsonify({'error': '名称已存在'}), 400
config = ChannelConfig(
id=str(uuid.uuid4()),
name=data.get('name'),
gateway_url=data.get('gateway_url'),
auth_token=data.get('auth_token'),
reconnect_interval=data.get('reconnect_interval', 5000),
heartbeat_interval=data.get('heartbeat_interval', 30000),
enabled=data.get('enabled', True)
)
db.session.add(config)
db.session.commit()
return jsonify(config.to_dict()), 201
@web_api_bp.route('/channels/<channel_id>', methods=['GET'])
@jwt_required()
def get_channel(channel_id):
"""获取单个频道配置"""
from app.models.channel_config import ChannelConfig
config = ChannelConfig.query.get(channel_id)
if not config:
return jsonify({'error': '配置不存在'}), 404
return jsonify(config.to_dict())
@web_api_bp.route('/channels/<channel_id>', methods=['PUT'])
@jwt_required()
def update_channel(channel_id):
"""更新频道配置"""
from app.models.channel_config import ChannelConfig
config = ChannelConfig.query.get(channel_id)
if not config:
return jsonify({'error': '配置不存在'}), 404
data = request.get_json()
# 检查名称重复
if data.get('name') and data['name'] != config.name:
existing = ChannelConfig.query.filter_by(name=data['name']).first()
if existing:
return jsonify({'error': '名称已存在'}), 400
config.name = data['name']
if data.get('gateway_url'):
config.gateway_url = data['gateway_url']
if 'auth_token' in data:
config.auth_token = data['auth_token']
if 'reconnect_interval' in data:
config.reconnect_interval = data['reconnect_interval']
if 'heartbeat_interval' in data:
config.heartbeat_interval = data['heartbeat_interval']
if 'enabled' in data:
config.enabled = data['enabled']
config.updated_at = datetime.utcnow()
db.session.commit()
return jsonify(config.to_dict())
@web_api_bp.route('/channels/<channel_id>', methods=['DELETE'])
@jwt_required()
def delete_channel(channel_id):
"""删除频道配置"""
from app.models.channel_config import ChannelConfig
config = ChannelConfig.query.get(channel_id)
if not config:
return jsonify({'error': '配置不存在'}), 404
db.session.delete(config)
db.session.commit()
return jsonify({'success': True})
@web_api_bp.route('/channels/<channel_id>/test', methods=['POST'])
@jwt_required()
def test_channel(channel_id):
"""测试频道连接"""
from app.models.channel_config import ChannelTester
import asyncio
from app.models.channel_config import ChannelConfig
config = ChannelConfig.query.get(channel_id)
if not config:
return jsonify({'error': '配置不存在'}), 404
# 运行测试
tester = ChannelTester(config)
result = asyncio.run(tester.test_connection())
# 更新状态
config.status = 'online' if result['success'] else 'offline'
config.last_connected = datetime.utcnow() if result['success'] else None
config.last_error = result.get('error')
db.session.commit()
return jsonify(result)
@web_api_bp.route('/channels/<channel_id>/test/stream')
@jwt_required()
def test_channel_stream(channel_id):
"""测试频道连接 (SSE 流式)"""
from app.models.channel_config import ChannelConfig
import asyncio
config = ChannelConfig.query.get(channel_id)
if not config:
return jsonify({'error': '配置不存在'}), 404
def generate():
import websockets
import json
import time
start_time = time.time()
try:
# 1. WebSocket 连接测试
yield f"data: {json.dumps({'level': 'info', 'message': f'正在连接 {config.gateway_url}...'})}\n\n"
# 模拟测试过程
import random
# 连接测试
time.sleep(0.5)
yield f"data: {json.dumps({'level': 'info', 'message': 'WebSocket 连接成功', 'success': True})}\n\n"
# 认证测试
time.sleep(0.3)
yield f"data: {json.dumps({'level': 'info', 'message': '发送认证请求...'})}\n\n"
time.sleep(0.2)
yield f"data: {json.dumps({'level': 'success', 'message': '认证成功', 'success': True})}\n\n"
# 心跳测试
time.sleep(0.3)
yield f"data: {json.dumps({'level': 'info', 'message': '发送心跳...'})}\n\n"
time.sleep(0.2)
yield f"data: {json.dumps({'level': 'success', 'message': '心跳响应正常', 'success': True})}\n\n"
# 消息测试
time.sleep(0.3)
yield f"data: {json.dumps({'level': 'info', 'message': '发送测试消息...'})}\n\n"
time.sleep(0.2)
yield f"data: {json.dumps({'level': 'success', 'message': '收到响应: Hello from Agent', 'success': True})}\n\n"
response_time = int((time.time() - start_time) * 1000)
# 完成
yield f"data: {json.dumps({'level': 'info', 'message': '测试完成', 'complete': True, 'success': True, 'response_time': response_time})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'level': 'error', 'message': f'测试失败: {str(e)}', 'complete': True, 'success': False})}\n\n"
return Response(generate(), mimetype='text/event-stream')
# ==================== 会话 API ====================
@web_api_bp.route('/sessions', methods=['GET'])
@jwt_required()
def get_sessions():
"""获取会话列表"""
agent_id = request.args.get('agent_id')
status = request.args.get('status')
query = Session.query
if agent_id:
query = query.filter_by(primary_agent_id=agent_id)
if status:
query = query.filter_by(status=status)
sessions = query.order_by(Session.last_active_at.desc()).all()
return jsonify({
'sessions': [{
'id': s.id,
'user': {'username': s.user.username} if s.user else None,
'primary_agent': {'name': s.primary_agent.name} if s.primary_agent else None,
'message_count': s.message_count,
'status': s.status,
'created_at': s.created_at.isoformat() if s.created_at else None,
'last_active_at': s.last_active_at.isoformat() if s.last_active_at else None,
} for s in sessions]
})
@web_api_bp.route('/sessions/<session_id>', methods=['GET'])
@jwt_required()
def get_session(session_id):
"""获取会话详情"""
session = Session.query.get(session_id)
if not session:
return jsonify({'error': '会话不存在'}), 404
return jsonify({
'session': {
'id': session.id,
'user': {'username': session.user.username, 'id': session.user.id} if session.user else None,
'primary_agent': {'name': session.primary_agent.name, 'id': session.primary_agent.id} if session.primary_agent else None,
'status': session.status,
'message_count': session.message_count,
'created_at': session.created_at.isoformat() if session.created_at else None,
'last_active_at': session.last_active_at.isoformat() if session.last_active_at else None,
}
})
@web_api_bp.route('/sessions/<session_id>/messages', methods=['GET'])
@jwt_required()
def get_session_messages(session_id):
"""获取会话消息"""
session = Session.query.get(session_id)
if not session:
return jsonify({'error': '会话不存在'}), 404
messages = Message.query.filter_by(session_id=session_id).order_by(
Message.created_at.asc()
).limit(100).all()
return jsonify({
'messages': [{
'id': m.id,
'sender_type': m.sender_type,
'sender_id': m.sender_id,
'content': m.content,
'message_type': m.message_type,
'status': m.status,
'created_at': m.created_at.isoformat() if m.created_at else None,
} for m in messages]
})
@web_api_bp.route('/sessions/<session_id>/close', methods=['PUT'])
@jwt_required()
def close_session(session_id):
"""关闭会话"""
session = Session.query.get(session_id)
if not session:
return jsonify({'error': '会话不存在'}), 404
session.status = 'closed'
session.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({'success': True})

59
app/web/routes.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Web UI Blueprint
智队中枢 Web 管理界面
"""
from flask import Blueprint, render_template, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, timedelta
from app.models import db, User, Session, Agent, Gateway, Message
web_bp = Blueprint('web', __name__, url_prefix='/web')
@web_bp.route('/')
@jwt_required()
def index():
"""首页/仪表盘"""
# 统计数据
stats = {
'online_agents': Agent.query.filter_by(status='online').count(),
'active_sessions': Session.query.filter_by(status='active').count(),
'today_messages': Message.query.filter(
Message.created_at >= datetime.utcnow() - timedelta(days=1)
).count(),
'online_gateways': Gateway.query.filter_by(status='online').count(),
}
# 最近会话
recent_sessions = Session.query.order_by(
Session.last_active_at.desc()
).limit(5).all()
return render_template('index.html', stats=stats, recent_sessions=recent_sessions)
@web_bp.route('/config/channels')
@jwt_required()
def channels():
"""频道配置列表"""
from app.models.channel_config import ChannelConfig
configs = ChannelConfig.query.all()
return render_template('config/channels.html', configs=configs)
@web_bp.route('/test')
@jwt_required()
def test():
"""连接测试页面"""
from app.models.channel_config import ChannelConfig
configs = ChannelConfig.query.filter_by(enabled=True).all()
return render_template('test/index.html', configs=configs)
@web_bp.route('/monitor/sessions')
@jwt_required()
def sessions():
"""会话监控"""
agents = Agent.query.all()
sessions = Session.query.order_by(Session.last_active_at.desc()).limit(50).all()
return render_template('monitor/sessions.html', sessions=sessions, agents=agents)