feat: Web UI 实现 - 暗黑主题 + 频道配置 + 连接测试 + 会话监控
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
185
app/models/channel_config.py
Normal file
185
app/models/channel_config.py
Normal 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
191
app/static/css/style.css
Normal 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
86
app/templates/base.html
Normal 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>
|
||||
33
app/templates/components/navbar.html
Normal file
33
app/templates/components/navbar.html
Normal 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>
|
||||
64
app/templates/components/sidebar.html
Normal file
64
app/templates/components/sidebar.html
Normal 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>
|
||||
50
app/templates/components/toast.html
Normal file
50
app/templates/components/toast.html
Normal 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>
|
||||
222
app/templates/config/channels.html
Normal file
222
app/templates/config/channels.html
Normal 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
129
app/templates/index.html
Normal 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 %}
|
||||
278
app/templates/monitor/sessions.html
Normal file
278
app/templates/monitor/sessions.html
Normal 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 %}
|
||||
159
app/templates/test/index.html
Normal file
159
app/templates/test/index.html
Normal 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
16
app/web/__init__.py
Normal 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
311
app/web/api.py
Normal 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
59
app/web/routes.py
Normal 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)
|
||||
Reference in New Issue
Block a user