From 657a3440b9e0199fc77475d5b789dd02f1f2784b Mon Sep 17 00:00:00 2001
From: "feifei.xu" <307327147@qq.com>
Date: Sat, 14 Mar 2026 22:26:00 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20Web=20UI=20=E5=AE=9E=E7=8E=B0=20-=20?=
=?UTF-8?q?=E6=9A=97=E9=BB=91=E4=B8=BB=E9=A2=98=20+=20=E9=A2=91=E9=81=93?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=20+=20=E4=BC=9A=E8=AF=9D=E7=9B=91=E6=8E=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 29 +++
app/__init__.py | 4 +
app/models/channel_config.py | 185 +++++++++++++++
app/static/css/style.css | 191 ++++++++++++++++
app/templates/base.html | 86 +++++++
app/templates/components/navbar.html | 33 +++
app/templates/components/sidebar.html | 64 ++++++
app/templates/components/toast.html | 50 +++++
app/templates/config/channels.html | 222 ++++++++++++++++++
app/templates/index.html | 129 +++++++++++
app/templates/monitor/sessions.html | 278 +++++++++++++++++++++++
app/templates/test/index.html | 159 +++++++++++++
app/web/__init__.py | 16 ++
app/web/api.py | 311 ++++++++++++++++++++++++++
app/web/routes.py | 59 +++++
15 files changed, 1816 insertions(+)
create mode 100644 app/models/channel_config.py
create mode 100644 app/static/css/style.css
create mode 100644 app/templates/base.html
create mode 100644 app/templates/components/navbar.html
create mode 100644 app/templates/components/sidebar.html
create mode 100644 app/templates/components/toast.html
create mode 100644 app/templates/config/channels.html
create mode 100644 app/templates/index.html
create mode 100644 app/templates/monitor/sessions.html
create mode 100644 app/templates/test/index.html
create mode 100644 app/web/__init__.py
create mode 100644 app/web/api.py
create mode 100644 app/web/routes.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f685a6..7b15d28 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/app/__init__.py b/app/__init__.py
index 0317e5b..aa0d61e 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -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():
diff --git a/app/models/channel_config.py b/app/models/channel_config.py
new file mode 100644
index 0000000..bc952c1
--- /dev/null
+++ b/app/models/channel_config.py
@@ -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
diff --git a/app/static/css/style.css b/app/static/css/style.css
new file mode 100644
index 0000000..f562868
--- /dev/null
+++ b/app/static/css/style.css
@@ -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;
+}
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..0e55c37
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,86 @@
+{# 基础模板 #}
+
+
+
+
+
+ {% block title %}智队中枢{% endblock %}
+
+ {# Tailwind CSS #}
+
+
+
+ {# 自定义样式 #}
+
+
+ {# HTMX #}
+
+
+ {% block extra_head %}{% endblock %}
+
+
+
+ {# 导航栏 #}
+ {% include 'components/navbar.html' %}
+
+
+ {# 侧边栏 #}
+ {% include 'components/sidebar.html' %}
+
+ {# 主内容区 #}
+
+ {% block content %}{% endblock %}
+
+
+
+ {# Toast 提示 #}
+ {% include 'components/toast.html' %}
+
+ {# 主题切换脚本 #}
+
+
+ {% block scripts %}{% endblock %}
+
+
diff --git a/app/templates/components/navbar.html b/app/templates/components/navbar.html
new file mode 100644
index 0000000..28f320e
--- /dev/null
+++ b/app/templates/components/navbar.html
@@ -0,0 +1,33 @@
+{# 导航栏 #}
+
diff --git a/app/templates/components/sidebar.html b/app/templates/components/sidebar.html
new file mode 100644
index 0000000..59c2b6c
--- /dev/null
+++ b/app/templates/components/sidebar.html
@@ -0,0 +1,64 @@
+{# 侧边栏 #}
+
diff --git a/app/templates/components/toast.html b/app/templates/components/toast.html
new file mode 100644
index 0000000..72b544c
--- /dev/null
+++ b/app/templates/components/toast.html
@@ -0,0 +1,50 @@
+{# Toast 提示组件 #}
+
+ {# Toast 会通过 JS 动态添加 #}
+
+
+
diff --git a/app/templates/config/channels.html b/app/templates/config/channels.html
new file mode 100644
index 0000000..403de45
--- /dev/null
+++ b/app/templates/config/channels.html
@@ -0,0 +1,222 @@
+{% extends "base.html" %}
+
+{% block title %}频道配置 - 智队中枢{% endblock %}
+
+{% block content %}
+
+ {# 页面标题 #}
+
+
频道配置
+
+
+
+ {# 频道列表 #}
+
+
+
+
+
+ | 名称 |
+ Gateway URL |
+ 状态 |
+ 最后连接 |
+ 操作 |
+
+
+
+ {% for config in configs %}
+
+ | {{ config.name }} |
+ {{ config.gateway_url }} |
+
+ {% if config.status == 'online' %}
+ 在线
+ {% else %}
+ 离线
+ {% endif %}
+ |
+
+ {{ config.last_connected.strftime('%Y-%m-%d %H:%M') if config.last_connected else '从未连接' }}
+ |
+
+
+
+
+
+
+ |
+
+ {% else %}
+
+ |
+ 暂无配置,点击下方按钮新增
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+{# 新增/编辑配置模态框 #}
+
+
+
+{% endblock %}
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..ae72200
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,129 @@
+{% extends "base.html" %}
+
+{% block title %}仪表盘 - 智队中枢{% endblock %}
+
+{% block content %}
+
+ {# 页面标题 #}
+
+
+ {# 统计卡片 #}
+
+ {# 在线 Agent #}
+
+
+
+
{{ stats.online_agents }}
+
在线 Agent
+
+
+
+
+
+ {# 活跃会话 #}
+
+
+
+
{{ stats.active_sessions }}
+
活跃会话
+
+
+
+
+
+ {# 今日消息 #}
+
+
+
+
{{ stats.today_messages }}
+
今日消息
+
+
+
+
+
+ {# 在线 Gateway #}
+
+
+
+
{{ stats.online_gateways }}
+
在线 Gateway
+
+
+
+
+
+
+ {# 最近会话 #}
+
+
+
+
+
+
+ | 用户 |
+ Agent |
+ 消息数 |
+ 状态 |
+ 最后活跃 |
+
+
+
+ {% for session in recent_sessions %}
+
+ | {{ session.user.username }} |
+ {{ session.primary_agent.name if session.primary_agent else '-' }} |
+ {{ session.message_count }} |
+
+ {% if session.status == 'active' %}
+ 活跃
+ {% elif session.status == 'paused' %}
+ 暂停
+ {% else %}
+ 关闭
+ {% endif %}
+ |
+ {{ session.last_active_at.strftime('%Y-%m-%d %H:%M') if session.last_active_at else '-' }} |
+
+ {% else %}
+
+ |
+ 暂无会话数据
+ |
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/monitor/sessions.html b/app/templates/monitor/sessions.html
new file mode 100644
index 0000000..b4f2fcc
--- /dev/null
+++ b/app/templates/monitor/sessions.html
@@ -0,0 +1,278 @@
+{% extends "base.html" %}
+
+{% block title %}会话监控 - 智队中枢{% endblock %}
+
+{% block content %}
+
+ {# 页面标题 #}
+
+
+ {# 筛选 #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# 会话列表 #}
+
+
+
+
+
+ | ID |
+ 用户 |
+ Agent |
+ 消息数 |
+ 状态 |
+ 创建时间 |
+ 最后活跃 |
+ 操作 |
+
+
+
+ {% for session in sessions %}
+
+ | {{ session.id[:8] }}... |
+ {{ session.user.username }} |
+ {{ session.primary_agent.name if session.primary_agent else '-' }} |
+ {{ session.message_count }} |
+
+ {% if session.status == 'active' %}
+ 活跃
+ {% elif session.status == 'paused' %}
+ 暂停
+ {% else %}
+ 关闭
+ {% endif %}
+ |
+ {{ session.created_at.strftime('%m-%d %H:%M') if session.created_at else '-' }} |
+ {{ session.last_active_at.strftime('%m-%d %H:%M') if session.last_active_at else '-' }} |
+
+
+ |
+
+ {% else %}
+
+ |
+ 暂无会话数据
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+{# 会话详情模态框 #}
+
+
+
+
+ {# 会话信息 #}
+
+
+ {# 消息记录 #}
+
+
消息记录
+
+ {# 动态加载 #}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/test/index.html b/app/templates/test/index.html
new file mode 100644
index 0000000..09bf6c9
--- /dev/null
+++ b/app/templates/test/index.html
@@ -0,0 +1,159 @@
+{% extends "base.html" %}
+
+{% block title %}连接测试 - 智队中枢{% endblock %}
+
+{% block content %}
+
+ {# 页面标题 #}
+
+
连接测试
+
+
+ {# 测试控制面板 #}
+
+
+
+
+
+
+
+
+
+
+
+
+ {# 测试结果 #}
+
+
+
+ {# 结果概览 #}
+
+
+ {# 详细日志 #}
+
+
测试日志
+
+ {# JS 将填充 #}
+
+
+
+
+
+ {# 测试说明 #}
+
+
+
测试说明
+
+ -
+
+ 测试将依次验证:WebSocket 连接、认证、心跳、消息收发
+
+ -
+
+ 请确保频道配置正确且目标服务正在运行
+
+ -
+
+ 测试日志将实时显示每一步的详细结果
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/web/__init__.py b/app/web/__init__.py
new file mode 100644
index 0000000..6ab0a26
--- /dev/null
+++ b/app/web/__init__.py
@@ -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)
diff --git a/app/web/api.py b/app/web/api.py
new file mode 100644
index 0000000..a293eb8
--- /dev/null
+++ b/app/web/api.py
@@ -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/', 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/', 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/', 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//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//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/', 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//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//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})
diff --git a/app/web/routes.py b/app/web/routes.py
new file mode 100644
index 0000000..0f38a11
--- /dev/null
+++ b/app/web/routes.py
@@ -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)