Compare commits
2 Commits
96177eddf3
...
26737747d9
| Author | SHA1 | Date | |
|---|---|---|---|
| 26737747d9 | |||
| b4aa4c8d02 |
88
.kiro/specs/architecture-evolution/tasks.md
Normal file
88
.kiro/specs/architecture-evolution/tasks.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 架构演进任务清单
|
||||
|
||||
## 概述
|
||||
|
||||
基于两轮架构审查发现的结构性问题,按优先级排列的演进任务。每个任务独立可交付,不依赖其他任务的完成。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [-] 1. 引入 Repository 层,分离数据访问逻辑
|
||||
- [x] 1.1 创建 `src/repositories/` 目录,为核心模型创建 Repository 类
|
||||
- WorkOrderRepository: 封装工单的 CRUD + 按 tenant_id 过滤
|
||||
- KnowledgeRepository: 封装知识库的 CRUD + 按 tenant_id 过滤
|
||||
- ConversationRepository: 封装对话/会话的 CRUD + 按 tenant_id 过滤
|
||||
- AlertRepository: 封装预警的 CRUD + 按 tenant_id 过滤
|
||||
- [ ] 1.2 将 blueprint 中的直接 DB 查询迁移到 Repository
|
||||
- workorders.py 的 get_workorders、create_workorder、delete_workorder
|
||||
- knowledge.py 的 get_knowledge、add_knowledge、delete_knowledge
|
||||
- conversations.py 的所有端点
|
||||
- alerts.py 的所有端点
|
||||
- [x] 1.3 在 Repository 基类中统一添加 tenant_id 过滤
|
||||
- 所有查询方法自动附加 tenant_id 条件
|
||||
- 写操作自动设置 tenant_id
|
||||
|
||||
- [ ] 2. 统一 LLM 客户端
|
||||
- [ ] 2.1 将 `src/agent/llm_client.py` 的异步能力合并到 `src/core/llm_client.py`
|
||||
- LLMClient 同时支持同步和异步调用
|
||||
- 统一超时、重试、token 统计逻辑
|
||||
- [ ] 2.2 让 agent_assistant.py 使用统一的 LLMClient
|
||||
- 删除 `src/agent/llm_client.py` 中的 LLMManager/OpenAIClient 等重复类
|
||||
- [ ] 2.3 统一 LLM 配置入口
|
||||
- 所有 LLM 调用从 unified_config 读取配置
|
||||
|
||||
- [ ] 3. 引入 MessagePipeline 统一消息处理
|
||||
- [ ] 3.1 创建 `src/dialogue/message_pipeline.py`
|
||||
- 定义统一的消息处理流程:接收 → 租户解析 → 会话管理 → 知识搜索 → LLM 调用 → 保存 → 回复
|
||||
- 各入口(WebSocket、HTTP、飞书 bot、飞书长连接)只负责协议适配
|
||||
- [ ] 3.2 重构 realtime_chat.py 使用 Pipeline
|
||||
- process_message 和 process_message_stream 委托给 Pipeline
|
||||
- [ ] 3.3 重构飞书 bot/longconn 使用 Pipeline
|
||||
- 消除 feishu_bot.py 和 feishu_longconn_service.py 中的重复逻辑
|
||||
|
||||
- [ ] 4. 引入 Alembic 数据库迁移
|
||||
- [ ] 4.1 初始化 Alembic 配置
|
||||
- alembic init, 配置 env.py 连接 unified_config
|
||||
- [ ] 4.2 生成初始迁移脚本
|
||||
- 从当前 models.py 生成 baseline migration
|
||||
- [ ] 4.3 移除 database.py 中的 _run_migrations 手动迁移逻辑
|
||||
- 改为启动时运行 alembic upgrade head
|
||||
|
||||
- [ ] 5. 统一配置管理
|
||||
- [ ] 5.1 定义配置优先级:环境变量 > system_settings.json > 代码默认值
|
||||
- [ ] 5.2 创建 ConfigService 统一读写接口
|
||||
- get(key, default) / set(key, value) / get_section(section)
|
||||
- 底层自动合并三个来源
|
||||
- [ ] 5.3 迁移 SystemOptimizer、PerformanceConfig 使用 ConfigService
|
||||
|
||||
- [ ] 6. API 契约定义
|
||||
- [ ] 6.1 引入 Flask-RESTX 或 apispec 生成 OpenAPI 文档
|
||||
- [ ] 6.2 为所有 blueprint 端点添加 schema 定义
|
||||
- [ ] 6.3 统一所有端点使用 api_response() 标准格式
|
||||
|
||||
- [ ] 7. 会话状态迁移到 Redis
|
||||
- [ ] 7.1 将 RealtimeChatManager.active_sessions 迁移到 Redis Hash
|
||||
- [ ] 7.2 将消息去重从内存缓存迁移到 Redis SET(支持多进程)
|
||||
- [ ] 7.3 支持多实例部署(无状态 Flask + 共享 Redis)
|
||||
|
||||
- [ ] 8. 密码哈希升级
|
||||
- [ ] 8.1 将 SHA-256 替换为 bcrypt(pip install bcrypt)
|
||||
- [ ] 8.2 兼容旧密码:登录时检测旧格式,自动升级为 bcrypt
|
||||
|
||||
- [ ] 9. 前端状态管理优化
|
||||
- [ ] 9.1 引入简易事件总线(EventEmitter 模式)
|
||||
- 模块间通过事件通信,不直接读写共享状态
|
||||
- [ ] 9.2 将 this.xxxCurrentTenantId 等状态封装为 Store 对象
|
||||
|
||||
- [x] 10. 清理旧代码
|
||||
- [x] 10.1 删除 src/web/static/js/core/ 目录(旧的未完成重构)
|
||||
- [x] 10.2 删除 src/web/static/js/services/ 目录
|
||||
- [x] 10.3 删除 src/web/static/js/components/ 目录
|
||||
- [x] 10.4 删除 src/web/static/js/pages/ 目录
|
||||
- [x] 10.5 清理 index.html、chat.html、chat_http.html 中对已删除 JS 的引用
|
||||
|
||||
## Notes
|
||||
|
||||
- 每个任务独立可交付,按 1 → 2 → 3 的顺序做收益最大
|
||||
- 任务 4(Alembic)可以随时做,不依赖其他任务
|
||||
- 任务 7(Redis 会话)只在需要多实例部署时才有必要
|
||||
- 任务 8(密码升级)安全性高但影响面小,可以穿插做
|
||||
550
README.md
550
README.md
@@ -1,521 +1,61 @@
|
||||
# TSP智能助手 (TSP Assistant)
|
||||
# TSP 智能助手
|
||||
|
||||
[](version.json)
|
||||
[](requirements.txt)
|
||||
[](Dockerfile)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
AI 驱动的多租户客服与工单管理系统,支持飞书机器人、WebSocket 实时对话、知识库语义搜索。
|
||||
|
||||
> 基于大语言模型的智能客服系统,专为TSP(Telematics Service Provider)车辆服务提供商设计
|
||||
## 功能概览
|
||||
|
||||
## 🚀 项目特色
|
||||
- **智能对话** — WebSocket 实时聊天 + 飞书机器人(长连接模式),按租户隔离知识库
|
||||
- **工单管理** — 创建、编辑、删除、飞书多维表格双向同步,AI 生成处理建议
|
||||
- **知识库** — TF-IDF + 可选 Embedding 语义搜索,支持文件导入、人工验证
|
||||
- **多租户** — 数据按 tenant_id 隔离,每个租户独立的系统提示词和飞书群绑定
|
||||
- **数据分析** — 工单趋势、预警统计、满意度分析,支持按租户筛选
|
||||
- **预警系统** — 自定义规则、多级别预警、批量管理
|
||||
- **系统管理** — 模块权限控制、流量/成本/安全配置、Token 监控
|
||||
|
||||
### 🧠 智能Agent架构
|
||||
- **多工具集成**: 知识库搜索、工单管理、数据分析、通知推送等10+工具
|
||||
- **智能规划**: 基于目标驱动的任务规划和执行
|
||||
- **自主学习**: 从用户反馈中持续优化响应质量
|
||||
- **实时监控**: 主动监控系统状态和异常情况
|
||||
- **模块化重构**: 后端服务(Agent, 车辆数据, 分析, 测试)蓝图化,提高可维护性和扩展性
|
||||
- **前端模块化**: 引入ES6模块化架构,优化UI组件、状态管理和API服务
|
||||
## 快速开始
|
||||
|
||||
### 💬 智能对话系统
|
||||
- **实时通信**: WebSocket支持,毫秒级响应,已修复连接稳定性问题
|
||||
- **上下文理解**: 多轮对话记忆和上下文关联
|
||||
- **VIN识别**: 自动识别车辆VIN码并获取实时数据
|
||||
- **知识库集成**: 基于TF-IDF和余弦相似度的智能检索
|
||||
- **自定义提示词**: 支持飞书同步和实时对话不同场景的LLM提示词
|
||||
|
||||
### 数据驱动分析
|
||||
- **真实数据**: 基于数据库的真实性能趋势分析
|
||||
- **多维度统计**: 工单、预警、满意度、性能指标
|
||||
- **可视化展示**: Chart.js图表,直观的数据呈现
|
||||
- **系统监控**: 实时CPU、内存、健康状态监控
|
||||
- **专属蓝图**: 独立的数据分析API模块,提供专业数据报告导出
|
||||
|
||||
### 🔧 企业级管理
|
||||
- **多环境部署**: 开发、测试、生产环境隔离
|
||||
- **版本控制**: 完整的版本管理和变更日志
|
||||
- **热更新**: 支持前端文件热更新,无需重启服务
|
||||
- **自动备份**: 更新前自动备份,支持一键回滚
|
||||
- **飞书集成**: 支持飞书多维表格数据同步和管理
|
||||
- **统一错误处理**: 后端API统一异常处理,提高系统健壮性
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 前端界面 │ │ 后端服务 │ │ 数据存储 │
|
||||
│ │ │ │ │ │
|
||||
│ • 仪表板 │◄──►│ • Flask API │◄──►│ • MySQL DB │
|
||||
│ • 智能对话 │ │ • WebSocket │ │ • Redis缓存 │
|
||||
│ • Agent管理 │ │ • Agent核心 │ │ • 知识库 │
|
||||
│ • 数据分析 │ │ • LLM集成 │ │ • 工单系统 │
|
||||
│ • 备份管理 │ │ • 备份系统 │ │ • 车辆数据 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 监控系统 │
|
||||
│ │
|
||||
│ • Prometheus │
|
||||
│ • Grafana │
|
||||
│ • Nginx代理 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 1. 智能对话 💬
|
||||
- **多轮对话**: 支持上下文关联的连续对话
|
||||
- **VIN识别**: 自动识别车辆VIN并获取实时数据
|
||||
- **知识库检索**: 智能匹配相关技术文档和解决方案
|
||||
- **工单创建**: 对话中直接创建和关联工单
|
||||
- **错误修复**: 解决了WebSocket连接TypeError问题
|
||||
|
||||
### 2. Agent管理 🤖
|
||||
- **工具管理**: 10+内置工具,支持自定义工具注册
|
||||
- **执行监控**: 实时监控Agent任务执行状态
|
||||
- **性能统计**: 工具使用频率和成功率分析
|
||||
- **智能规划**: 基于目标的任务分解和执行
|
||||
- **专用蓝图**: Agent相关API已独立为蓝图管理
|
||||
|
||||
### 3. 工单系统 📋
|
||||
- **AI建议**: 基于知识库生成工单处理建议
|
||||
- **人工审核**: 支持人工输入和AI建议对比
|
||||
- **相似度评估**: 自动计算AI与人工建议的相似度
|
||||
- **知识库更新**: 高相似度建议自动入库
|
||||
- **飞书AI提示词**: 针对飞书同步场景提供更详细的AI建议提示词
|
||||
|
||||
### 4. 知识库管理 📚
|
||||
- **多格式支持**: TXT、PDF、DOC、DOCX、MD文件
|
||||
- **智能提取**: 自动从文档中提取Q&A对
|
||||
- **向量化检索**: TF-IDF + 余弦相似度搜索
|
||||
- **质量验证**: 支持知识条目验证和置信度设置
|
||||
|
||||
### 5. 数据分析
|
||||
- **实时趋势**: 基于真实数据的性能趋势分析
|
||||
- **多维度统计**: 工单、预警、满意度等关键指标
|
||||
- **系统健康**: CPU、内存、响应时间监控
|
||||
- **可视化展示**: 丰富的图表和仪表板
|
||||
- **专用蓝图**: 数据分析API已独立为蓝图管理,并支持Excel报告导出
|
||||
|
||||
### 6. 系统设置 ⚙️
|
||||
- **API管理**: 支持多种LLM提供商配置
|
||||
- **模型参数**: 温度、最大令牌数等参数调节
|
||||
- **端口配置**: Web服务和WebSocket端口管理
|
||||
- **日志级别**: 灵活的日志级别控制
|
||||
- **数据库健壮性**: 优化了数据库连接配置和错误处理
|
||||
|
||||
### 7. 飞书集成 📱
|
||||
- **多维表格同步**: 自动同步飞书多维表格数据
|
||||
- **字段映射**: 智能映射飞书字段到本地数据库
|
||||
- **实时更新**: 支持增量同步和全量同步
|
||||
- **数据预览**: 同步前预览数据,确保准确性
|
||||
- **统一管理**: 飞书功能集成到主仪表板
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端技术
|
||||
- **Python 3.11+**: 核心开发语言
|
||||
- **Flask 2.3+**: Web框架和API服务
|
||||
- **SQLAlchemy 2.0+**: ORM数据库操作
|
||||
- **WebSocket**: 实时通信支持
|
||||
- **psutil**: 系统资源监控
|
||||
- **Redis**: 缓存和会话管理
|
||||
|
||||
### 前端技术
|
||||
- **Bootstrap 5**: UI框架
|
||||
- **Chart.js**: 数据可视化
|
||||
- **JavaScript ES6+**: 前端逻辑
|
||||
- **WebSocket**: 实时通信客户端
|
||||
|
||||
### AI/ML技术
|
||||
- **大语言模型**: 支持OpenAI、通义千问等
|
||||
- **TF-IDF**: 文本向量化
|
||||
- **余弦相似度**: 语义相似度计算
|
||||
- **Agent框架**: 智能任务规划
|
||||
- **Transformers**: 预训练模型支持
|
||||
|
||||
### 部署运维
|
||||
- **Docker**: 容器化部署
|
||||
- **Docker Compose**: 多服务编排
|
||||
- **Nginx**: 反向代理和静态文件服务
|
||||
- **Prometheus**: 监控数据收集
|
||||
- **Grafana**: 监控仪表板
|
||||
- **MySQL 8.0**: 主数据库
|
||||
- **Redis 7**: 缓存服务
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
#### Docker部署(推荐)
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 4GB+ 可用内存
|
||||
- 10GB+ 可用磁盘空间
|
||||
|
||||
#### 本地部署
|
||||
- Python 3.11+
|
||||
- Node.js 16+ (可选,用于前端构建)
|
||||
- MySQL 8.0+ 或 SQLite
|
||||
- Redis 7+ (可选)
|
||||
- Git
|
||||
|
||||
### 🐳 Docker部署(推荐)
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone http://jeason.online:3000/zhaojie/assist.git
|
||||
cd assist
|
||||
```
|
||||
|
||||
2. **一键启动所有服务**
|
||||
```bash
|
||||
# 使用部署脚本
|
||||
chmod +x scripts/docker_deploy.sh
|
||||
./scripts/docker_deploy.sh start
|
||||
|
||||
# 或直接使用docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. **访问系统**
|
||||
- **TSP助手**: http://localhost:5000
|
||||
- **Nginx代理**: http://localhost
|
||||
- **Prometheus监控**: http://localhost:9090
|
||||
- **Grafana仪表板**: http://localhost:3000 (admin/admin123456)
|
||||
|
||||
4. **服务管理**
|
||||
```bash
|
||||
# 查看服务状态
|
||||
./scripts/docker_deploy.sh status
|
||||
|
||||
# 查看日志
|
||||
./scripts/docker_deploy.sh logs tsp-assistant
|
||||
|
||||
# 停止服务
|
||||
./scripts/docker_deploy.sh stop
|
||||
|
||||
# 重启服务
|
||||
./scripts/docker_deploy.sh restart
|
||||
```
|
||||
|
||||
### 💻 本地部署
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone http://jeason.online:3000/zhaojie/assist.git
|
||||
cd assist
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **初始化数据库**
|
||||
```bash
|
||||
cp .env.example .env # 编辑填入 LLM API Key、飞书凭证等
|
||||
python init_database.py
|
||||
```
|
||||
|
||||
4. **启动服务**
|
||||
```bash
|
||||
python start_dashboard.py
|
||||
```
|
||||
|
||||
5. **访问系统**
|
||||
- 打开浏览器访问: `http://localhost:5000`
|
||||
- 默认端口: 5000 (可在系统设置中修改)
|
||||
访问 http://localhost:5000,默认账号 `admin` / `admin123`。
|
||||
|
||||
### Windows快速启动
|
||||
```cmd
|
||||
# 双击运行
|
||||
快速启动.bat
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # 配置管理(unified_config + config_service)
|
||||
├── core/ # 基础设施(数据库、LLM、缓存、认证)
|
||||
├── dialogue/ # 对话管理(realtime_chat、message_pipeline)
|
||||
├── knowledge_base/ # 知识库(搜索、导入、验证)
|
||||
├── repositories/ # 数据访问层(自动 tenant_id 过滤)
|
||||
├── analytics/ # 监控与分析(预警、Token、AI 成功率)
|
||||
├── integrations/ # 外部集成(飞书客户端、工单同步)
|
||||
├── agent/ # ReAct Agent(工具调度)
|
||||
└── web/ # Web 层
|
||||
├── app.py # Flask 应用
|
||||
├── blueprints/ # API 蓝图(每个领域一个文件)
|
||||
├── service_manager.py
|
||||
├── static/js/ # 前端模块
|
||||
│ ├── dashboard.js # 核心
|
||||
│ └── modules/ # 功能模块
|
||||
└── templates/ # Jinja2 模板
|
||||
```
|
||||
|
||||
## 📖 使用指南
|
||||
## 环境变量
|
||||
|
||||
### 基础操作
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `SECRET_KEY` | Flask session 密钥 | 随机生成 |
|
||||
| `LLM_BASE_URL` | LLM API 地址 | DashScope |
|
||||
| `LLM_API_KEY` | LLM API 密钥 | - |
|
||||
| `LLM_MODEL` | 模型名称 | qwen-plus-latest |
|
||||
| `FEISHU_APP_ID` | 飞书应用 ID | - |
|
||||
| `FEISHU_APP_SECRET` | 飞书应用密钥 | - |
|
||||
| `REDIS_HOST` | Redis 地址 | localhost |
|
||||
| `ADMIN_PASSWORD` | 默认管理员密码 | admin123 |
|
||||
|
||||
1. **智能对话**
|
||||
- 在"智能对话"页面输入问题
|
||||
- 系统自动检索知识库并生成回答
|
||||
- 支持VIN码识别和车辆数据查询
|
||||
|
||||
2. **工单管理**
|
||||
- 创建工单并获取AI建议
|
||||
- 人工输入解决方案
|
||||
- 系统自动评估相似度并更新知识库
|
||||
|
||||
3. **知识库维护**
|
||||
- 手动添加Q&A对
|
||||
- 上传文档自动提取知识
|
||||
- 设置置信度和验证状态
|
||||
|
||||
4. **系统监控**
|
||||
- 查看实时性能趋势
|
||||
- 监控系统健康状态
|
||||
- 管理预警和通知
|
||||
|
||||
### 高级功能
|
||||
|
||||
1. **Agent工具管理**
|
||||
- 查看工具使用统计
|
||||
- 注册自定义工具
|
||||
- 监控执行历史
|
||||
|
||||
2. **数据分析**
|
||||
- 多维度数据统计
|
||||
- 自定义时间范围
|
||||
- 导出分析报告
|
||||
|
||||
3. **系统配置**
|
||||
- API和模型参数配置
|
||||
- 端口和日志级别设置
|
||||
- 环境变量管理
|
||||
|
||||
## 🔄 部署与更新
|
||||
|
||||
### 版本管理
|
||||
```bash
|
||||
# 更新版本号
|
||||
python version.py increment --type minor
|
||||
|
||||
# 添加变更日志
|
||||
python version.py changelog --message "新功能描述"
|
||||
|
||||
# 创建发布标签
|
||||
python version.py tag --message "Release v1.3.0"
|
||||
```
|
||||
|
||||
## 系统监控
|
||||
|
||||
### 健康检查
|
||||
- **API状态**: `/api/health`
|
||||
- **服务监控**: 自动健康检查和故障恢复
|
||||
- **性能指标**: 响应时间、吞吐量、错误率
|
||||
|
||||
### 日志管理
|
||||
- **应用日志**: `logs/tsp_assistant.log`
|
||||
- **访问日志**: Nginx访问日志
|
||||
- **错误追踪**: 详细的错误堆栈信息
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### Docker环境变量
|
||||
```bash
|
||||
# 数据库配置
|
||||
DATABASE_URL=mysql+pymysql://tsp_user:tsp_password@mysql:3306/tsp_assistant?charset=utf8mb4
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# LLM配置
|
||||
LLM_PROVIDER=openai
|
||||
LLM_API_KEY=your_api_key
|
||||
LLM_MODEL=gpt-3.5-turbo
|
||||
|
||||
# 服务配置
|
||||
SERVER_PORT=5000
|
||||
WEBSOCKET_PORT=8765
|
||||
LOG_LEVEL=INFO
|
||||
TZ=Asia/Shanghai
|
||||
```
|
||||
|
||||
### Docker服务配置
|
||||
|
||||
#### 主要服务
|
||||
- **tsp-assistant**: 主应用服务 (端口: 5000, 8765)
|
||||
- **mysql**: MySQL数据库 (端口: 3306)
|
||||
- **redis**: Redis缓存 (端口: 6379)
|
||||
- **nginx**: 反向代理 (端口: 80, 443)
|
||||
|
||||
#### 监控服务
|
||||
- **prometheus**: 监控数据收集 (端口: 9090)
|
||||
- **grafana**: 监控仪表板 (端口: 3000)
|
||||
|
||||
#### 数据卷
|
||||
- `mysql_data`: MySQL数据持久化
|
||||
- `redis_data`: Redis数据持久化
|
||||
- `prometheus_data`: Prometheus数据持久化
|
||||
- `grafana_data`: Grafana配置和数据持久化
|
||||
|
||||
### 配置文件
|
||||
- `config/llm_config.py`: LLM客户端配置
|
||||
- `config/integrations_config.json`: 飞书集成配置
|
||||
- `nginx.conf`: Nginx反向代理配置
|
||||
- `monitoring/prometheus.yml`: Prometheus监控配置
|
||||
- `init.sql`: 数据库初始化脚本
|
||||
- `docker-compose.yml`: Docker服务编排配置
|
||||
- `Dockerfile`: 应用镜像构建配置
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
### 开发流程
|
||||
1. Fork项目到个人仓库
|
||||
2. 创建功能分支: `git checkout -b feature/new-feature`
|
||||
3. 提交更改: `git commit -m "Add new feature"`
|
||||
4. 推送分支: `git push origin feature/new-feature`
|
||||
5. 创建Pull Request
|
||||
|
||||
### 代码规范
|
||||
- Python代码遵循PEP 8规范
|
||||
- JavaScript使用ES6+语法
|
||||
- 提交信息使用约定式提交格式
|
||||
- 新功能需要添加相应的测试
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
### v2.1.0 (2025-12-08) - 全面架构优化与问题修复
|
||||
- ⚙️ **后端架构重构**:
|
||||
- 将Agent、车辆数据、数据分析、API测试相关路由拆分为独立蓝图。
|
||||
- 精简 `app.py` 主应用文件,提升模块化和可维护性。
|
||||
- 引入统一错误处理装饰器和依赖注入机制。
|
||||
- 🎨 **前端架构优化**:
|
||||
- 实现了JavaScript模块化架构,划分 `core`, `services`, `components` 目录。
|
||||
- 引入了统一状态管理 (`store.js`) 和API服务 (`api.js`)。
|
||||
- 优化了通知管理和预警显示组件。
|
||||
- 🛠️ **关键问题修复**:
|
||||
- 修复了WebSocket连接中 `TypeError: missing 1 required positional argument: 'path'` 错误。
|
||||
- 改进了数据库连接的健壮性,优化MySQL连接池配置,并增强了异常处理和重连机制。
|
||||
- 解决了 `generator didn't stop` 错误,确保数据库会话的正确关闭。
|
||||
- 增强了预警系统异常处理,并在规则检查失败时生成系统预警。
|
||||
- 优化了API错误响应,包含更详细的错误信息。
|
||||
- ✨ **新功能增强**:
|
||||
- 为飞书同步和实时对话场景引入了不同的LLM提示词,提升AI建议的针对性。
|
||||
- 增加了对`Analysising`工单状态的映射处理。
|
||||
|
||||
### v2.0.0 (2025-09-22) - Docker环境全面升级
|
||||
- 🐳 **Docker环境重构**: 升级到Python 3.11,优化镜像构建
|
||||
- 🐳 **多服务编排**: MySQL 8.0 + Redis 7 + Nginx + Prometheus + Grafana
|
||||
- 🐳 **监控系统**: 集成Prometheus监控和Grafana仪表板
|
||||
- 🐳 **安全增强**: 非root用户运行,数据卷隔离
|
||||
- 🐳 **部署脚本**: 一键部署脚本,支持启动/停止/重启/清理
|
||||
- 🔧 **知识库搜索修复**: 简化搜索算法,提升检索准确率
|
||||
- 🔧 **批量删除优化**: 修复外键约束和缓存问题
|
||||
- 🔧 **日志编码修复**: 解决中文乱码问题
|
||||
- **可视化增强**: 修复预警、性能、满意度图表显示
|
||||
- 📚 **文档更新**: 完整的Docker部署和使用指南
|
||||
|
||||
### v1.4.0 (2025-09-19)
|
||||
- 飞书集成功能:支持飞书多维表格数据同步
|
||||
- 页面功能合并:飞书同步页面合并到主仪表板
|
||||
- 数据库架构优化:扩展工单表字段,支持飞书数据
|
||||
- 代码重构优化:大文件拆分,降低运行风险
|
||||
- 字段映射完善:智能映射飞书字段到本地数据库
|
||||
- 数据库初始化改进:集成字段迁移到初始化流程
|
||||
|
||||
### v1.3.0 (2025-09-17)
|
||||
- 数据库架构优化:MySQL主数据库+SQLite备份系统
|
||||
- 工单详情API修复:解决数据库会话管理问题
|
||||
- 备份管理系统:自动备份MySQL数据到SQLite
|
||||
- 数据库状态监控:实时监控MySQL和SQLite状态
|
||||
- 备份管理API:支持数据备份和恢复操作
|
||||
|
||||
### v1.2.0 (2025-09-16)
|
||||
- 系统设置扩展:API管理、模型参数配置、端口管理
|
||||
- 真实数据分析:修复性能趋势图表显示问题
|
||||
- 工单AI建议功能:智能生成处理建议
|
||||
- 知识库搜索优化:提升检索准确率
|
||||
- Agent管理改进:工具使用统计和自定义工具
|
||||
|
||||
### v1.1.0 (2025-09-16)
|
||||
- 工单AI建议功能
|
||||
- 知识库搜索优化
|
||||
- Agent管理改进
|
||||
|
||||
### v1.0.0 (2024-01-01)
|
||||
- 初始版本发布
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### Docker部署问题
|
||||
|
||||
#### 常见问题
|
||||
1. **端口冲突**
|
||||
```bash
|
||||
# 检查端口占用
|
||||
netstat -tulpn | grep :5000
|
||||
# 修改docker-compose.yml中的端口映射
|
||||
```
|
||||
|
||||
2. **内存不足**
|
||||
```bash
|
||||
# 检查Docker资源使用
|
||||
docker stats
|
||||
# 增加Docker内存限制或关闭其他服务
|
||||
```
|
||||
|
||||
3. **数据库连接失败**
|
||||
```bash
|
||||
# 检查MySQL服务状态
|
||||
docker-compose logs mysql
|
||||
# 等待数据库完全启动(约30秒)
|
||||
```
|
||||
|
||||
4. **权限问题**
|
||||
```bash
|
||||
# 给脚本添加执行权限
|
||||
chmod +x scripts/docker_deploy.sh
|
||||
# 检查文件权限
|
||||
ls -la scripts/
|
||||
```
|
||||
|
||||
#### 日志查看
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker-compose logs -f tsp-assistant
|
||||
docker-compose logs -f mysql
|
||||
docker-compose logs -f redis
|
||||
```
|
||||
|
||||
#### 服务重启
|
||||
```bash
|
||||
# 重启特定服务
|
||||
docker-compose restart tsp-assistant
|
||||
|
||||
# 重启所有服务
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
### 性能优化
|
||||
|
||||
#### Docker资源限制
|
||||
```yaml
|
||||
# 在docker-compose.yml中添加资源限制
|
||||
services:
|
||||
tsp-assistant:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '1.0'
|
||||
```
|
||||
|
||||
#### 数据库优化
|
||||
```sql
|
||||
-- MySQL性能优化
|
||||
SET GLOBAL innodb_buffer_pool_size = 1G;
|
||||
SET GLOBAL max_connections = 200;
|
||||
```
|
||||
|
||||
## 📞 支持与联系
|
||||
|
||||
- **项目地址**: http://jeason.online:3000/zhaojie/assist
|
||||
- **问题反馈**: 请在Issues中提交问题
|
||||
- **功能建议**: 欢迎提交Feature Request
|
||||
- **Docker问题**: 请提供docker-compose logs输出
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有为项目做出贡献的开发者和用户!
|
||||
|
||||
---
|
||||
|
||||
**TSP智能助手** - 让车辆服务更智能,让客户体验更美好! 🚗✨
|
||||
完整变量列表见 `.env.example`。
|
||||
|
||||
76
src/config/config_service.py
Normal file
76
src/config/config_service.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
统一配置服务
|
||||
优先级:环境变量 > system_settings.json > 代码默认值
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SETTINGS_PATH = os.path.join('data', 'system_settings.json')
|
||||
|
||||
|
||||
class ConfigService:
|
||||
def __init__(self):
|
||||
self._file_cache = None
|
||||
self._file_mtime = 0
|
||||
|
||||
def _load_file(self) -> dict:
|
||||
try:
|
||||
if os.path.exists(_SETTINGS_PATH):
|
||||
mtime = os.path.getmtime(_SETTINGS_PATH)
|
||||
if mtime != self._file_mtime or self._file_cache is None:
|
||||
with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
||||
self._file_cache = json.load(f)
|
||||
self._file_mtime = mtime
|
||||
return self._file_cache or {}
|
||||
except Exception as e:
|
||||
logger.debug(f"加载配置文件失败: {e}")
|
||||
return {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
env_key = key.upper().replace('.', '_')
|
||||
env_val = os.environ.get(env_key)
|
||||
if env_val is not None:
|
||||
return self._cast(env_val, default)
|
||||
settings = self._load_file()
|
||||
parts = key.split('.')
|
||||
val = settings
|
||||
for part in parts:
|
||||
if isinstance(val, dict):
|
||||
val = val.get(part)
|
||||
else:
|
||||
return default
|
||||
return val if val is not None else default
|
||||
|
||||
def get_section(self, section: str) -> dict:
|
||||
return self._load_file().get(section, {})
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
settings = self._load_file()
|
||||
parts = key.split('.')
|
||||
target = settings
|
||||
for part in parts[:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
target[parts[-1]] = value
|
||||
os.makedirs('data', exist_ok=True)
|
||||
with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, ensure_ascii=False, indent=2)
|
||||
self._file_cache = settings
|
||||
|
||||
@staticmethod
|
||||
def _cast(value: str, default: Any) -> Any:
|
||||
if default is None: return value
|
||||
if isinstance(default, bool): return value.lower() in ('true', '1', 'yes')
|
||||
if isinstance(default, int):
|
||||
try: return int(value)
|
||||
except: return default
|
||||
if isinstance(default, float):
|
||||
try: return float(value)
|
||||
except: return default
|
||||
return value
|
||||
|
||||
|
||||
config_service = ConfigService()
|
||||
@@ -18,16 +18,19 @@ class AuthManager:
|
||||
"""认证管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.secret_key = "your-secret-key-change-this-in-production" # 应该从配置中读取
|
||||
import os
|
||||
self.secret_key = os.environ.get('SECRET_KEY', 'change-this-in-production')
|
||||
self.token_expiry = timedelta(hours=24)
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""密码哈希"""
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
import bcrypt
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
def verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""验证密码"""
|
||||
return self.hash_password(password) == password_hash
|
||||
import bcrypt
|
||||
if password_hash and password_hash.startswith('$2b$'):
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
return hashlib.sha256(password.encode()).hexdigest() == password_hash
|
||||
|
||||
def generate_token(self, user_data: dict) -> str:
|
||||
"""生成JWT token"""
|
||||
|
||||
@@ -294,12 +294,19 @@ class User(Base):
|
||||
last_login = Column(DateTime)
|
||||
|
||||
def set_password(self, password):
|
||||
"""设置密码哈希"""
|
||||
self.password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
"""设置密码哈希(bcrypt)"""
|
||||
import bcrypt
|
||||
self.password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
def check_password(self, password):
|
||||
"""验证密码"""
|
||||
return self.password_hash == hashlib.sha256(password.encode()).hexdigest()
|
||||
"""验证密码(兼容旧 SHA-256,验证通过后自动升级为 bcrypt)"""
|
||||
import bcrypt
|
||||
if self.password_hash and self.password_hash.startswith('$2b$'):
|
||||
return bcrypt.checkpw(password.encode(), self.password_hash.encode())
|
||||
if self.password_hash == hashlib.sha256(password.encode()).hexdigest():
|
||||
self.set_password(password)
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典格式(用于API响应)"""
|
||||
|
||||
1
src/repositories/__init__.py
Normal file
1
src/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Repository 层 — 统一数据访问,自动 tenant_id 过滤
|
||||
24
src/repositories/alert_repo.py
Normal file
24
src/repositories/alert_repo.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import desc
|
||||
from src.core.models import Alert
|
||||
from .base import BaseRepository
|
||||
|
||||
|
||||
class AlertRepository(BaseRepository):
|
||||
model_class = Alert
|
||||
|
||||
def list_alerts(self, tenant_id=None, page=1, per_page=20, level=None, is_active=None):
|
||||
filters = {}
|
||||
if level:
|
||||
filters['level'] = level
|
||||
if is_active is not None:
|
||||
filters['is_active'] = is_active
|
||||
return self.list(tenant_id=tenant_id, page=page, per_page=per_page,
|
||||
filters=filters, order_by=desc(Alert.created_at))
|
||||
|
||||
def resolve(self, alert_id: int, tenant_id=None):
|
||||
from datetime import datetime
|
||||
return self.update(alert_id, {'is_active': False, 'resolved_at': datetime.now()}, tenant_id=tenant_id)
|
||||
|
||||
|
||||
alert_repo = AlertRepository()
|
||||
121
src/repositories/base.py
Normal file
121
src/repositories/base.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Repository 基类
|
||||
所有数据访问通过 Repository 层,自动附加 tenant_id 过滤。
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from sqlalchemy.orm import Session
|
||||
from src.core.database import db_manager
|
||||
from src.core.models import DEFAULT_TENANT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseRepository:
|
||||
"""
|
||||
Repository 基类。子类只需指定 model_class 和 tenant_field。
|
||||
所有查询自动按 tenant_id 过滤(如果模型有该字段)。
|
||||
"""
|
||||
model_class = None # 子类必须设置
|
||||
tenant_field = 'tenant_id' # 默认租户字段名
|
||||
|
||||
def _base_query(self, session: Session, tenant_id: str = None):
|
||||
"""构建带 tenant_id 过滤的基础查询"""
|
||||
q = session.query(self.model_class)
|
||||
if tenant_id and hasattr(self.model_class, self.tenant_field):
|
||||
q = q.filter(getattr(self.model_class, self.tenant_field) == tenant_id)
|
||||
return q
|
||||
|
||||
def get_by_id(self, id: int, tenant_id: str = None) -> Optional[Dict]:
|
||||
"""按 ID 查询"""
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id).filter(self.model_class.id == id)
|
||||
obj = q.first()
|
||||
return self._to_dict(obj) if obj else None
|
||||
|
||||
def list(self, tenant_id: str = None, page: int = 1, per_page: int = 20,
|
||||
filters: Dict = None, order_by=None) -> Dict[str, Any]:
|
||||
"""分页列表查询"""
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id)
|
||||
if filters:
|
||||
for field, value in filters.items():
|
||||
if value is not None and hasattr(self.model_class, field):
|
||||
q = q.filter(getattr(self.model_class, field) == value)
|
||||
if order_by is not None:
|
||||
q = q.order_by(order_by)
|
||||
total = q.count()
|
||||
items = q.offset((page - 1) * per_page).limit(per_page).all()
|
||||
return {
|
||||
'items': [self._to_dict(item) for item in items],
|
||||
'page': page, 'per_page': per_page,
|
||||
'total': total, 'total_pages': (total + per_page - 1) // per_page
|
||||
}
|
||||
|
||||
def create(self, data: Dict, tenant_id: str = None) -> Dict:
|
||||
"""创建记录,自动设置 tenant_id"""
|
||||
with db_manager.get_session() as session:
|
||||
if tenant_id and hasattr(self.model_class, self.tenant_field):
|
||||
data[self.tenant_field] = tenant_id
|
||||
elif hasattr(self.model_class, self.tenant_field) and self.tenant_field not in data:
|
||||
data[self.tenant_field] = DEFAULT_TENANT
|
||||
# 只保留模型有的字段
|
||||
valid = {k: v for k, v in data.items() if hasattr(self.model_class, k) and not isinstance(v, (dict, list))}
|
||||
obj = self.model_class(**valid)
|
||||
session.add(obj)
|
||||
session.flush()
|
||||
result = self._to_dict(obj)
|
||||
return result
|
||||
|
||||
def update(self, id: int, data: Dict, tenant_id: str = None) -> Optional[Dict]:
|
||||
"""更新记录"""
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id).filter(self.model_class.id == id)
|
||||
obj = q.first()
|
||||
if not obj:
|
||||
return None
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k) and k not in ('id', 'tenant_id'):
|
||||
setattr(obj, k, v)
|
||||
session.flush()
|
||||
return self._to_dict(obj)
|
||||
|
||||
def delete(self, id: int, tenant_id: str = None) -> bool:
|
||||
"""删除记录"""
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id).filter(self.model_class.id == id)
|
||||
obj = q.first()
|
||||
if not obj:
|
||||
return False
|
||||
session.delete(obj)
|
||||
return True
|
||||
|
||||
def batch_delete(self, ids: List[int], tenant_id: str = None) -> int:
|
||||
"""批量删除,返回实际删除数量"""
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id).filter(self.model_class.id.in_(ids))
|
||||
count = q.delete(synchronize_session='fetch')
|
||||
return count
|
||||
|
||||
def count(self, tenant_id: str = None, filters: Dict = None) -> int:
|
||||
"""计数"""
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id)
|
||||
if filters:
|
||||
for field, value in filters.items():
|
||||
if value is not None and hasattr(self.model_class, field):
|
||||
q = q.filter(getattr(self.model_class, field) == value)
|
||||
return q.count()
|
||||
|
||||
def _to_dict(self, obj) -> Dict:
|
||||
"""将 ORM 对象转为字典。子类可覆盖。"""
|
||||
if hasattr(obj, 'to_dict'):
|
||||
return obj.to_dict()
|
||||
result = {}
|
||||
for col in obj.__table__.columns:
|
||||
val = getattr(obj, col.name)
|
||||
if hasattr(val, 'isoformat'):
|
||||
val = val.isoformat()
|
||||
result[col.name] = val
|
||||
return result
|
||||
43
src/repositories/conversation_repo.py
Normal file
43
src/repositories/conversation_repo.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import desc
|
||||
from src.core.models import ChatSession, Conversation
|
||||
from src.core.database import db_manager
|
||||
from .base import BaseRepository
|
||||
|
||||
|
||||
class ChatSessionRepository(BaseRepository):
|
||||
model_class = ChatSession
|
||||
|
||||
def list_sessions(self, tenant_id=None, page=1, per_page=20, status=None, search=None):
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id)
|
||||
if status:
|
||||
q = q.filter(ChatSession.status == status)
|
||||
if search:
|
||||
from sqlalchemy import or_
|
||||
q = q.filter(or_(
|
||||
ChatSession.title.contains(search),
|
||||
ChatSession.session_id.contains(search)
|
||||
))
|
||||
q = q.order_by(desc(ChatSession.updated_at))
|
||||
total = q.count()
|
||||
items = q.offset((page - 1) * per_page).limit(per_page).all()
|
||||
return {
|
||||
'sessions': [self._to_dict(s) for s in items],
|
||||
'page': page, 'per_page': per_page,
|
||||
'total': total, 'total_pages': (total + per_page - 1) // per_page
|
||||
}
|
||||
|
||||
|
||||
class ConversationRepository(BaseRepository):
|
||||
model_class = Conversation
|
||||
|
||||
def get_by_session_id(self, session_id: str, tenant_id=None):
|
||||
with db_manager.get_session() as session:
|
||||
q = self._base_query(session, tenant_id).filter(Conversation.session_id == session_id)
|
||||
items = q.order_by(Conversation.timestamp).all()
|
||||
return [self._to_dict(c) for c in items]
|
||||
|
||||
|
||||
chat_session_repo = ChatSessionRepository()
|
||||
conversation_repo = ConversationRepository()
|
||||
20
src/repositories/knowledge_repo.py
Normal file
20
src/repositories/knowledge_repo.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import desc
|
||||
from src.core.models import KnowledgeEntry
|
||||
from .base import BaseRepository
|
||||
|
||||
|
||||
class KnowledgeRepository(BaseRepository):
|
||||
model_class = KnowledgeEntry
|
||||
|
||||
def list_knowledge(self, tenant_id=None, page=1, per_page=20, category=None, verified=None):
|
||||
filters = {}
|
||||
if category:
|
||||
filters['category'] = category
|
||||
if verified is not None:
|
||||
filters['is_verified'] = verified
|
||||
return self.list(tenant_id=tenant_id, page=page, per_page=per_page,
|
||||
filters=filters, order_by=desc(KnowledgeEntry.updated_at))
|
||||
|
||||
|
||||
knowledge_repo = KnowledgeRepository()
|
||||
28
src/repositories/workorder_repo.py
Normal file
28
src/repositories/workorder_repo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import desc
|
||||
from src.core.models import WorkOrder
|
||||
from .base import BaseRepository
|
||||
|
||||
|
||||
class WorkOrderRepository(BaseRepository):
|
||||
model_class = WorkOrder
|
||||
|
||||
def list_workorders(self, tenant_id=None, page=1, per_page=20, status=None, priority=None):
|
||||
"""工单列表(带状态和优先级过滤)"""
|
||||
filters = {}
|
||||
if status:
|
||||
filters['status'] = status
|
||||
if priority:
|
||||
filters['priority'] = priority
|
||||
return self.list(tenant_id=tenant_id, page=page, per_page=per_page,
|
||||
filters=filters, order_by=desc(WorkOrder.created_at))
|
||||
|
||||
def find_by_feishu_record_id(self, feishu_record_id: str):
|
||||
"""按飞书记录 ID 查找"""
|
||||
from src.core.database import db_manager
|
||||
with db_manager.get_session() as session:
|
||||
obj = session.query(WorkOrder).filter(WorkOrder.feishu_record_id == feishu_record_id).first()
|
||||
return self._to_dict(obj) if obj else None
|
||||
|
||||
|
||||
workorder_repo = WorkOrderRepository()
|
||||
@@ -496,8 +496,8 @@ def get_error_detail(conv_id):
|
||||
'assistant_response': conv.assistant_response,
|
||||
'confidence_score': conv.confidence_score,
|
||||
'response_time': conv.response_time,
|
||||
'category': conv.category,
|
||||
'source': conv.source,
|
||||
'session_id': conv.session_id,
|
||||
'invocation_method': conv.invocation_method,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
/**
|
||||
* 预警管理组件
|
||||
* 专门处理预警相关的功能
|
||||
*/
|
||||
|
||||
class AlertManager {
|
||||
constructor() {
|
||||
this.refreshInterval = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadInitialData();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 监控控制按钮
|
||||
this.bindButton('start-monitor', () => this.startMonitoring());
|
||||
this.bindButton('stop-monitor', () => this.stopMonitoring());
|
||||
this.bindButton('check-alerts', () => this.checkAlerts());
|
||||
this.bindButton('refresh-alerts', () => this.loadAlerts());
|
||||
|
||||
// 预警过滤和排序
|
||||
this.bindSelect('alert-filter', () => this.updateAlertsDisplay());
|
||||
this.bindSelect('alert-sort', () => this.updateAlertsDisplay());
|
||||
}
|
||||
|
||||
bindButton(id, handler) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
|
||||
bindSelect(id, handler) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('change', handler);
|
||||
}
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
store.setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadAlerts(),
|
||||
this.loadRules(),
|
||||
this.loadMonitorStatus()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('加载初始数据失败:', error);
|
||||
notificationManager.error('加载数据失败,请刷新页面重试');
|
||||
} finally {
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
// 清除现有定时器
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
|
||||
// 每10秒刷新一次预警
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadAlerts();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// 监控控制方法
|
||||
async startMonitoring() {
|
||||
try {
|
||||
store.setLoading(true);
|
||||
const result = await apiService.startMonitoring();
|
||||
|
||||
if (result.success) {
|
||||
notificationManager.success('监控已启动');
|
||||
await this.loadMonitorStatus();
|
||||
} else {
|
||||
notificationManager.error(result.message || '启动监控失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启动监控失败:', error);
|
||||
notificationManager.error('启动监控失败');
|
||||
} finally {
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async stopMonitoring() {
|
||||
try {
|
||||
store.setLoading(true);
|
||||
const result = await apiService.stopMonitoring();
|
||||
|
||||
if (result.success) {
|
||||
notificationManager.success('监控已停止');
|
||||
await this.loadMonitorStatus();
|
||||
} else {
|
||||
notificationManager.error(result.message || '停止监控失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停止监控失败:', error);
|
||||
notificationManager.error('停止监控失败');
|
||||
} finally {
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async checkAlerts() {
|
||||
try {
|
||||
store.setLoading(true);
|
||||
await this.loadAlerts();
|
||||
notificationManager.success('预警检查完成');
|
||||
} catch (error) {
|
||||
console.error('检查预警失败:', error);
|
||||
notificationManager.error('检查预警失败');
|
||||
} finally {
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 数据加载方法
|
||||
async loadAlerts() {
|
||||
try {
|
||||
const data = await apiService.getAlerts();
|
||||
store.updateAlerts(data);
|
||||
this.updateAlertsDisplay();
|
||||
} catch (error) {
|
||||
console.error('加载预警失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRules() {
|
||||
try {
|
||||
const data = await apiService.getRules();
|
||||
store.updateRules(data);
|
||||
this.updateRulesDisplay();
|
||||
} catch (error) {
|
||||
console.error('加载规则失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadMonitorStatus() {
|
||||
try {
|
||||
const data = await apiService.getMonitorStatus();
|
||||
store.updateMonitorStatus(data.monitor_status);
|
||||
this.updateMonitorStatusDisplay();
|
||||
} catch (error) {
|
||||
console.error('加载监控状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示更新方法
|
||||
updateAlertsDisplay() {
|
||||
const alerts = store.getSortedAlerts('timestamp', 'desc');
|
||||
const container = document.getElementById('alerts-container');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (alerts.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-muted py-4"><i class="fas fa-info-circle fa-2x mb-2"></i><br>暂无预警</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = alerts.map(alert => this.createAlertElement(alert)).join('');
|
||||
}
|
||||
|
||||
createAlertElement(alert) {
|
||||
const levelClass = this.getLevelClass(alert.level);
|
||||
const typeText = this.getTypeText(alert.alert_type);
|
||||
const levelText = this.getLevelText(alert.level);
|
||||
const timeText = this.formatTime(alert.timestamp);
|
||||
|
||||
return `
|
||||
<div class="alert-item card mb-2 border-${levelClass}">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="badge bg-${levelClass} me-2">${levelText}</span>
|
||||
<span class="badge bg-secondary">${typeText}</span>
|
||||
<small class="text-muted ms-2">${timeText}</small>
|
||||
</div>
|
||||
<h6 class="card-title mb-1">${alert.title}</h6>
|
||||
<p class="card-text small text-muted mb-2">${alert.description}</p>
|
||||
<div class="alert-actions">
|
||||
<button class="btn btn-sm btn-outline-primary me-1" onclick="alertManager.acknowledgeAlert('${alert.id}')">
|
||||
<i class="fas fa-check"></i> 确认
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="alertManager.viewAlertDetail('${alert.id}')">
|
||||
<i class="fas fa-eye"></i> 详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateRulesDisplay() {
|
||||
const rules = store.getState().rules;
|
||||
const container = document.getElementById('rules-container');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (rules.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-muted py-4"><i class="fas fa-list fa-2x mb-2"></i><br>暂无规则</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = rules.map(rule => this.createRuleElement(rule)).join('');
|
||||
}
|
||||
|
||||
createRuleElement(rule) {
|
||||
const enabledText = rule.enabled ? '<span class="badge bg-success">启用</span>' : '<span class="badge bg-secondary">禁用</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${rule.name}</td>
|
||||
<td>${rule.alert_type}</td>
|
||||
<td>${rule.level}</td>
|
||||
<td>${rule.threshold}</td>
|
||||
<td>${enabledText}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" onclick="alertManager.editRule('${rule.name}')">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="alertManager.deleteRule('${rule.name}')">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
updateMonitorStatusDisplay() {
|
||||
const status = store.getState().monitorStatus;
|
||||
const element = document.getElementById('monitor-status');
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const statusConfig = {
|
||||
'running': { icon: 'text-success', text: '运行中' },
|
||||
'stopped': { icon: 'text-danger', text: '已停止' },
|
||||
'unknown': { icon: 'text-warning', text: '未知' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.unknown;
|
||||
|
||||
element.innerHTML = `
|
||||
<i class="fas fa-circle ${config.icon}"></i> 监控状态: ${config.text}
|
||||
`;
|
||||
}
|
||||
|
||||
// 工具方法
|
||||
getLevelClass(level) {
|
||||
const levelMap = {
|
||||
'critical': 'danger',
|
||||
'error': 'danger',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
return levelMap[level] || 'secondary';
|
||||
}
|
||||
|
||||
getLevelText(level) {
|
||||
const levelMap = {
|
||||
'critical': '严重',
|
||||
'error': '错误',
|
||||
'warning': '警告',
|
||||
'info': '信息'
|
||||
};
|
||||
return levelMap[level] || level;
|
||||
}
|
||||
|
||||
getTypeText(type) {
|
||||
const typeMap = {
|
||||
'performance': '性能',
|
||||
'quality': '质量',
|
||||
'volume': '量级',
|
||||
'system': '系统',
|
||||
'business': '业务'
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
}
|
||||
|
||||
formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
if (diff < 60000) return '刚刚';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// 预警操作方法
|
||||
async acknowledgeAlert(alertId) {
|
||||
try {
|
||||
await apiService.updateAlert(alertId, { acknowledged: true });
|
||||
notificationManager.success('预警已确认');
|
||||
await this.loadAlerts();
|
||||
} catch (error) {
|
||||
console.error('确认预警失败:', error);
|
||||
notificationManager.error('确认预警失败');
|
||||
}
|
||||
}
|
||||
|
||||
viewAlertDetail(alertId) {
|
||||
// 这里可以实现查看详情的逻辑
|
||||
notificationManager.info('详情查看功能开发中');
|
||||
}
|
||||
|
||||
// 规则操作方法
|
||||
editRule(ruleName) {
|
||||
const rule = store.getState().rules.find(r => r.name === ruleName);
|
||||
if (!rule) {
|
||||
notificationManager.error('规则不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 填充编辑表单
|
||||
document.getElementById('edit-rule-name-original').value = rule.name;
|
||||
document.getElementById('edit-rule-name').value = rule.name;
|
||||
document.getElementById('edit-rule-type').value = rule.alert_type;
|
||||
document.getElementById('edit-rule-level').value = rule.level;
|
||||
document.getElementById('edit-rule-threshold').value = rule.threshold;
|
||||
document.getElementById('edit-rule-description').value = rule.description || '';
|
||||
document.getElementById('edit-rule-condition').value = rule.condition;
|
||||
document.getElementById('edit-rule-interval').value = rule.check_interval;
|
||||
document.getElementById('edit-rule-cooldown').value = rule.cooldown;
|
||||
document.getElementById('edit-rule-enabled').checked = rule.enabled;
|
||||
|
||||
// 显示编辑模态框
|
||||
const modal = new bootstrap.Modal(document.getElementById('editRuleModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async deleteRule(ruleName) {
|
||||
if (!confirm(`确定要删除规则 "${ruleName}" 吗?`)) return;
|
||||
|
||||
try {
|
||||
await apiService.deleteRule(ruleName);
|
||||
notificationManager.success('规则删除成功');
|
||||
await this.loadRules();
|
||||
} catch (error) {
|
||||
console.error('删除规则失败:', error);
|
||||
notificationManager.error('删除规则失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 通知管理组件
|
||||
* 统一处理应用内通知显示
|
||||
*/
|
||||
|
||||
class NotificationManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 创建通知容器
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'notification-container position-fixed';
|
||||
this.container.style.cssText = `
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
`;
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
// 监听状态变化
|
||||
store.subscribe((prevState, newState) => {
|
||||
if (prevState.notifications !== newState.notifications) {
|
||||
this.renderNotifications(newState.notifications);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderNotifications(notifications) {
|
||||
this.container.innerHTML = '';
|
||||
|
||||
notifications.forEach(notification => {
|
||||
const notificationEl = this.createNotificationElement(notification);
|
||||
this.container.appendChild(notificationEl);
|
||||
});
|
||||
}
|
||||
|
||||
createNotificationElement(notification) {
|
||||
const div = document.createElement('div');
|
||||
const typeClass = this.getTypeClass(notification.type);
|
||||
|
||||
div.className = `alert alert-${typeClass} alert-dismissible fade show shadow`;
|
||||
div.style.cssText = `
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="${this.getIconClass(notification.type)} me-2"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>${notification.title || this.getDefaultTitle(notification.type)}</strong>
|
||||
<div class="small mt-1">${notification.message}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 添加关闭事件
|
||||
const closeBtn = div.querySelector('.btn-close');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
store.removeNotification(notification.id);
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
getTypeClass(type) {
|
||||
const typeMap = {
|
||||
'success': 'success',
|
||||
'error': 'danger',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
return typeMap[type] || 'info';
|
||||
}
|
||||
|
||||
getIconClass(type) {
|
||||
const iconMap = {
|
||||
'success': 'fas fa-check-circle',
|
||||
'error': 'fas fa-exclamation-triangle',
|
||||
'warning': 'fas fa-exclamation-circle',
|
||||
'info': 'fas fa-info-circle'
|
||||
};
|
||||
return iconMap[type] || 'fas fa-info-circle';
|
||||
}
|
||||
|
||||
getDefaultTitle(type) {
|
||||
const titleMap = {
|
||||
'success': '成功',
|
||||
'error': '错误',
|
||||
'warning': '警告',
|
||||
'info': '提示'
|
||||
};
|
||||
return titleMap[type] || '通知';
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
success(message, title = null) {
|
||||
store.addNotification({
|
||||
type: 'success',
|
||||
message,
|
||||
title
|
||||
});
|
||||
}
|
||||
|
||||
error(message, title = null) {
|
||||
store.addNotification({
|
||||
type: 'error',
|
||||
message,
|
||||
title
|
||||
});
|
||||
}
|
||||
|
||||
warning(message, title = null) {
|
||||
store.addNotification({
|
||||
type: 'warning',
|
||||
message,
|
||||
title
|
||||
});
|
||||
}
|
||||
|
||||
info(message, title = null) {
|
||||
store.addNotification({
|
||||
type: 'info',
|
||||
message,
|
||||
title
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局通知管理器实例
|
||||
const notificationManager = new NotificationManager();
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* 模态框组件
|
||||
*/
|
||||
|
||||
import { addClass, removeClass, hasClass } from '../core/utils.js';
|
||||
|
||||
export class Modal {
|
||||
constructor(options = {}) {
|
||||
this.id = options.id || `modal-${Date.now()}`;
|
||||
this.title = options.title || '';
|
||||
this.content = options.content || '';
|
||||
this.size = options.size || ''; // sm, lg, xl
|
||||
this.backdrop = options.backdrop !== false;
|
||||
this.keyboard = options.keyboard !== false;
|
||||
this.centered = options.centered || false;
|
||||
this.scrollable = options.scrollable || false;
|
||||
this.static = options.static || false;
|
||||
this.className = options.className || '';
|
||||
this.footer = options.footer || null;
|
||||
this.show = false;
|
||||
|
||||
this.onShow = options.onShow || (() => {});
|
||||
this.onShown = options.onShown || (() => {});
|
||||
this.onHide = options.onHide || (() => {});
|
||||
this.onHidden = options.onHidden || (() => {});
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createModal();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
createModal() {
|
||||
// 创建模态框容器
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = 'modal fade';
|
||||
this.modal.id = this.id;
|
||||
this.modal.setAttribute('tabindex', '-1');
|
||||
this.modal.setAttribute('aria-labelledby', `${this.id}-label`);
|
||||
this.modal.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// 模态框对话框
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = `modal-dialog ${this.size ? `modal-${this.size}` : ''} ${this.centered ? 'modal-dialog-centered' : ''} ${this.scrollable ? 'modal-dialog-scrollable' : ''}`;
|
||||
|
||||
// 模态框内容
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
if (this.className) {
|
||||
addClass(content, this.className);
|
||||
}
|
||||
|
||||
// 构建模态框HTML
|
||||
let modalHTML = `
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="${this.id}-label">${this.title}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${this.content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 添加底部按钮
|
||||
if (this.footer) {
|
||||
modalHTML += `
|
||||
<div class="modal-footer">
|
||||
${typeof this.footer === 'string' ? this.footer : this.renderFooter()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = modalHTML;
|
||||
dialog.appendChild(content);
|
||||
this.modal.appendChild(dialog);
|
||||
|
||||
// 添加到页面
|
||||
document.body.appendChild(this.modal);
|
||||
}
|
||||
|
||||
renderFooter() {
|
||||
if (!this.footer) return '';
|
||||
|
||||
if (Array.isArray(this.footer)) {
|
||||
return this.footer.map(btn => {
|
||||
const attrs = Object.keys(btn)
|
||||
.filter(key => key !== 'text')
|
||||
.map(key => `${key}="${btn[key]}"`)
|
||||
.join(' ');
|
||||
return `<button ${attrs}>${btn.text}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 关闭按钮
|
||||
const closeBtn = this.modal.querySelector('.btn-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
}
|
||||
|
||||
// 背景点击
|
||||
if (!this.static) {
|
||||
this.modal.addEventListener('click', (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ESC键关闭
|
||||
if (this.keyboard) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.show) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.show) return;
|
||||
|
||||
// 触发显示前事件
|
||||
this.onShow();
|
||||
|
||||
// 添加到页面
|
||||
if (!this.modal.parentNode) {
|
||||
document.body.appendChild(this.modal);
|
||||
}
|
||||
|
||||
// 显示模态框
|
||||
this.modal.style.display = 'block';
|
||||
addClass(this.modal, 'show');
|
||||
this.modal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
// 防止背景滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
this.show = true;
|
||||
|
||||
// 聚焦到第一个可聚焦元素
|
||||
setTimeout(() => {
|
||||
const focusable = this.modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (focusable) {
|
||||
focusable.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 触发显示后事件
|
||||
setTimeout(() => {
|
||||
this.onShown();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.show) return;
|
||||
|
||||
// 触发隐藏前事件
|
||||
this.onHide();
|
||||
|
||||
// 隐藏模态框
|
||||
removeClass(this.modal, 'show');
|
||||
this.modal.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// 恢复背景滚动
|
||||
document.body.style.overflow = '';
|
||||
|
||||
this.show = false;
|
||||
|
||||
// 延迟移除DOM
|
||||
setTimeout(() => {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'none';
|
||||
if (this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
}
|
||||
|
||||
// 触发隐藏后事件
|
||||
this.onHidden();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.show) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
update(options) {
|
||||
if (options.title) {
|
||||
this.title = options.title;
|
||||
const titleEl = this.modal.querySelector('.modal-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = this.title;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.content) {
|
||||
this.content = options.content;
|
||||
const bodyEl = this.modal.querySelector('.modal-body');
|
||||
if (bodyEl) {
|
||||
bodyEl.innerHTML = this.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.footer !== undefined) {
|
||||
this.footer = options.footer;
|
||||
const footerEl = this.modal.querySelector('.modal-footer');
|
||||
if (footerEl) {
|
||||
if (this.footer) {
|
||||
footerEl.style.display = 'block';
|
||||
footerEl.innerHTML = typeof this.footer === 'string' ? this.footer : this.renderFooter();
|
||||
|
||||
// 重新绑定底部按钮事件
|
||||
this.bindFooterEvents();
|
||||
} else {
|
||||
footerEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindFooterEvents() {
|
||||
const footer = this.modal.querySelector('.modal-footer');
|
||||
if (!footer) return;
|
||||
|
||||
footer.querySelectorAll('button').forEach(btn => {
|
||||
const dataDismiss = btn.getAttribute('data-bs-dismiss');
|
||||
if (dataDismiss === 'modal') {
|
||||
btn.addEventListener('click', () => this.hide());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getModal() {
|
||||
return this.modal;
|
||||
}
|
||||
|
||||
getElement(selector) {
|
||||
return this.modal.querySelector(selector);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.hide();
|
||||
setTimeout(() => {
|
||||
if (this.modal && this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
this.modal = null;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// 确认对话框
|
||||
export function confirm(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new Modal({
|
||||
title: options.title || '确认',
|
||||
content: `
|
||||
<div class="text-center">
|
||||
<i class="fas fa-question-circle fa-3x text-warning mb-3"></i>
|
||||
<p>${options.message || '确定要执行此操作吗?'}</p>
|
||||
</div>
|
||||
`,
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
footer: [
|
||||
{ text: '取消', class: 'btn btn-secondary', 'data-bs-dismiss': 'modal' },
|
||||
{ text: '确定', class: 'btn btn-primary', id: 'confirm-btn' }
|
||||
]
|
||||
});
|
||||
|
||||
modal.onHidden = () => {
|
||||
modal.destroy();
|
||||
};
|
||||
|
||||
modal.getElement('#confirm-btn').addEventListener('click', () => {
|
||||
modal.hide();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
modal.onHidden = () => {
|
||||
resolve(false);
|
||||
modal.destroy();
|
||||
};
|
||||
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// 警告对话框
|
||||
export function alert(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new Modal({
|
||||
title: options.title || '提示',
|
||||
content: `
|
||||
<div class="text-center">
|
||||
<i class="fas fa-${options.type === 'error' ? 'exclamation-circle text-danger' : 'info-circle text-info'} fa-3x mb-3"></i>
|
||||
<p>${options.message || ''}</p>
|
||||
</div>
|
||||
`,
|
||||
size: 'sm',
|
||||
centered: true,
|
||||
footer: [
|
||||
{ text: '确定', class: 'btn btn-primary', id: 'alert-btn' }
|
||||
]
|
||||
});
|
||||
|
||||
modal.onHidden = () => {
|
||||
modal.destroy();
|
||||
resolve();
|
||||
};
|
||||
|
||||
modal.getElement('#alert-btn').addEventListener('click', () => {
|
||||
modal.hide();
|
||||
});
|
||||
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Toast通知
|
||||
export class Toast {
|
||||
constructor(options = {}) {
|
||||
this.id = `toast-${Date.now()}`;
|
||||
this.type = options.type || 'info';
|
||||
this.message = options.message || '';
|
||||
this.duration = options.duration || 3000;
|
||||
this.closable = options.closable !== false;
|
||||
this.autoHide = options.autoHide !== false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createToast();
|
||||
this.show();
|
||||
}
|
||||
|
||||
createToast() {
|
||||
// 查找或创建toast容器
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// 创建toast元素
|
||||
this.toast = document.createElement('div');
|
||||
this.toast.className = `toast ${this.type}`;
|
||||
this.toast.id = this.id;
|
||||
|
||||
const iconMap = {
|
||||
success: 'fa-check-circle',
|
||||
error: 'fa-exclamation-circle',
|
||||
warning: 'fa-exclamation-triangle',
|
||||
info: 'fa-info-circle'
|
||||
};
|
||||
|
||||
this.toast.innerHTML = `
|
||||
<div class="toast-content">
|
||||
<i class="fas ${iconMap[this.type] || iconMap.info} me-2"></i>
|
||||
<span>${this.message}</span>
|
||||
${this.closable ? '<button type="button" class="btn-close ms-2" aria-label="Close"></button>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(this.toast);
|
||||
|
||||
// 绑定关闭事件
|
||||
if (this.closable) {
|
||||
const closeBtn = this.toast.querySelector('.btn-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
}
|
||||
}
|
||||
|
||||
// 自动隐藏
|
||||
if (this.autoHide) {
|
||||
setTimeout(() => this.hide(), this.duration);
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
setTimeout(() => {
|
||||
addClass(this.toast, 'show');
|
||||
}, 10);
|
||||
}
|
||||
|
||||
hide() {
|
||||
removeClass(this.toast, 'show');
|
||||
setTimeout(() => {
|
||||
if (this.toast && this.toast.parentNode) {
|
||||
this.toast.parentNode.removeChild(this.toast);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局toast函数
|
||||
export function showToast(options) {
|
||||
if (typeof options === 'string') {
|
||||
options = { message: options };
|
||||
}
|
||||
return new Toast(options);
|
||||
}
|
||||
|
||||
// 导出
|
||||
export default Modal;
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* 导航栏组件
|
||||
*/
|
||||
|
||||
import { addClass, removeClass, hasClass, toggleClass } from '../core/utils.js';
|
||||
import store from '../core/store.js';
|
||||
import router from '../core/router.js';
|
||||
|
||||
export class Navbar {
|
||||
constructor(container) {
|
||||
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
||||
this.userMenuOpen = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<nav class="navbar">
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<button class="navbar-toggler" id="sidebar-toggle" type="button">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<div class="navbar-brand">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>TSP智能助手</span>
|
||||
</div>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<!-- 监控状态 -->
|
||||
<li class="nav-item">
|
||||
<span class="nav-link" id="monitor-status">
|
||||
<i class="fas fa-circle" id="status-indicator"></i>
|
||||
<span id="status-text">检查中...</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<!-- 通知 -->
|
||||
<li class="nav-item dropdown" id="notifications-dropdown">
|
||||
<a href="#" class="nav-link" data-toggle="dropdown">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="badge bg-danger" id="notification-count">0</span>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<div class="dropdown-header">
|
||||
<h6>通知</h6>
|
||||
<a href="#" class="btn btn-sm btn-link" id="clear-notifications">清空</a>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div id="notification-list" class="notification-list">
|
||||
<div class="dropdown-item text-muted">暂无通知</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<li class="nav-item dropdown user-menu">
|
||||
<a href="#" class="nav-link" id="user-menu-toggle">
|
||||
<div class="user-avatar" id="user-avatar">
|
||||
${this.getUserInitial()}
|
||||
</div>
|
||||
</a>
|
||||
<div class="user-dropdown" id="user-dropdown">
|
||||
<div class="dropdown-header">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="user-avatar me-2">
|
||||
${this.getUserInitial()}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold" id="user-name">${this.getUserName()}</div>
|
||||
<div class="small text-muted" id="user-role">${this.getUserRole()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a href="#" class="user-dropdown-item" data-route="/profile">
|
||||
<i class="fas fa-user me-2"></i>个人资料
|
||||
</a>
|
||||
<a href="#" class="user-dropdown-item" data-route="/settings">
|
||||
<i class="fas fa-cog me-2"></i>系统设置
|
||||
</a>
|
||||
<div class="user-dropdown-divider"></div>
|
||||
<a href="#" class="user-dropdown-item text-danger" id="logout-btn">
|
||||
<i class="fas fa-sign-out-alt me-2"></i>退出登录
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<li class="nav-item">
|
||||
<button class="nav-link btn btn-link" id="theme-toggle">
|
||||
<i class="fas fa-moon" id="theme-icon"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 侧边栏切换(移动端)
|
||||
const sidebarToggle = this.container.querySelector('#sidebar-toggle');
|
||||
if (sidebarToggle) {
|
||||
sidebarToggle.addEventListener('click', () => {
|
||||
this.toggleSidebar();
|
||||
});
|
||||
}
|
||||
|
||||
// 用户菜单切换
|
||||
const userMenuToggle = this.container.querySelector('#user-menu-toggle');
|
||||
if (userMenuToggle) {
|
||||
userMenuToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleUserMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// 通知下拉菜单
|
||||
const notificationsDropdown = this.container.querySelector('#notifications-dropdown');
|
||||
if (notificationsDropdown) {
|
||||
const toggle = notificationsDropdown.querySelector('[data-toggle="dropdown"]');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleNotifications();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 主题切换
|
||||
const themeToggle = this.container.querySelector('#theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
this.toggleTheme();
|
||||
});
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logoutBtn = this.container.querySelector('#logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleLogout();
|
||||
});
|
||||
}
|
||||
|
||||
// 清空通知
|
||||
const clearNotifications = this.container.querySelector('#clear-notifications');
|
||||
if (clearNotifications) {
|
||||
clearNotifications.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.clearNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
// 路由链接
|
||||
this.container.querySelectorAll('[data-route]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const route = e.currentTarget.getAttribute('data-route');
|
||||
if (route) {
|
||||
router.push(route);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.container.contains(e.target)) {
|
||||
this.closeUserMenu();
|
||||
this.closeNotifications();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听store变化
|
||||
store.subscribe((state) => {
|
||||
this.updateUser(state.user);
|
||||
this.updateNotifications(state.ui.notifications);
|
||||
this.updateMonitorStatus(state.monitor);
|
||||
});
|
||||
}
|
||||
|
||||
toggleUserMenu() {
|
||||
const dropdown = this.container.querySelector('#user-dropdown');
|
||||
if (dropdown) {
|
||||
this.userMenuOpen = !this.userMenuOpen;
|
||||
toggleClass(dropdown, 'show');
|
||||
}
|
||||
}
|
||||
|
||||
closeUserMenu() {
|
||||
const dropdown = this.container.querySelector('#user-dropdown');
|
||||
if (dropdown && hasClass(dropdown, 'show')) {
|
||||
this.userMenuOpen = false;
|
||||
removeClass(dropdown, 'show');
|
||||
}
|
||||
}
|
||||
|
||||
toggleNotifications() {
|
||||
const dropdown = this.container.querySelector('#notifications-dropdown .dropdown-menu');
|
||||
if (dropdown) {
|
||||
toggleClass(dropdown, 'show');
|
||||
}
|
||||
}
|
||||
|
||||
closeNotifications() {
|
||||
const dropdown = this.container.querySelector('#notifications-dropdown .dropdown-menu');
|
||||
if (dropdown && hasClass(dropdown, 'show')) {
|
||||
removeClass(dropdown, 'show');
|
||||
}
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const currentTheme = store.getState('app.theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
|
||||
store.commit('SET_THEME', newTheme);
|
||||
|
||||
const icon = this.container.querySelector('#theme-icon');
|
||||
if (icon) {
|
||||
icon.className = newTheme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
|
||||
}
|
||||
}
|
||||
|
||||
handleLogout() {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
// 调用注销API
|
||||
fetch('/api/logout', { method: 'POST' })
|
||||
.then(() => {
|
||||
// 清除应用状态
|
||||
store.commit('SET_USER', null);
|
||||
store.commit('SET_LOGIN', false);
|
||||
store.commit('SET_TOKEN', null);
|
||||
|
||||
// 清除本地存储和会话存储
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('remember');
|
||||
sessionStorage.removeItem('token');
|
||||
|
||||
// 显示提示
|
||||
if (window.showToast) {
|
||||
window.showToast('已退出登录', 'info');
|
||||
}
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/login');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('注销失败:', error);
|
||||
// 即使API调用失败,也要清除本地状态
|
||||
store.commit('SET_USER', null);
|
||||
store.commit('SET_LOGIN', false);
|
||||
store.commit('SET_TOKEN', null);
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
router.push('/login');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clearNotifications() {
|
||||
store.setState({
|
||||
ui: {
|
||||
...store.getState('ui'),
|
||||
notifications: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateNotifications(notifications) {
|
||||
const countEl = this.container.querySelector('#notification-count');
|
||||
const listEl = this.container.querySelector('#notification-list');
|
||||
|
||||
if (!countEl || !listEl) return;
|
||||
|
||||
const count = notifications.length;
|
||||
countEl.textContent = count;
|
||||
countEl.style.display = count > 0 ? 'inline-block' : 'none';
|
||||
|
||||
if (count === 0) {
|
||||
listEl.innerHTML = '<div class="dropdown-item text-muted">暂无通知</div>';
|
||||
} else {
|
||||
listEl.innerHTML = notifications.slice(0, 5).map(notification => `
|
||||
<a href="#" class="dropdown-item ${notification.read ? '' : 'unread'}">
|
||||
<div class="d-flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas ${this.getNotificationIcon(notification.type)} text-${notification.type}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-2">
|
||||
<div class="small">${notification.message}</div>
|
||||
<div class="text-muted small">${this.formatTime(notification.time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
getNotificationIcon(type) {
|
||||
const icons = {
|
||||
success: 'fa-check-circle',
|
||||
error: 'fa-exclamation-circle',
|
||||
warning: 'fa-exclamation-triangle',
|
||||
info: 'fa-info-circle'
|
||||
};
|
||||
return icons[type] || 'fa-bell';
|
||||
}
|
||||
|
||||
updateMonitorStatus(monitor) {
|
||||
const indicator = this.container.querySelector('#status-indicator');
|
||||
const text = this.container.querySelector('#status-text');
|
||||
|
||||
if (!indicator || !text) return;
|
||||
|
||||
if (monitor.status === 'running') {
|
||||
indicator.className = 'fas fa-circle text-success';
|
||||
text.textContent = '监控运行中';
|
||||
} else {
|
||||
indicator.className = 'fas fa-circle text-warning';
|
||||
text.textContent = '监控已停止';
|
||||
}
|
||||
}
|
||||
|
||||
updateUser(user) {
|
||||
const avatar = this.container.querySelector('#user-avatar');
|
||||
const name = this.container.querySelector('#user-name');
|
||||
const role = this.container.querySelector('#user-role');
|
||||
|
||||
if (user && user.info) {
|
||||
const initial = this.getInitial(user.info.name);
|
||||
if (avatar) avatar.textContent = initial;
|
||||
if (name) name.textContent = user.info.name;
|
||||
if (role) role.textContent = user.info.role || '用户';
|
||||
} else {
|
||||
if (avatar) avatar.textContent = 'U';
|
||||
if (name) name.textContent = '未登录';
|
||||
if (role) role.textContent = '访客';
|
||||
}
|
||||
}
|
||||
|
||||
getUserInitial() {
|
||||
const user = store.getState('user.info');
|
||||
return user ? this.getInitial(user.name) : 'U';
|
||||
}
|
||||
|
||||
getUserName() {
|
||||
const user = store.getState('user.info');
|
||||
return user ? user.name : '未登录';
|
||||
}
|
||||
|
||||
getUserRole() {
|
||||
const user = store.getState('user.info');
|
||||
return user ? (user.role || '用户') : '访客';
|
||||
}
|
||||
|
||||
getInitial(name) {
|
||||
if (!name) return 'U';
|
||||
const chars = name.trim().split(/\s+/);
|
||||
if (chars.length >= 2) {
|
||||
return chars[0][0] + chars[chars.length - 1][0];
|
||||
}
|
||||
return name[0].toUpperCase();
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const overlay = document.querySelector('.sidebar-overlay') || this.createOverlay();
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
// 移动端:切换显示
|
||||
toggleClass(sidebar, 'open');
|
||||
toggleClass(overlay, 'show');
|
||||
} else {
|
||||
// 桌面端:切换折叠
|
||||
toggleClass(sidebar, 'collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'sidebar-overlay';
|
||||
overlay.addEventListener('click', () => {
|
||||
this.toggleSidebar();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
formatTime(time) {
|
||||
const date = new Date(time);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
if (diff < 60000) {
|
||||
return '刚刚';
|
||||
} else if (diff < 3600000) {
|
||||
return `${Math.floor(diff / 60000)}分钟前`;
|
||||
} else if (diff < 86400000) {
|
||||
return `${Math.floor(diff / 3600000)}小时前`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出组件
|
||||
export default Navbar;
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* 侧边栏组件
|
||||
*/
|
||||
|
||||
import { addClass, removeClass, hasClass, toggleClass } from '../core/utils.js';
|
||||
import router from '../core/router.js';
|
||||
|
||||
export class Sidebar {
|
||||
constructor(container) {
|
||||
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
||||
this.collapsed = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="sidebar" id="sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<a href="/" class="sidebar-logo">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>TSP助手</span>
|
||||
</a>
|
||||
<button class="btn btn-link sidebar-toggle" id="sidebar-toggle">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
${this.renderMenuItems()}
|
||||
</nav>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 初始化折叠状态
|
||||
const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
if (isCollapsed) {
|
||||
this.collapsed = true;
|
||||
addClass(this.container.querySelector('#sidebar'), 'collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
renderMenuItems() {
|
||||
const menuItems = [
|
||||
{
|
||||
path: '/',
|
||||
icon: 'fas fa-tachometer-alt',
|
||||
title: '仪表板',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/workorders',
|
||||
icon: 'fas fa-tasks',
|
||||
title: '工单管理',
|
||||
badge: 'workorders'
|
||||
},
|
||||
{
|
||||
path: '/alerts',
|
||||
icon: 'fas fa-bell',
|
||||
title: '预警管理',
|
||||
badge: 'alerts'
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
icon: 'fas fa-book',
|
||||
title: '知识库',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
icon: 'fas fa-comments',
|
||||
title: '智能对话',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/chat-http',
|
||||
icon: 'fas fa-comment-dots',
|
||||
title: 'HTTP对话',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/monitoring',
|
||||
icon: 'fas fa-chart-line',
|
||||
title: '系统监控',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/feishu',
|
||||
icon: 'fab fa-lark',
|
||||
title: '飞书同步',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/agent',
|
||||
icon: 'fas fa-robot',
|
||||
title: '智能Agent',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/vehicle',
|
||||
icon: 'fas fa-car',
|
||||
title: '车辆数据',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
icon: 'fas fa-cog',
|
||||
title: '系统设置',
|
||||
badge: null
|
||||
}
|
||||
];
|
||||
|
||||
return menuItems.map(item => `
|
||||
<a href="${item.path}" class="sidebar-nav-item" data-route="${item.path}">
|
||||
<i class="${item.icon}"></i>
|
||||
<span>${item.title}</span>
|
||||
${item.badge ? `<span class="badge bg-danger ms-auto" id="sidebar-badge-${item.badge}">0</span>` : ''}
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 折叠切换
|
||||
const toggleBtn = this.container.querySelector('#sidebar-toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
// 菜单项点击
|
||||
this.container.querySelectorAll('.sidebar-nav-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const route = e.currentTarget.getAttribute('data-route');
|
||||
if (route) {
|
||||
router.push(route);
|
||||
}
|
||||
|
||||
// 移动端点击后自动收起侧边栏
|
||||
if (window.innerWidth < 992 && !this.collapsed) {
|
||||
this.toggle();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听路由变化
|
||||
router.afterEach((to) => {
|
||||
this.updateActiveMenu(to.path);
|
||||
});
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', () => {
|
||||
this.handleResize();
|
||||
});
|
||||
|
||||
// 初始化激活状态
|
||||
this.updateActiveMenu(window.location.pathname);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const sidebar = this.container.querySelector('#sidebar');
|
||||
if (sidebar) {
|
||||
this.collapsed = !this.collapsed;
|
||||
toggleClass(sidebar, 'collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', this.collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
expand() {
|
||||
const sidebar = this.container.querySelector('#sidebar');
|
||||
if (sidebar && hasClass(sidebar, 'collapsed')) {
|
||||
this.collapsed = false;
|
||||
removeClass(sidebar, 'collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
collapse() {
|
||||
const sidebar = this.container.querySelector('#sidebar');
|
||||
if (sidebar && !hasClass(sidebar, 'collapsed')) {
|
||||
this.collapsed = true;
|
||||
addClass(sidebar, 'collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveMenu(path) {
|
||||
this.container.querySelectorAll('.sidebar-nav-item').forEach(item => {
|
||||
const route = item.getAttribute('data-route');
|
||||
if (route === path) {
|
||||
addClass(item, 'active');
|
||||
} else {
|
||||
removeClass(item, 'active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateBadge(type, count) {
|
||||
const badge = this.container.querySelector(`#sidebar-badge-${type}`);
|
||||
if (badge) {
|
||||
badge.textContent = count;
|
||||
badge.style.display = count > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (window.innerWidth >= 992) {
|
||||
// 桌面端,恢复之前的折叠状态
|
||||
const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
if (isCollapsed !== this.collapsed) {
|
||||
if (isCollapsed) {
|
||||
this.collapse();
|
||||
} else {
|
||||
this.expand();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 移动端,默认收起
|
||||
if (!this.collapsed) {
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出组件
|
||||
export default Sidebar;
|
||||
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* API统一管理模块
|
||||
*/
|
||||
|
||||
import { defaultConfig, debounce, storage, handleError } from './utils.js';
|
||||
|
||||
// API配置
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
timeout: 10000, // 请求超时时间
|
||||
retryTimes: 3, // 重试次数
|
||||
retryDelay: 1000 // 重试延迟
|
||||
};
|
||||
|
||||
// 请求拦截器
|
||||
const requestInterceptors = [];
|
||||
const responseInterceptors = [];
|
||||
|
||||
// 添加请求拦截器
|
||||
export function addRequestInterceptor(interceptor) {
|
||||
requestInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
export function addResponseInterceptor(interceptor) {
|
||||
responseInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
// 默认请求拦截器
|
||||
addRequestInterceptor(async (options) => {
|
||||
// 添加认证头
|
||||
const token = storage.get('authToken');
|
||||
if (token) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
// 添加用户信息头
|
||||
const userInfo = storage.get('userInfo');
|
||||
if (userInfo) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'X-User-Name': userInfo.name || '',
|
||||
'X-User-Role': userInfo.role || ''
|
||||
};
|
||||
}
|
||||
|
||||
// 添加请求ID
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'X-Request-ID': generateRequestId()
|
||||
};
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
// 默认响应拦截器
|
||||
addResponseInterceptor(async (response) => {
|
||||
// 处理通用错误
|
||||
if (response.status === 401) {
|
||||
// 未授权,清除本地存储并跳转到登录页
|
||||
storage.remove('authToken');
|
||||
storage.remove('userInfo');
|
||||
window.location.href = '/login';
|
||||
throw new Error('未授权,请重新登录');
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
throw new Error('服务器错误,请稍后重试');
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
// 生成请求ID
|
||||
function generateRequestId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
// 基础请求函数
|
||||
async function request(url, options = {}) {
|
||||
// 合并配置
|
||||
const finalOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
// 执行请求拦截器
|
||||
for (const interceptor of requestInterceptors) {
|
||||
Object.assign(finalOptions, await interceptor(finalOptions));
|
||||
}
|
||||
|
||||
// 构建完整URL
|
||||
const fullUrl = url.startsWith('http') ? url : `${config.apiBaseUrl}${url}`;
|
||||
|
||||
// 创建AbortController用于超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||
finalOptions.signal = controller.signal;
|
||||
|
||||
try {
|
||||
let lastError;
|
||||
|
||||
// 重试机制
|
||||
for (let i = 0; i <= config.retryTimes; i++) {
|
||||
try {
|
||||
const response = await fetch(fullUrl, finalOptions);
|
||||
|
||||
// 执行响应拦截器
|
||||
let processedResponse = response;
|
||||
for (const interceptor of responseInterceptors) {
|
||||
processedResponse = await interceptor(processedResponse);
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
const data = await parseResponse(processedResponse);
|
||||
|
||||
// 如果响应表示失败,抛出错误
|
||||
if (data.code && data.code !== 200) {
|
||||
throw new Error(data.message || '请求失败');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// 如果是网络错误或超时,且还有重试次数,则延迟后重试
|
||||
if (i < config.retryTimes && (error.name === 'TypeError' || error.name === 'AbortError')) {
|
||||
await new Promise(resolve => setTimeout(resolve, config.retryDelay * Math.pow(2, i)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其他错误直接抛出
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
} catch (error) {
|
||||
handleError(error, `API Request: ${finalOptions.method} ${url}`);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
async function parseResponse(response) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else if (contentType && contentType.includes('text/')) {
|
||||
return {
|
||||
code: response.ok ? 200 : response.status,
|
||||
data: await response.text(),
|
||||
message: response.statusText
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
code: response.ok ? 200 : response.status,
|
||||
data: await response.blob(),
|
||||
message: response.statusText
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP方法封装
|
||||
export const http = {
|
||||
get(url, params = {}, options = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
return request(fullUrl, { ...options, method: 'GET' });
|
||||
},
|
||||
|
||||
post(url, data = {}, options = {}) {
|
||||
return request(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
},
|
||||
|
||||
put(url, data = {}, options = {}) {
|
||||
return request(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
},
|
||||
|
||||
patch(url, data = {}, options = {}) {
|
||||
return request(url, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
},
|
||||
|
||||
delete(url, options = {}) {
|
||||
return request(url, { ...options, method: 'DELETE' });
|
||||
},
|
||||
|
||||
upload(url, formData, options = {}) {
|
||||
return request(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// 不要设置Content-Type,让浏览器自动设置multipart/form-data
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
},
|
||||
|
||||
download(url, params = {}, options = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
|
||||
return request(fullUrl, {
|
||||
...options,
|
||||
method: 'GET'
|
||||
}).then(response => {
|
||||
// 创建下载链接
|
||||
const blob = response.data;
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
|
||||
// 从响应头获取文件名
|
||||
const contentDisposition = response.headers?.get('content-disposition');
|
||||
if (contentDisposition) {
|
||||
const filename = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
link.download = filename ? filename[1] : 'download';
|
||||
}
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API接口定义
|
||||
export const api = {
|
||||
// 系统相关
|
||||
system: {
|
||||
health: () => http.get('/health'),
|
||||
info: () => http.get('/system/info'),
|
||||
settings: () => http.get('/settings'),
|
||||
saveSettings: (data) => http.post('/settings', data)
|
||||
},
|
||||
|
||||
// 工单管理
|
||||
workorders: {
|
||||
list: (params) => http.get('/workorders', params),
|
||||
create: (data) => http.post('/workorders', data),
|
||||
get: (id) => http.get(`/workorders/${id}`),
|
||||
update: (id, data) => http.put(`/workorders/${id}`, data),
|
||||
delete: (id) => http.delete(`/workorders/${id}`),
|
||||
dispatch: (id, module) => http.post(`/workorders/${id}/dispatch`, { target_module: module }),
|
||||
suggestModule: (id) => http.post(`/workorders/${id}/suggest-module`),
|
||||
aiSuggestion: (id) => http.post(`/workorders/${id}/ai-suggestion`),
|
||||
humanResolution: (id, data) => http.post(`/workorders/${id}/human-resolution`, data),
|
||||
approveToKnowledge: (id, data) => http.post(`/workorders/${id}/approve-to-knowledge`, data),
|
||||
processHistory: (id) => http.get(`/workorders/${id}/process-history`),
|
||||
addProcessHistory: (id, data) => http.post(`/workorders/${id}/process-history`, data),
|
||||
import: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return http.upload('/workorders/import', formData);
|
||||
},
|
||||
export: (params) => http.download('/workorders/export', params),
|
||||
getTemplate: () => http.get('/workorders/import/template'),
|
||||
downloadTemplate: () => http.download('/workorders/import/template/file'),
|
||||
modules: () => http.get('/workorders/modules'),
|
||||
byStatus: (status) => http.get(`/workorders/by-status/${status}`),
|
||||
batchDelete: (ids) => http.post('/batch-delete/workorders', { ids })
|
||||
},
|
||||
|
||||
// 对话管理
|
||||
conversations: {
|
||||
list: (params) => http.get('/conversations', params),
|
||||
get: (id) => http.get(`/conversations/${id}`),
|
||||
delete: (id) => http.delete(`/conversations/${id}`),
|
||||
clear: () => http.delete('/conversations/clear'),
|
||||
search: (params) => http.get('/conversations/search', params),
|
||||
analytics: () => http.get('/conversations/analytics'),
|
||||
migrateMerge: (data) => http.post('/conversations/migrate-merge', data),
|
||||
timeline: (workOrderId, params) => http.get(`/conversations/workorder/${workOrderId}/timeline`, params),
|
||||
context: (workOrderId) => http.get(`/conversations/workorder/${workOrderId}/context`),
|
||||
summary: (workOrderId) => http.get(`/conversations/workorder/${workOrderId}/summary`)
|
||||
},
|
||||
|
||||
// 聊天接口
|
||||
chat: {
|
||||
createSession: (data) => http.post('/chat/session', data),
|
||||
sendMessage: (data) => http.post('/chat/message', data),
|
||||
getHistory: (sessionId) => http.get(`/chat/history/${sessionId}`),
|
||||
createWorkOrder: (data) => http.post('/chat/work-order', data),
|
||||
getWorkOrderStatus: (workOrderId) => http.get(`/chat/work-order/${workOrderId}`),
|
||||
endSession: (sessionId) => http.delete(`/chat/session/${sessionId}`),
|
||||
sessions: () => http.get('/chat/sessions')
|
||||
},
|
||||
|
||||
// 知识库
|
||||
knowledge: {
|
||||
list: (params) => http.get('/knowledge', params),
|
||||
search: (params) => http.get('/knowledge/search', params),
|
||||
create: (data) => http.post('/knowledge', data),
|
||||
get: (id) => http.get(`/knowledge/${id}`),
|
||||
update: (id, data) => http.put(`/knowledge/${id}`, data),
|
||||
delete: (id) => http.delete(`/knowledge/delete/${id}`),
|
||||
verify: (id) => http.post(`/knowledge/verify/${id}`),
|
||||
unverify: (id) => http.post(`/knowledge/unverify/${id}`),
|
||||
stats: () => http.get('/knowledge/stats'),
|
||||
upload: (file, data) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
Object.keys(data).forEach(key => {
|
||||
formData.append(key, data[key]);
|
||||
});
|
||||
return http.upload('/knowledge/upload', formData);
|
||||
},
|
||||
byStatus: (status) => http.get(`/knowledge/by-status/${status}`),
|
||||
batchDelete: (ids) => http.post('/batch-delete/knowledge', { ids })
|
||||
},
|
||||
|
||||
// 预警管理
|
||||
alerts: {
|
||||
list: (params) => http.get('/alerts', params),
|
||||
create: (data) => http.post('/alerts', data),
|
||||
get: (id) => http.get(`/alerts/${id}`),
|
||||
update: (id, data) => http.put(`/alerts/${id}`, data),
|
||||
delete: (id) => http.delete(`/alerts/${id}`),
|
||||
resolve: (id, data) => http.post(`/alerts/${id}/resolve`, data),
|
||||
statistics: () => http.get('/alerts/statistics'),
|
||||
byLevel: (level) => http.get(`/alerts/by-level/${level}`),
|
||||
batchDelete: (ids) => http.post('/batch-delete/alerts', { ids })
|
||||
},
|
||||
|
||||
// 预警规则
|
||||
rules: {
|
||||
list: () => http.get('/rules'),
|
||||
create: (data) => http.post('/rules', data),
|
||||
update: (name, data) => http.put(`/rules/${name}`, data),
|
||||
delete: (name) => http.delete(`/rules/${name}`)
|
||||
},
|
||||
|
||||
// 监控管理
|
||||
monitor: {
|
||||
start: () => http.post('/monitor/start'),
|
||||
stop: () => http.post('/monitor/stop'),
|
||||
status: () => http.get('/monitor/status'),
|
||||
checkAlerts: () => http.post('/check-alerts'),
|
||||
analytics: (params) => http.get('/analytics', params)
|
||||
},
|
||||
|
||||
// Token监控
|
||||
tokenMonitor: {
|
||||
stats: () => http.get('/token-monitor/stats'),
|
||||
chart: (params) => http.get('/token-monitor/chart', params),
|
||||
records: (params) => http.get('/token-monitor/records', params),
|
||||
settings: (data) => http.post('/token-monitor/settings', data),
|
||||
export: (params) => http.download('/token-monitor/export', params)
|
||||
},
|
||||
|
||||
// AI监控
|
||||
aiMonitor: {
|
||||
stats: () => http.get('/ai-monitor/stats'),
|
||||
modelComparison: () => http.get('/ai-monitor/model-comparison'),
|
||||
errorDistribution: () => http.get('/ai-monitor/error-distribution'),
|
||||
errorLog: (params) => http.get('/ai-monitor/error-log', params),
|
||||
clearErrorLog: () => http.delete('/ai-monitor/error-log')
|
||||
},
|
||||
|
||||
// Agent相关
|
||||
agent: {
|
||||
status: () => http.get('/agent/status'),
|
||||
toggle: () => http.post('/agent/toggle'),
|
||||
chat: (data) => http.post('/agent/chat', data),
|
||||
actionHistory: () => http.get('/agent/action-history'),
|
||||
clearHistory: () => http.post('/agent/clear-history'),
|
||||
tools: {
|
||||
stats: () => http.get('/agent/tools/stats'),
|
||||
execute: (data) => http.post('/agent/tools/execute', data),
|
||||
register: (data) => http.post('/agent/tools/register', data),
|
||||
unregister: (name) => http.delete(`/agent/tools/unregister/${name}`)
|
||||
},
|
||||
monitoring: {
|
||||
start: () => http.post('/agent/monitoring/start'),
|
||||
stop: () => http.post('/agent/monitoring/stop'),
|
||||
proactiveCheck: () => http.post('/agent/proactive-monitoring'),
|
||||
intelligentAnalysis: () => http.post('/agent/intelligent-analysis')
|
||||
},
|
||||
llmStats: () => http.get('/agent/llm-stats'),
|
||||
triggerSample: () => http.post('/agent/trigger-sample')
|
||||
},
|
||||
|
||||
// 车辆数据
|
||||
vehicle: {
|
||||
data: (params) => http.get('/vehicle/data', params),
|
||||
latestById: (id) => http.get(`/vehicle/data/${id}/latest`),
|
||||
latestByVin: (vin) => http.get(`/vehicle/data/vin/${vin}/latest`),
|
||||
summary: (id) => http.get(`/vehicle/data/${id}/summary`),
|
||||
add: (data) => http.post('/vehicle/data', data),
|
||||
initSample: () => http.post('/vehicle/init-sample-data')
|
||||
},
|
||||
|
||||
// 飞书同步
|
||||
feishu: {
|
||||
config: {
|
||||
get: () => http.get('/feishu-sync/config'),
|
||||
save: (data) => http.post('/feishu-sync/config', data)
|
||||
},
|
||||
testConnection: () => http.get('/feishu-sync/test-connection'),
|
||||
checkPermissions: () => http.get('/feishu-sync/check-permissions'),
|
||||
syncFromFeishu: (data) => http.post('/feishu-sync/sync-from-feishu', data),
|
||||
syncToFeishu: (workOrderId) => http.post(`/feishu-sync/sync-to-feishu/${workOrderId}`),
|
||||
status: () => http.get('/feishu-sync/status'),
|
||||
createWorkorder: (data) => http.post('/feishu-sync/create-workorder', data),
|
||||
fieldMapping: {
|
||||
status: () => http.get('/feishu-sync/field-mapping/status'),
|
||||
discover: () => http.post('/feishu-sync/field-mapping/discover'),
|
||||
add: (data) => http.post('/feishu-sync/field-mapping/add', data),
|
||||
remove: (data) => http.post('/feishu-sync/field-mapping/remove', data)
|
||||
},
|
||||
previewData: (params) => http.get('/feishu-sync/preview-feishu-data', params),
|
||||
config: {
|
||||
export: () => http.download('/feishu-sync/config/export'),
|
||||
import: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return http.upload('/feishu-sync/config/import', formData);
|
||||
},
|
||||
reset: () => http.post('/feishu-sync/config/reset')
|
||||
}
|
||||
},
|
||||
|
||||
// 系统优化
|
||||
systemOptimizer: {
|
||||
status: () => http.get('/system-optimizer/status'),
|
||||
optimizeCpu: () => http.post('/system-optimizer/optimize-cpu'),
|
||||
optimizeMemory: () => http.post('/system-optimizer/optimize-memory'),
|
||||
optimizeDisk: () => http.post('/system-optimizer/optimize-disk'),
|
||||
clearCache: () => http.post('/system-optimizer/clear-cache'),
|
||||
optimizeAll: () => http.post('/system-optimizer/optimize-all'),
|
||||
securitySettings: (data) => http.post('/system-optimizer/security-settings', data),
|
||||
trafficSettings: (data) => http.post('/system-optimizer/traffic-settings', data),
|
||||
costSettings: (data) => http.post('/system-optimizer/cost-settings', data),
|
||||
healthCheck: () => http.post('/system-optimizer/health-check')
|
||||
},
|
||||
|
||||
// 数据库备份
|
||||
backup: {
|
||||
info: () => http.get('/backup/info'),
|
||||
create: (data) => http.post('/backup/create', data),
|
||||
restore: (data) => http.post('/backup/restore', data)
|
||||
}
|
||||
};
|
||||
|
||||
// 导出默认配置和请求方法
|
||||
export { config };
|
||||
export default { http, api, config };
|
||||
@@ -1,466 +0,0 @@
|
||||
/**
|
||||
* 路由管理模块
|
||||
*/
|
||||
|
||||
import { parseQueryString, serializeQueryString } from './utils.js';
|
||||
import store from './store.js';
|
||||
|
||||
// 路由配置
|
||||
class Router {
|
||||
constructor() {
|
||||
this.routes = new Map();
|
||||
this.currentRoute = null;
|
||||
this.beforeEachHooks = [];
|
||||
this.afterEachHooks = [];
|
||||
this.mode = 'history'; // 'history' 或 'hash'
|
||||
this.base = '/';
|
||||
this.fallback = true;
|
||||
|
||||
// 绑定事件处理器
|
||||
this.handlePopState = this.handlePopState.bind(this);
|
||||
this.handleHashChange = this.handleHashChange.bind(this);
|
||||
}
|
||||
|
||||
// 配置路由
|
||||
config(options = {}) {
|
||||
if (options.mode) this.mode = options.mode;
|
||||
if (options.base) this.base = options.base;
|
||||
if (options.fallback !== undefined) this.fallback = options.fallback;
|
||||
return this;
|
||||
}
|
||||
|
||||
// 添加路由
|
||||
addRoute(path, component, options = {}) {
|
||||
const route = {
|
||||
path,
|
||||
component,
|
||||
name: options.name || path,
|
||||
meta: options.meta || {},
|
||||
props: options.props || false,
|
||||
children: options.children || [],
|
||||
beforeEnter: options.beforeEnter
|
||||
};
|
||||
|
||||
// 转换路径为正则表达式
|
||||
route.regex = this.pathToRegex(path);
|
||||
route.keys = [];
|
||||
|
||||
// 提取动态参数
|
||||
const paramNames = path.match(/:\w+/g);
|
||||
if (paramNames) {
|
||||
route.keys = paramNames.map(name => name.slice(1));
|
||||
}
|
||||
|
||||
this.routes.set(path, route);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 批量添加路由
|
||||
addRoutes(routes) {
|
||||
routes.forEach(route => {
|
||||
this.addRoute(route.path, route.component, route);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
// 路径转正则
|
||||
pathToRegex(path) {
|
||||
const regexPath = path
|
||||
.replace(/\//g, '\\/')
|
||||
.replace(/:\w+/g, '([^\\/]+)')
|
||||
.replace(/\*/g, '(.*)');
|
||||
return new RegExp(`^${regexPath}$`);
|
||||
}
|
||||
|
||||
// 匹配路由
|
||||
match(path) {
|
||||
for (const [routePath, route] of this.routes) {
|
||||
const match = path.match(route.regex);
|
||||
if (match) {
|
||||
const params = {};
|
||||
route.keys.forEach((key, index) => {
|
||||
params[key] = match[index + 1];
|
||||
});
|
||||
|
||||
return {
|
||||
route,
|
||||
params,
|
||||
path,
|
||||
query: this.parseQuery(path)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 404处理
|
||||
return {
|
||||
route: { path: '/404', component: 'notfound' },
|
||||
params: {},
|
||||
path,
|
||||
query: {}
|
||||
};
|
||||
}
|
||||
|
||||
// 解析查询字符串
|
||||
parseQuery(path) {
|
||||
const queryIndex = path.indexOf('?');
|
||||
if (queryIndex === -1) return {};
|
||||
|
||||
const queryString = path.slice(queryIndex + 1);
|
||||
return parseQueryString(queryString);
|
||||
}
|
||||
|
||||
// 构建路径
|
||||
buildPath(route, params = {}, query = {}) {
|
||||
let path = route.path;
|
||||
|
||||
// 替换动态参数
|
||||
Object.keys(params).forEach(key => {
|
||||
path = path.replace(`:${key}`, params[key]);
|
||||
});
|
||||
|
||||
// 添加查询字符串
|
||||
const queryString = serializeQueryString(query);
|
||||
if (queryString) {
|
||||
path += `?${queryString}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// 导航到指定路径
|
||||
push(path, data = {}) {
|
||||
return this.navigateTo(path, 'push', data);
|
||||
}
|
||||
|
||||
// 替换当前路径
|
||||
replace(path, data = {}) {
|
||||
return this.navigateTo(path, 'replace', data);
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
go(n) {
|
||||
window.history.go(n);
|
||||
}
|
||||
|
||||
// 返回
|
||||
back() {
|
||||
this.go(-1);
|
||||
}
|
||||
|
||||
// 前进
|
||||
forward() {
|
||||
this.go(1);
|
||||
}
|
||||
|
||||
// 执行导航
|
||||
async navigateTo(path, type = 'push', data = {}) {
|
||||
// 匹配路由
|
||||
const matched = this.match(path);
|
||||
|
||||
// 创建路由对象
|
||||
const route = {
|
||||
path: matched.path,
|
||||
name: matched.route.name,
|
||||
params: matched.params,
|
||||
query: matched.query,
|
||||
meta: matched.route.meta,
|
||||
hash: this.parseHash(path),
|
||||
...data
|
||||
};
|
||||
|
||||
// 执行前置守卫
|
||||
const guards = [...this.beforeEachHooks, matched.route.beforeEnter].filter(Boolean);
|
||||
for (const guard of guards) {
|
||||
const result = await guard(route, this.currentRoute);
|
||||
if (result === false) {
|
||||
return Promise.reject(new Error('Navigation cancelled'));
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return this.navigateTo(result, type, data);
|
||||
}
|
||||
if (result && typeof result === 'object') {
|
||||
return this.navigateTo(result.path || result, type, result);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前路由
|
||||
const prevRoute = this.currentRoute;
|
||||
this.currentRoute = route;
|
||||
|
||||
// 更新URL
|
||||
this.updateURL(path, type);
|
||||
|
||||
// 执行后置守卫
|
||||
this.afterEachHooks.forEach(hook => {
|
||||
try {
|
||||
hook(route, prevRoute);
|
||||
} catch (error) {
|
||||
console.error('After each hook error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新store
|
||||
store.commit('SET_CURRENT_ROUTE', route);
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
// 更新URL
|
||||
updateURL(path, type) {
|
||||
if (this.mode === 'history') {
|
||||
const url = this.base === '/' ? path : `${this.base}${path}`.replace('//', '/');
|
||||
if (type === 'replace') {
|
||||
window.history.replaceState({ path }, '', url);
|
||||
} else {
|
||||
window.history.pushState({ path }, '', url);
|
||||
}
|
||||
} else {
|
||||
const hash = this.mode === 'hash' ? `#${path}` : `#${this.base}${path}`.replace('//', '/');
|
||||
if (type === 'replace') {
|
||||
window.location.replace(hash);
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析hash
|
||||
parseHash(path) {
|
||||
const hashIndex = path.indexOf('#');
|
||||
return hashIndex === -1 ? '' : path.slice(hashIndex + 1);
|
||||
}
|
||||
|
||||
// 处理popstate事件
|
||||
handlePopState(event) {
|
||||
if (event.state && event.state.path) {
|
||||
this.navigateTo(event.state.path, 'push', { replace: true });
|
||||
} else {
|
||||
const path = this.getCurrentPath();
|
||||
this.navigateTo(path, 'push', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 处理hashchange事件
|
||||
handleHashChange() {
|
||||
const path = this.getCurrentPath();
|
||||
this.navigateTo(path, 'push', { replace: true });
|
||||
}
|
||||
|
||||
// 获取当前路径
|
||||
getCurrentPath() {
|
||||
if (this.mode === 'history') {
|
||||
const path = window.location.pathname;
|
||||
return path.startsWith(this.base) ? path.slice(this.base.length) : path;
|
||||
} else {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return hash.startsWith(this.base) ? hash.slice(this.base.length) : hash;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局前置守卫
|
||||
beforeEach(hook) {
|
||||
this.beforeEachHooks.push(hook);
|
||||
}
|
||||
|
||||
// 全局后置守卫
|
||||
afterEach(hook) {
|
||||
this.afterEachHooks.push(hook);
|
||||
}
|
||||
|
||||
// 启动路由
|
||||
start() {
|
||||
// 监听事件
|
||||
if (this.mode === 'history') {
|
||||
window.addEventListener('popstate', this.handlePopState);
|
||||
} else {
|
||||
window.addEventListener('hashchange', this.handleHashChange);
|
||||
}
|
||||
|
||||
// 处理初始路由
|
||||
const path = this.getCurrentPath();
|
||||
this.navigateTo(path, 'push', { replace: true });
|
||||
|
||||
// 拦截链接点击
|
||||
this.interceptLinks();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// 拦截链接
|
||||
interceptLinks() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.push(href);
|
||||
});
|
||||
}
|
||||
|
||||
// 停止路由
|
||||
stop() {
|
||||
window.removeEventListener('popstate', this.handlePopState);
|
||||
window.removeEventListener('hashchange', this.handleHashChange);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建路由实例
|
||||
export const router = new Router();
|
||||
|
||||
// 路由配置
|
||||
router.config({
|
||||
mode: 'history',
|
||||
base: '/',
|
||||
fallback: true
|
||||
});
|
||||
|
||||
// 添加路由
|
||||
router.addRoutes([
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: 'Dashboard',
|
||||
meta: { title: '仪表板', icon: 'fas fa-tachometer-alt' }
|
||||
},
|
||||
{
|
||||
path: '/workorders',
|
||||
name: 'workorders',
|
||||
component: 'WorkOrders',
|
||||
meta: { title: '工单管理', icon: 'fas fa-tasks' }
|
||||
},
|
||||
{
|
||||
path: '/workorders/:id',
|
||||
name: 'workorder-detail',
|
||||
component: 'WorkOrderDetail',
|
||||
meta: { title: '工单详情' }
|
||||
},
|
||||
{
|
||||
path: '/alerts',
|
||||
name: 'alerts',
|
||||
component: 'Alerts',
|
||||
meta: { title: '预警管理', icon: 'fas fa-bell' }
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
name: 'knowledge',
|
||||
component: 'Knowledge',
|
||||
meta: { title: '知识库', icon: 'fas fa-book' }
|
||||
},
|
||||
{
|
||||
path: '/knowledge/:id',
|
||||
name: 'knowledge-detail',
|
||||
component: 'KnowledgeDetail',
|
||||
meta: { title: '知识详情' }
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
component: 'Chat',
|
||||
meta: { title: '智能对话', icon: 'fas fa-comments' }
|
||||
},
|
||||
{
|
||||
path: '/chat-http',
|
||||
name: 'chat-http',
|
||||
component: 'ChatHttp',
|
||||
meta: { title: '对话(HTTP)', icon: 'fas fa-comment-dots' }
|
||||
},
|
||||
{
|
||||
path: '/monitoring',
|
||||
name: 'monitoring',
|
||||
component: 'Monitoring',
|
||||
meta: { title: '系统监控', icon: 'fas fa-chart-line' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: 'Settings',
|
||||
meta: { title: '系统设置', icon: 'fas fa-cog' }
|
||||
},
|
||||
{
|
||||
path: '/feishu',
|
||||
name: 'feishu',
|
||||
component: 'Feishu',
|
||||
meta: { title: '飞书同步', icon: 'fab fa-lark' }
|
||||
},
|
||||
{
|
||||
path: '/agent',
|
||||
name: 'agent',
|
||||
component: 'Agent',
|
||||
meta: { title: '智能Agent', icon: 'fas fa-robot' }
|
||||
},
|
||||
{
|
||||
path: '/vehicle',
|
||||
name: 'vehicle',
|
||||
component: 'Vehicle',
|
||||
meta: { title: '车辆数据', icon: 'fas fa-car' }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: 'Profile',
|
||||
meta: { title: '个人资料' }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: 'Login',
|
||||
meta: { title: '登录', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: '404',
|
||||
component: 'notfound',
|
||||
meta: { title: '页面未找到' }
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
redirect: '/404'
|
||||
}
|
||||
]);
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach((to, from) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - TSP智能助手`;
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
if (to.meta.requiresAuth !== false && !store.getState('user.isLogin')) {
|
||||
return '/login';
|
||||
}
|
||||
|
||||
// 管理员权限检查
|
||||
if (to.meta.requiresAdmin && !store.getState('user.info.isAdmin')) {
|
||||
return '/403';
|
||||
}
|
||||
});
|
||||
|
||||
// 导出路由实例和辅助函数
|
||||
export function push(path, data) {
|
||||
return router.push(path, data);
|
||||
}
|
||||
|
||||
export function replace(path, data) {
|
||||
return router.replace(path, data);
|
||||
}
|
||||
|
||||
export function go(n) {
|
||||
return router.go(n);
|
||||
}
|
||||
|
||||
export function back() {
|
||||
return router.back();
|
||||
}
|
||||
|
||||
export function forward() {
|
||||
return router.forward();
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* 全局状态管理
|
||||
* 集中管理应用状态,避免状态分散
|
||||
*/
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.state = {
|
||||
// 预警相关
|
||||
alerts: [],
|
||||
alertFilters: {
|
||||
level: 'all',
|
||||
type: 'all',
|
||||
status: 'all'
|
||||
},
|
||||
alertStats: {
|
||||
total: 0,
|
||||
critical: 0,
|
||||
warning: 0,
|
||||
info: 0
|
||||
},
|
||||
|
||||
// 规则相关
|
||||
rules: [],
|
||||
|
||||
// 系统状态
|
||||
health: {},
|
||||
monitorStatus: 'unknown',
|
||||
|
||||
// Agent相关
|
||||
agentStatus: {
|
||||
status: 'inactive',
|
||||
active_goals: 0,
|
||||
available_tools: 0
|
||||
},
|
||||
agentHistory: [],
|
||||
|
||||
// 车辆数据
|
||||
vehicleData: [],
|
||||
|
||||
// UI状态
|
||||
loading: false,
|
||||
notifications: []
|
||||
};
|
||||
|
||||
this.listeners = [];
|
||||
this.debounceTimers = new Map();
|
||||
}
|
||||
|
||||
// 获取状态
|
||||
getState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setState(updates) {
|
||||
const prevState = { ...this.state };
|
||||
this.state = { ...this.state, ...updates };
|
||||
|
||||
// 通知监听器
|
||||
this.notifyListeners(prevState, this.state);
|
||||
}
|
||||
|
||||
// 订阅状态变化
|
||||
subscribe(listener) {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
// 通知监听器
|
||||
notifyListeners(prevState, newState) {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(prevState, newState);
|
||||
} catch (error) {
|
||||
console.error('状态监听器错误:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 防抖更新状态
|
||||
setStateDebounced(updates, delay = 300) {
|
||||
const key = JSON.stringify(updates);
|
||||
|
||||
if (this.debounceTimers.has(key)) {
|
||||
clearTimeout(this.debounceTimers.get(key));
|
||||
}
|
||||
|
||||
this.debounceTimers.set(key, setTimeout(() => {
|
||||
this.setState(updates);
|
||||
this.debounceTimers.delete(key);
|
||||
}, delay));
|
||||
}
|
||||
|
||||
// 预警相关方法
|
||||
updateAlerts(alerts) {
|
||||
this.setState({ alerts });
|
||||
|
||||
// 更新统计信息
|
||||
const stats = {
|
||||
total: alerts.length,
|
||||
critical: alerts.filter(a => a.level === 'critical').length,
|
||||
warning: alerts.filter(a => a.level === 'warning').length,
|
||||
info: alerts.filter(a => a.level === 'info').length
|
||||
};
|
||||
this.setState({ alertStats: stats });
|
||||
}
|
||||
|
||||
updateAlertFilters(filters) {
|
||||
this.setState({ alertFilters: { ...this.state.alertFilters, ...filters } });
|
||||
}
|
||||
|
||||
// 规则相关方法
|
||||
updateRules(rules) {
|
||||
this.setState({ rules });
|
||||
}
|
||||
|
||||
// 系统状态相关方法
|
||||
updateHealth(health) {
|
||||
this.setState({ health });
|
||||
}
|
||||
|
||||
updateMonitorStatus(status) {
|
||||
this.setState({ monitorStatus: status });
|
||||
}
|
||||
|
||||
// Agent相关方法
|
||||
updateAgentStatus(status) {
|
||||
this.setState({ agentStatus: status });
|
||||
}
|
||||
|
||||
updateAgentHistory(history) {
|
||||
this.setState({ agentHistory: history });
|
||||
}
|
||||
|
||||
// 车辆数据相关方法
|
||||
updateVehicleData(data) {
|
||||
this.setState({ vehicleData: data });
|
||||
}
|
||||
|
||||
// UI状态相关方法
|
||||
setLoading(loading) {
|
||||
this.setState({ loading });
|
||||
}
|
||||
|
||||
// 通知相关方法
|
||||
addNotification(notification) {
|
||||
const notifications = [...this.state.notifications, {
|
||||
id: Date.now(),
|
||||
timestamp: new Date(),
|
||||
...notification
|
||||
}];
|
||||
this.setState({ notifications });
|
||||
|
||||
// 3秒后自动移除
|
||||
setTimeout(() => {
|
||||
this.removeNotification(notification.id || notifications[notifications.length - 1].id);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
removeNotification(id) {
|
||||
const notifications = this.state.notifications.filter(n => n.id !== id);
|
||||
this.setState({ notifications });
|
||||
}
|
||||
|
||||
// 获取过滤后的预警
|
||||
getFilteredAlerts() {
|
||||
const { alerts, alertFilters } = this.state;
|
||||
|
||||
return alerts.filter(alert => {
|
||||
if (alertFilters.level !== 'all' && alert.level !== alertFilters.level) return false;
|
||||
if (alertFilters.type !== 'all' && alert.alert_type !== alertFilters.type) return false;
|
||||
if (alertFilters.status !== 'all' && alert.status !== alertFilters.status) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取排序后的预警
|
||||
getSortedAlerts(sortBy = 'timestamp', sortOrder = 'desc') {
|
||||
const filtered = this.getFilteredAlerts();
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
let aVal = a[sortBy];
|
||||
let bVal = b[sortBy];
|
||||
|
||||
if (sortBy === 'timestamp') {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aVal > bVal ? 1 : -1;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局状态管理实例
|
||||
const store = new Store();
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* 工具函数集合
|
||||
*/
|
||||
|
||||
// 防抖函数
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!date) return '';
|
||||
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds);
|
||||
}
|
||||
|
||||
// 相对时间格式化
|
||||
export function formatRelativeTime(date) {
|
||||
if (!date) return '';
|
||||
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diff = now - target;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 30) {
|
||||
return formatDate(date, 'YYYY-MM-DD');
|
||||
} else if (days > 0) {
|
||||
return `${days}天前`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}小时前`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分钟前`;
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
// 文件大小格式化
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 数字千分位格式化
|
||||
export function formatNumber(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
// 深拷贝
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime());
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => deepClone(item));
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const clonedObj = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
export function generateId(prefix = '') {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const randomStr = Math.random().toString(36).substr(2, 5);
|
||||
return `${prefix}${timestamp}${randomStr}`;
|
||||
}
|
||||
|
||||
// 查询参数解析
|
||||
export function parseQueryString(queryString) {
|
||||
const params = new URLSearchParams(queryString);
|
||||
const result = {};
|
||||
|
||||
for (const [key, value] of params) {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 查询参数序列化
|
||||
export function serializeQueryString(params) {
|
||||
return Object.entries(params)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
export function getQueryParam(name) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(name);
|
||||
}
|
||||
|
||||
// 设置查询参数
|
||||
export function setQueryParam(name, value) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(name, value);
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`;
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
|
||||
// 删除查询参数
|
||||
export function removeQueryParam(name) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}${window.location.hash}`;
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
|
||||
// 本地存储封装
|
||||
export const storage = {
|
||||
set(key, value, isSession = false) {
|
||||
try {
|
||||
const storage = isSession ? sessionStorage : localStorage;
|
||||
storage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error('Storage set error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
get(key, defaultValue = null, isSession = false) {
|
||||
try {
|
||||
const storage = isSession ? sessionStorage : localStorage;
|
||||
const item = storage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (e) {
|
||||
console.error('Storage get error:', e);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
remove(key, isSession = false) {
|
||||
try {
|
||||
const storage = isSession ? sessionStorage : localStorage;
|
||||
storage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error('Storage remove error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
clear(isSession = false) {
|
||||
try {
|
||||
const storage = isSession ? sessionStorage : localStorage;
|
||||
storage.clear();
|
||||
} catch (e) {
|
||||
console.error('Storage clear error:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cookie操作
|
||||
export const cookie = {
|
||||
set(name, value, days = 7) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
const expires = `expires=${date.toUTCString()}`;
|
||||
document.cookie = `${name}=${value};${expires};path=/`;
|
||||
},
|
||||
|
||||
get(name) {
|
||||
const nameEQ = `${name}=`;
|
||||
const ca = document.cookie.split(';');
|
||||
for (let c of ca) {
|
||||
while (c.charAt(0) === ' ') {
|
||||
c = c.substring(1);
|
||||
}
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
remove(name) {
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
|
||||
}
|
||||
};
|
||||
|
||||
// 类名操作
|
||||
export function addClass(element, className) {
|
||||
if (element.classList) {
|
||||
element.classList.add(className);
|
||||
} else {
|
||||
element.className += ` ${className}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeClass(element, className) {
|
||||
if (element.classList) {
|
||||
element.classList.remove(className);
|
||||
} else {
|
||||
element.className = element.className.replace(
|
||||
new RegExp(`(^|\\b)${className.split(' ').join('|')}(\\b|$)`, 'gi'),
|
||||
' '
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasClass(element, className) {
|
||||
if (element.classList) {
|
||||
return element.classList.contains(className);
|
||||
} else {
|
||||
return new RegExp(`(^| )${className}( |$)`, 'gi').test(element.className);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleClass(element, className) {
|
||||
if (hasClass(element, className)) {
|
||||
removeClass(element, className);
|
||||
} else {
|
||||
addClass(element, className);
|
||||
}
|
||||
}
|
||||
|
||||
// 事件委托
|
||||
export function delegate(parent, selector, event, handler) {
|
||||
parent.addEventListener(event, function(e) {
|
||||
if (e.target.matches(selector)) {
|
||||
handler(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// DOM就绪
|
||||
export function ready(fn) {
|
||||
if (document.readyState !== 'loading') {
|
||||
fn();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
}
|
||||
|
||||
// 动画帧
|
||||
export const raf = window.requestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
function(callback) { return setTimeout(callback, 1000 / 60); };
|
||||
|
||||
export const caf = window.cancelAnimationFrame ||
|
||||
window.webkitCancelAnimationFrame ||
|
||||
window.mozCancelAnimationFrame ||
|
||||
function(id) { clearTimeout(id); };
|
||||
|
||||
// 错误处理
|
||||
export function handleError(error, context = '') {
|
||||
console.error(`Error in ${context}:`, error);
|
||||
|
||||
// 发送错误到服务器(可选)
|
||||
if (window.errorReporting) {
|
||||
window.errorReporting.report(error, context);
|
||||
}
|
||||
|
||||
// 显示用户友好的错误信息
|
||||
showToast('发生错误,请稍后重试', 'error');
|
||||
}
|
||||
|
||||
// 安全的JSON解析
|
||||
export function safeJsonParse(str, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// XSS防护 - HTML转义
|
||||
export function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
// 验证函数
|
||||
export const validators = {
|
||||
email: (email) => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
},
|
||||
|
||||
phone: (phone) => {
|
||||
const re = /^1[3-9]\d{9}$/;
|
||||
return re.test(phone);
|
||||
},
|
||||
|
||||
url: (url) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
idCard: (idCard) => {
|
||||
const re = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
|
||||
return re.test(idCard);
|
||||
}
|
||||
};
|
||||
|
||||
// URL构建器
|
||||
export class URLBuilder {
|
||||
constructor(baseURL = '') {
|
||||
this.baseURL = baseURL;
|
||||
this.params = {};
|
||||
}
|
||||
|
||||
path(path) {
|
||||
this.baseURL = this.baseURL.replace(/\/$/, '') + '/' + path.replace(/^\//, '');
|
||||
return this;
|
||||
}
|
||||
|
||||
query(key, value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
this.params[key] = value;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
const queryString = serializeQueryString(this.params);
|
||||
return queryString ? `${this.baseURL}?${queryString}` : this.baseURL;
|
||||
}
|
||||
}
|
||||
|
||||
// 图片压缩
|
||||
export function compressImage(file, options = {}) {
|
||||
const {
|
||||
maxWidth = 800,
|
||||
maxHeight = 800,
|
||||
quality = 0.8,
|
||||
mimeType = 'image/jpeg'
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// 计算新尺寸
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width *= ratio;
|
||||
height *= ratio;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 绘制并压缩
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(resolve, mimeType, quality);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// 导出默认配置
|
||||
export const defaultConfig = {
|
||||
apiBaseUrl: '/api',
|
||||
wsUrl: `ws://${window.location.host}:8765`,
|
||||
toastDuration: 3000,
|
||||
paginationSize: 10,
|
||||
debounceDelay: 300,
|
||||
throttleDelay: 100
|
||||
};
|
||||
@@ -1,441 +0,0 @@
|
||||
/**
|
||||
* WebSocket管理模块
|
||||
*/
|
||||
|
||||
import { defaultConfig, storage, debounce } from './utils.js';
|
||||
import store from './store.js';
|
||||
|
||||
// WebSocket配置
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
reconnectInterval: 3000, // 重连间隔
|
||||
maxReconnectAttempts: 5, // 最大重连次数
|
||||
heartbeatInterval: 30000, // 心跳间隔
|
||||
heartbeatTimeout: 5000, // 心跳超时
|
||||
messageQueue: [], // 消息队列
|
||||
debug: false // 调试模式
|
||||
};
|
||||
|
||||
// WebSocket状态枚举
|
||||
export const WebSocketState = {
|
||||
CONNECTING: 0,
|
||||
OPEN: 1,
|
||||
CLOSING: 2,
|
||||
CLOSED: 3
|
||||
};
|
||||
|
||||
// WebSocket管理器类
|
||||
class WebSocketManager {
|
||||
constructor(url = config.wsUrl) {
|
||||
this.url = url;
|
||||
this.ws = null;
|
||||
this.state = WebSocketState.CLOSED;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectTimer = null;
|
||||
this.heartbeatTimer = null;
|
||||
this.heartbeatTimeoutTimer = null;
|
||||
this.messageHandlers = new Map();
|
||||
this.readyStateHandlers = [];
|
||||
this.messageId = 0;
|
||||
this.pendingRequests = new Map();
|
||||
|
||||
// 绑定方法
|
||||
this.onOpen = this.onOpen.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
}
|
||||
|
||||
// 连接WebSocket
|
||||
connect() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.log('WebSocket already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的重连定时器
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.log('Connecting to WebSocket...');
|
||||
this.setState(WebSocketState.CONNECTING);
|
||||
|
||||
try {
|
||||
// 构建WebSocket URL,添加认证信息
|
||||
const token = storage.get('authToken');
|
||||
const wsUrl = token ? `${this.url}?token=${token}` : this.url;
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
// 绑定事件处理器
|
||||
this.ws.onopen = this.onOpen;
|
||||
this.ws.onclose = this.onClose;
|
||||
this.ws.onmessage = this.onMessage;
|
||||
this.ws.onerror = this.onError;
|
||||
|
||||
} catch (error) {
|
||||
this.log('WebSocket connection error:', error);
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
disconnect() {
|
||||
this.log('Disconnecting WebSocket...');
|
||||
this.setState(WebSocketState.CLOSING);
|
||||
|
||||
// 停止重连
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
this.stopHeartbeat();
|
||||
|
||||
// 关闭连接
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState(WebSocketState.CLOSED);
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
send(type, data = {}, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
id: this.messageId++,
|
||||
type,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 如果需要响应,保存回调
|
||||
if (options.expectResponse) {
|
||||
this.pendingRequests.set(message.id, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: setTimeout(() => {
|
||||
this.pendingRequests.delete(message.id);
|
||||
reject(new Error('Request timeout'));
|
||||
}, options.timeout || 30000)
|
||||
});
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
this.ws.send(JSON.stringify(message));
|
||||
this.log('Sent message:', message);
|
||||
|
||||
// 如果不需要响应,立即解决
|
||||
if (!options.expectResponse) {
|
||||
resolve(message.id);
|
||||
}
|
||||
} else {
|
||||
// 连接未就绪,加入队列
|
||||
config.messageQueue.push({ type, data, options, resolve, reject });
|
||||
reject(new Error('WebSocket not connected'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 注册消息处理器
|
||||
on(type, handler) {
|
||||
if (!this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.set(type, []);
|
||||
}
|
||||
this.messageHandlers.get(type).push(handler);
|
||||
}
|
||||
|
||||
// 取消注册消息处理器
|
||||
off(type, handler) {
|
||||
if (this.messageHandlers.has(type)) {
|
||||
const handlers = this.messageHandlers.get(type);
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册状态变化处理器
|
||||
onReadyStateChange(handler) {
|
||||
this.readyStateHandlers.push(handler);
|
||||
}
|
||||
|
||||
// 事件处理器
|
||||
onOpen() {
|
||||
this.log('WebSocket connected');
|
||||
this.setState(WebSocketState.OPEN);
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 启动心跳
|
||||
this.startHeartbeat();
|
||||
|
||||
// 发送队列中的消息
|
||||
this.flushMessageQueue();
|
||||
|
||||
// 通知状态变化
|
||||
this.notifyReadyStateChange();
|
||||
|
||||
// 更新store状态
|
||||
store.commit('SET_WS_CONNECTED', true);
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
this.log('WebSocket closed:', event);
|
||||
this.setState(WebSocketState.CLOSED);
|
||||
|
||||
// 停止心跳
|
||||
this.stopHeartbeat();
|
||||
|
||||
// 拒绝所有待处理的请求
|
||||
this.pendingRequests.forEach(({ reject, timeout }) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('WebSocket closed'));
|
||||
});
|
||||
this.pendingRequests.clear();
|
||||
|
||||
// 通知状态变化
|
||||
this.notifyReadyStateChange();
|
||||
|
||||
// 更新store状态
|
||||
store.commit('SET_WS_CONNECTED', false);
|
||||
|
||||
// 自动重连
|
||||
if (!event.wasClean && this.reconnectAttempts < config.maxReconnectAttempts) {
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.log('Received message:', message);
|
||||
|
||||
// 处理响应消息
|
||||
if (message.id && this.pendingRequests.has(message.id)) {
|
||||
const { resolve, reject, timeout } = this.pendingRequests.get(message.id);
|
||||
clearTimeout(timeout);
|
||||
this.pendingRequests.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理业务消息
|
||||
const handlers = this.messageHandlers.get(message.type);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(message.data, message);
|
||||
} catch (error) {
|
||||
this.log('Handler error:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log('No handler for message type:', message.type);
|
||||
}
|
||||
|
||||
// 处理心跳响应
|
||||
if (message.type === 'pong') {
|
||||
this.handlePong();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.log('Message parse error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onError(error) {
|
||||
this.log('WebSocket error:', error);
|
||||
this.handleError(error);
|
||||
}
|
||||
|
||||
// 重连
|
||||
reconnect() {
|
||||
if (this.reconnectAttempts >= config.maxReconnectAttempts) {
|
||||
this.log('Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.log(`Reconnecting... Attempt ${this.reconnectAttempts}`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect();
|
||||
}, config.reconnectInterval);
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
startHeartbeat() {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send('ping').catch(error => {
|
||||
this.log('Heartbeat error:', error);
|
||||
});
|
||||
|
||||
// 设置心跳超时
|
||||
this.heartbeatTimeoutTimer = setTimeout(() => {
|
||||
this.log('Heartbeat timeout');
|
||||
this.disconnect();
|
||||
this.reconnect();
|
||||
}, config.heartbeatTimeout);
|
||||
}, config.heartbeatInterval);
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.heartbeatTimeoutTimer) {
|
||||
clearTimeout(this.heartbeatTimeoutTimer);
|
||||
this.heartbeatTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理pong响应
|
||||
handlePong() {
|
||||
if (this.heartbeatTimeoutTimer) {
|
||||
clearTimeout(this.heartbeatTimeoutTimer);
|
||||
this.heartbeatTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 清空消息队列
|
||||
flushMessageQueue() {
|
||||
while (config.messageQueue.length > 0) {
|
||||
const { type, data, options, resolve, reject } = config.messageQueue.shift();
|
||||
this.send(type, data, options).then(resolve).catch(reject);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置状态
|
||||
setState(state) {
|
||||
this.state = state;
|
||||
this.notifyReadyStateChange();
|
||||
}
|
||||
|
||||
// 通知状态变化
|
||||
notifyReadyStateChange() {
|
||||
this.readyStateHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(this.state);
|
||||
} catch (error) {
|
||||
this.log('ReadyState handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理错误
|
||||
handleError(error) {
|
||||
this.log('WebSocket error:', error);
|
||||
|
||||
// 显示错误提示
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: 'WebSocket连接失败'
|
||||
});
|
||||
}
|
||||
|
||||
// 日志输出
|
||||
log(...args) {
|
||||
if (config.debug) {
|
||||
console.log('[WebSocket]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取连接状态
|
||||
getReadyState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// 检查是否已连接
|
||||
isConnected() {
|
||||
return this.state === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局WebSocket实例
|
||||
export const wsManager = new WebSocketManager();
|
||||
|
||||
// 扩展WebSocket管理器,添加业务方法
|
||||
wsManager.on('connected', (data) => {
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: 'WebSocket已连接'
|
||||
});
|
||||
});
|
||||
|
||||
wsManager.on('disconnected', (data) => {
|
||||
store.dispatch('showToast', {
|
||||
type: 'warning',
|
||||
message: 'WebSocket已断开'
|
||||
});
|
||||
});
|
||||
|
||||
// 业务方法封装
|
||||
export const wsApi = {
|
||||
// 创建会话
|
||||
createSession: (data) => wsManager.send('create_session', data, { expectResponse: true }),
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (data) => wsManager.send('send_message', data, { expectResponse: true }),
|
||||
|
||||
// 获取历史记录
|
||||
getHistory: (sessionId) => wsManager.send('get_history', { session_id: sessionId }, { expectResponse: true }),
|
||||
|
||||
// 创建工单
|
||||
createWorkOrder: (data) => wsManager.send('create_work_order', data, { expectResponse: true }),
|
||||
|
||||
// 获取工单状态
|
||||
getWorkOrderStatus: (workOrderId) => wsManager.send('get_work_order_status', { work_order_id: workOrderId }, { expectResponse: true }),
|
||||
|
||||
// 结束会话
|
||||
endSession: (sessionId) => wsManager.send('end_session', { session_id: sessionId }, { expectResponse: true })
|
||||
};
|
||||
|
||||
// 初始化WebSocket连接
|
||||
export function initWebSocket() {
|
||||
// 页面可见时连接
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && !wsManager.isConnected()) {
|
||||
wsManager.connect();
|
||||
}
|
||||
});
|
||||
|
||||
// 窗口获得焦点时检查连接
|
||||
window.addEventListener('focus', () => {
|
||||
if (!wsManager.isConnected()) {
|
||||
wsManager.connect();
|
||||
}
|
||||
});
|
||||
|
||||
// 页面卸载时断开连接
|
||||
window.addEventListener('beforeunload', () => {
|
||||
wsManager.disconnect();
|
||||
});
|
||||
|
||||
// 网络状态变化时重连
|
||||
window.addEventListener('online', () => {
|
||||
if (!wsManager.isConnected()) {
|
||||
wsManager.connect();
|
||||
}
|
||||
});
|
||||
|
||||
// 自动连接
|
||||
wsManager.connect();
|
||||
}
|
||||
|
||||
// 导出配置和管理器
|
||||
export { config };
|
||||
export default wsManager;
|
||||
@@ -69,6 +69,8 @@ function updatePageLanguage(lang) {
|
||||
|
||||
class TSPDashboard {
|
||||
constructor() {
|
||||
// ===== 事件总线 =====
|
||||
this._eventHandlers = {};
|
||||
// ===== 共享状态(所有模块通过 this.xxx 访问)=====
|
||||
// 导航
|
||||
this.currentTab = 'dashboard';
|
||||
@@ -116,6 +118,11 @@ class TSPDashboard {
|
||||
updatePageLanguage(this.currentLanguage);
|
||||
}
|
||||
|
||||
// ===== 事件总线 =====
|
||||
on(event, handler) { (this._eventHandlers[event] = this._eventHandlers[event] || []).push(handler); }
|
||||
off(event, handler) { const h = this._eventHandlers[event]; if (h) this._eventHandlers[event] = h.filter(fn => fn !== handler); }
|
||||
emit(event, data) { (this._eventHandlers[event] || []).forEach(fn => { try { fn(data); } catch (e) { console.error(`Event ${event} error:`, e); } }); }
|
||||
|
||||
async applyModulePermissions() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings');
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
/**
|
||||
* Agent页面组件
|
||||
*/
|
||||
|
||||
export default class Agent {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
this.loadAgentStatus();
|
||||
this.loadActionHistory();
|
||||
} catch (error) {
|
||||
console.error('Agent init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">智能Agent</h1>
|
||||
<p class="page-subtitle">AI助手自动监控和任务执行</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Agent状态 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-robot me-2"></i>Agent状态
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="agent-status" class="text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">加载中...</span>
|
||||
</div>
|
||||
<div class="mt-2">加载Agent状态...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-sliders-h me-2"></i>控制面板
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-success" id="start-monitoring-btn">
|
||||
<i class="fas fa-play me-2"></i>启动监控
|
||||
</button>
|
||||
<button class="btn btn-warning" id="stop-monitoring-btn">
|
||||
<i class="fas fa-stop me-2"></i>停止监控
|
||||
</button>
|
||||
<button class="btn btn-info" id="run-analysis-btn">
|
||||
<i class="fas fa-chart-line me-2"></i>运行分析
|
||||
</button>
|
||||
<button class="btn btn-primary" id="trigger-sample-btn">
|
||||
<i class="fas fa-magic me-2"></i>触发示例动作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent对话 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-comments me-2"></i>Agent对话
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="chat-messages" class="chat-messages mb-3" style="height: 300px; overflow-y: auto;">
|
||||
<div class="text-muted text-center">暂无对话记录</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="chat-input"
|
||||
placeholder="输入消息与Agent对话..." maxlength="500">
|
||||
<button class="btn btn-primary" id="send-chat-btn">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具统计 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-tools me-2"></i>工具统计
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="tools-stats" class="text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM统计 -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-brain me-2"></i>LLM使用统计
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="llm-stats" class="text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行历史 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-history me-2"></i>执行历史
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-outline-danger" id="clear-history-btn">
|
||||
<i class="fas fa-trash me-2"></i>清空历史
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="action-history" class="text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 控制按钮事件
|
||||
document.getElementById('start-monitoring-btn').addEventListener('click', () => {
|
||||
this.startMonitoring();
|
||||
});
|
||||
|
||||
document.getElementById('stop-monitoring-btn').addEventListener('click', () => {
|
||||
this.stopMonitoring();
|
||||
});
|
||||
|
||||
document.getElementById('run-analysis-btn').addEventListener('click', () => {
|
||||
this.runAnalysis();
|
||||
});
|
||||
|
||||
document.getElementById('trigger-sample-btn').addEventListener('click', () => {
|
||||
this.triggerSampleActions();
|
||||
});
|
||||
|
||||
// 对话事件
|
||||
document.getElementById('send-chat-btn').addEventListener('click', () => {
|
||||
this.sendChatMessage();
|
||||
});
|
||||
|
||||
document.getElementById('chat-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.sendChatMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// 清空历史
|
||||
document.getElementById('clear-history-btn').addEventListener('click', () => {
|
||||
this.clearHistory();
|
||||
});
|
||||
|
||||
// 定期刷新状态
|
||||
this.statusInterval = setInterval(() => {
|
||||
this.loadAgentStatus();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async loadAgentStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/status');
|
||||
const data = await response.json();
|
||||
|
||||
const statusDiv = document.getElementById('agent-status');
|
||||
if (data.success) {
|
||||
const status = data.status || 'unknown';
|
||||
const activeGoals = data.active_goals || 0;
|
||||
const availableTools = data.available_tools || 0;
|
||||
|
||||
let statusClass = 'text-warning';
|
||||
let statusText = '未知状态';
|
||||
|
||||
switch (status) {
|
||||
case 'active':
|
||||
statusClass = 'text-success';
|
||||
statusText = '运行中';
|
||||
break;
|
||||
case 'inactive':
|
||||
statusClass = 'text-secondary';
|
||||
statusText = '未激活';
|
||||
break;
|
||||
case 'error':
|
||||
statusClass = 'text-danger';
|
||||
statusText = '错误';
|
||||
break;
|
||||
}
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="mb-3">
|
||||
<i class="fas fa-circle ${statusClass} me-2"></i>
|
||||
<span class="h5 mb-0 ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-1">${activeGoals}</div>
|
||||
<small class="text-muted">活跃目标</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-1">${availableTools}</div>
|
||||
<small class="text-muted">可用工具</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-warning fa-2x mb-2"></i>
|
||||
<div>Agent服务不可用</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载Agent状态失败:', error);
|
||||
document.getElementById('agent-status').innerHTML = `
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-danger fa-2x mb-2"></i>
|
||||
<div>加载状态失败</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async loadActionHistory() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/action-history?limit=20');
|
||||
const data = await response.json();
|
||||
|
||||
const historyDiv = document.getElementById('action-history');
|
||||
if (data.success && data.history.length > 0) {
|
||||
let html = '<div class="table-responsive"><table class="table table-sm">';
|
||||
html += '<thead><tr><th>时间</th><th>动作</th><th>状态</th><th>详情</th></tr></thead><tbody>';
|
||||
|
||||
data.history.forEach(action => {
|
||||
const timestamp = new Date(action.timestamp).toLocaleString();
|
||||
const statusClass = action.success ? 'text-success' : 'text-danger';
|
||||
const statusText = action.success ? '成功' : '失败';
|
||||
|
||||
html += `<tr>
|
||||
<td>${timestamp}</td>
|
||||
<td>${action.action_type || '未知'}</td>
|
||||
<td><span class="${statusClass}">${statusText}</span></td>
|
||||
<td><small class="text-muted">${action.details || ''}</small></td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
historyDiv.innerHTML = html;
|
||||
} else {
|
||||
historyDiv.innerHTML = '<div class="text-muted text-center">暂无执行历史</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载执行历史失败:', error);
|
||||
document.getElementById('action-history').innerHTML = '<div class="text-danger text-center">加载历史失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async loadToolsStats() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/tools/stats');
|
||||
const data = await response.json();
|
||||
|
||||
const statsDiv = document.getElementById('tools-stats');
|
||||
if (data.success) {
|
||||
const tools = data.tools || [];
|
||||
const performance = data.performance || {};
|
||||
|
||||
let html = `<div class="mb-2"><strong>工具数量:</strong> ${tools.length}</div>`;
|
||||
|
||||
if (tools.length > 0) {
|
||||
html += '<div class="small"><strong>可用工具:</strong></div><ul class="list-unstyled small">';
|
||||
tools.slice(0, 5).forEach(tool => {
|
||||
html += `<li>• ${tool.name}</li>`;
|
||||
});
|
||||
if (tools.length > 5) {
|
||||
html += `<li class="text-muted">... 还有 ${tools.length - 5} 个工具</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
statsDiv.innerHTML = html;
|
||||
} else {
|
||||
statsDiv.innerHTML = '<div class="text-muted">获取工具统计失败</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载工具统计失败:', error);
|
||||
document.getElementById('tools-stats').innerHTML = '<div class="text-danger">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async loadLLMStats() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/llm-stats');
|
||||
const data = await response.json();
|
||||
|
||||
const statsDiv = document.getElementById('llm-stats');
|
||||
if (data.success) {
|
||||
const stats = data.stats || {};
|
||||
let html = '';
|
||||
|
||||
if (stats.total_requests) {
|
||||
html += `<div class="mb-1"><strong>总请求数:</strong> ${stats.total_requests}</div>`;
|
||||
}
|
||||
if (stats.success_rate !== undefined) {
|
||||
html += `<div class="mb-1"><strong>成功率:</strong> ${(stats.success_rate * 100).toFixed(1)}%</div>`;
|
||||
}
|
||||
if (stats.average_response_time) {
|
||||
html += `<div class="mb-1"><strong>平均响应时间:</strong> ${stats.average_response_time.toFixed(2)}s</div>`;
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<div class="text-muted">暂无统计数据</div>';
|
||||
}
|
||||
|
||||
statsDiv.innerHTML = html;
|
||||
} else {
|
||||
statsDiv.innerHTML = '<div class="text-muted">获取LLM统计失败</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载LLM统计失败:', error);
|
||||
document.getElementById('llm-stats').innerHTML = '<div class="text-danger">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async startMonitoring() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/monitoring/start', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('监控已启动', 'success');
|
||||
}
|
||||
this.loadAgentStatus();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.message || '启动监控失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启动监控失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopMonitoring() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/monitoring/stop', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('监控已停止', 'success');
|
||||
}
|
||||
this.loadAgentStatus();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.message || '停止监控失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停止监控失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runAnalysis() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/intelligent-analysis', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('智能分析完成', 'success');
|
||||
}
|
||||
this.loadActionHistory();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast('分析失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('运行分析失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSampleActions() {
|
||||
try {
|
||||
const response = await fetch('/api/agent/trigger-sample', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('示例动作已触发', 'success');
|
||||
}
|
||||
this.loadActionHistory();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast('触发示例动作失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('触发示例动作失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendChatMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const message = input.value.trim();
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加用户消息到界面
|
||||
this.addMessageToChat('user', message);
|
||||
input.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
context: { user_id: 'admin' }
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// 添加Agent回复到界面
|
||||
this.addMessageToChat('agent', data.response);
|
||||
} else {
|
||||
this.addMessageToChat('agent', '抱歉,处理您的请求时出现错误。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
this.addMessageToChat('agent', '网络错误,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
addMessageToChat(sender, message) {
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `chat-message ${sender === 'user' ? 'text-end' : ''}`;
|
||||
|
||||
const time = new Date().toLocaleTimeString();
|
||||
messageDiv.innerHTML = `
|
||||
<div class="d-inline-block p-2 mb-2 rounded ${sender === 'user' ? 'bg-primary text-white' : 'bg-light'}">
|
||||
<div class="small">${message}</div>
|
||||
<div class="small opacity-75">${time}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(messageDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
async clearHistory() {
|
||||
if (!confirm('确定要清空所有执行历史吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/agent/clear-history', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('历史已清空', 'success');
|
||||
}
|
||||
this.loadActionHistory();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast('清空历史失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清空历史失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<h4>页面加载失败</h4>
|
||||
<p class="text-muted">${error.message || '未知错误'}</p>
|
||||
<button class="btn btn-primary" onclick="location.reload()">
|
||||
<i class="fas fa-redo me-2"></i>重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.statusInterval) {
|
||||
clearInterval(this.statusInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,738 +0,0 @@
|
||||
/**
|
||||
* 预警管理页面组件
|
||||
*/
|
||||
|
||||
import { api } from '../core/api.js';
|
||||
import { formatDate, formatRelativeTime } from '../core/utils.js';
|
||||
import { confirm, alert } from '../components/modal.js';
|
||||
import store from '../core/store.js';
|
||||
|
||||
export default class Alerts {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.filters = {
|
||||
level: '',
|
||||
status: '',
|
||||
type: '',
|
||||
page: 1,
|
||||
per_page: 10
|
||||
};
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
await this.loadData();
|
||||
this.bindEvents();
|
||||
} catch (error) {
|
||||
console.error('Alerts page init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">预警管理</h1>
|
||||
<p class="page-subtitle">系统预警规则与实时监控</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#ruleModal">
|
||||
<i class="fas fa-plus me-2"></i>添加规则
|
||||
</button>
|
||||
<button class="btn btn-success" id="check-alerts">
|
||||
<i class="fas fa-search me-2"></i>检查预警
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预警统计 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card danger">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="critical-alerts">0</div>
|
||||
<div class="stat-label">严重预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card warning">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="warning-alerts">0</div>
|
||||
<div class="stat-label">警告预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card info">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="info-alerts">0</div>
|
||||
<div class="stat-label">信息预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="total-alerts">0</div>
|
||||
<div class="stat-label">总预警数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 监控控制 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">监控控制</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="me-3">监控状态:</span>
|
||||
<span class="status-indicator" id="monitor-status">
|
||||
<span id="monitor-text">检查中...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button class="btn btn-success me-2" id="start-monitor">
|
||||
<i class="fas fa-play me-1"></i>启动监控
|
||||
</button>
|
||||
<button class="btn btn-danger" id="stop-monitor">
|
||||
<i class="fas fa-stop me-1"></i>停止监控
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预警规则 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">预警规则</h5>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#ruleModal">
|
||||
<i class="fas fa-plus me-1"></i>添加规则
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>规则名称</th>
|
||||
<th>类型</th>
|
||||
<th>级别</th>
|
||||
<th>阈值</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rules-table">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">
|
||||
<div class="spinner spinner-sm"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预警列表 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">预警历史</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 筛选器 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="filter-level">
|
||||
<option value="">所有级别</option>
|
||||
<option value="critical">严重</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">信息</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="filter-status">
|
||||
<option value="">所有状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="resolved">已解决</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="filter-search" placeholder="搜索预警...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-secondary w-100" id="reset-filters">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预警列表 -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>级别</th>
|
||||
<th>规则</th>
|
||||
<th>消息</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alerts-table">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">
|
||||
<div class="spinner spinner-sm"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- 分页将在这里生成 -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 渲染规则模态框
|
||||
this.renderRuleModal();
|
||||
}
|
||||
|
||||
renderRuleModal() {
|
||||
const modalHTML = `
|
||||
<div class="modal fade" id="ruleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">添加预警规则</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="rule-form">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">规则名称</label>
|
||||
<input type="text" class="form-control" name="name" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">预警类型</label>
|
||||
<select class="form-select" name="alert_type" required>
|
||||
<option value="performance">性能预警</option>
|
||||
<option value="quality">质量预警</option>
|
||||
<option value="volume">量级预警</option>
|
||||
<option value="system">系统预警</option>
|
||||
<option value="business">业务预警</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">预警级别</label>
|
||||
<select class="form-select" name="level" required>
|
||||
<option value="info">信息</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="critical">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">阈值</label>
|
||||
<input type="number" class="form-control" name="threshold" step="0.01" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">规则描述</label>
|
||||
<textarea class="form-control" name="description" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">条件表达式</label>
|
||||
<input type="text" class="form-control" name="condition"
|
||||
placeholder="例如: satisfaction_avg < threshold" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">检查间隔(秒)</label>
|
||||
<input type="number" class="form-control" name="check_interval" value="300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">冷却时间(秒)</label>
|
||||
<input type="number" class="form-control" name="cooldown" value="3600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<div class="form-check mt-4">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" checked>
|
||||
<label class="form-check-label">启用规则</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="save-rule">保存规则</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
// 并行加载数据
|
||||
const [alertsRes, rulesRes, statistics, monitorStatus] = await Promise.all([
|
||||
api.alerts.list(this.filters),
|
||||
api.rules.list(),
|
||||
api.alerts.statistics(),
|
||||
api.monitor.status()
|
||||
]);
|
||||
|
||||
// 更新统计数据
|
||||
this.updateStatistics(statistics);
|
||||
|
||||
// 更新监控状态
|
||||
this.updateMonitorStatus(monitorStatus);
|
||||
|
||||
// 更新规则列表
|
||||
this.updateRulesList(rulesRes);
|
||||
|
||||
// 更新预警列表
|
||||
this.updateAlertsList(alertsRes);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load alerts data error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
updateStatistics(statistics) {
|
||||
document.getElementById('critical-alerts').textContent = statistics.critical || 0;
|
||||
document.getElementById('warning-alerts').textContent = statistics.warning || 0;
|
||||
document.getElementById('info-alerts').textContent = statistics.info || 0;
|
||||
document.getElementById('total-alerts').textContent = statistics.total || 0;
|
||||
}
|
||||
|
||||
updateMonitorStatus(status) {
|
||||
const statusEl = document.getElementById('monitor-status');
|
||||
const textEl = document.getElementById('monitor-text');
|
||||
|
||||
if (status.status === 'running') {
|
||||
statusEl.className = 'status-indicator online';
|
||||
textEl.textContent = '监控运行中';
|
||||
} else {
|
||||
statusEl.className = 'status-indicator offline';
|
||||
textEl.textContent = '监控已停止';
|
||||
}
|
||||
}
|
||||
|
||||
updateRulesList(rules) {
|
||||
const tbody = document.getElementById('rules-table');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!rules || rules.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">暂无预警规则</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rules.map(rule => `
|
||||
<tr>
|
||||
<td>${rule.name}</td>
|
||||
<td><span class="badge bg-secondary">${rule.alert_type}</span></td>
|
||||
<td><span class="badge bg-${this.getLevelColor(rule.level)}">${rule.level}</span></td>
|
||||
<td>${rule.threshold}</td>
|
||||
<td>
|
||||
<span class="status-indicator ${rule.enabled ? 'online' : 'offline'}">
|
||||
${rule.enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="alertsPage.editRule('${rule.name}')">
|
||||
编辑
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" onclick="alertsPage.deleteRule('${rule.name}')">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
updateAlertsList(response) {
|
||||
const tbody = document.getElementById('alerts-table');
|
||||
if (!tbody) return;
|
||||
|
||||
const alerts = response.alerts || [];
|
||||
|
||||
if (alerts.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">暂无预警记录</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = alerts.map(alert => `
|
||||
<tr>
|
||||
<td>${formatDate(alert.created_at, 'MM-DD HH:mm')}</td>
|
||||
<td><span class="badge bg-${this.getLevelColor(alert.level)}">${alert.level}</span></td>
|
||||
<td>${alert.rule_name}</td>
|
||||
<td>${alert.message}</td>
|
||||
<td>
|
||||
<span class="status-indicator ${alert.is_active ? 'online' : 'offline'}">
|
||||
${alert.is_active ? '活跃' : '已解决'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${alert.is_active ? `
|
||||
<button class="btn btn-sm btn-success" onclick="alertsPage.resolveAlert('${alert.id}')">
|
||||
解决
|
||||
</button>
|
||||
` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// 更新分页
|
||||
this.updatePagination(response);
|
||||
}
|
||||
|
||||
updatePagination(response) {
|
||||
const pagination = document.getElementById('pagination');
|
||||
if (!pagination) return;
|
||||
|
||||
const { page = 1, total_pages = 1 } = response;
|
||||
|
||||
if (total_pages <= 1) {
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// 上一页
|
||||
if (page > 1) {
|
||||
html += `<li class="page-item">
|
||||
<a class="page-link" href="#" data-page="${page - 1}">上一页</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// 页码
|
||||
for (let i = Math.max(1, page - 2); i <= Math.min(total_pages, page + 2); i++) {
|
||||
html += `<li class="page-item ${i === page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" data-page="${i}">${i}</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// 下一页
|
||||
if (page < total_pages) {
|
||||
html += `<li class="page-item">
|
||||
<a class="page-link" href="#" data-page="${page + 1}">下一页</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
pagination.innerHTML = html;
|
||||
}
|
||||
|
||||
getLevelColor(level) {
|
||||
const colors = {
|
||||
'critical': 'danger',
|
||||
'error': 'danger',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
return colors[level] || 'secondary';
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 监控控制
|
||||
document.getElementById('start-monitor')?.addEventListener('click', () => {
|
||||
this.startMonitor();
|
||||
});
|
||||
|
||||
document.getElementById('stop-monitor')?.addEventListener('click', () => {
|
||||
this.stopMonitor();
|
||||
});
|
||||
|
||||
document.getElementById('check-alerts')?.addEventListener('click', () => {
|
||||
this.checkAlerts();
|
||||
});
|
||||
|
||||
// 规则表单
|
||||
document.getElementById('save-rule')?.addEventListener('click', () => {
|
||||
this.saveRule();
|
||||
});
|
||||
|
||||
// 筛选器
|
||||
document.getElementById('filter-level')?.addEventListener('change', (e) => {
|
||||
this.filters.level = e.target.value;
|
||||
this.filters.page = 1;
|
||||
this.loadAlerts();
|
||||
});
|
||||
|
||||
document.getElementById('filter-status')?.addEventListener('change', (e) => {
|
||||
this.filters.status = e.target.value === 'active' ? 'active' :
|
||||
e.target.value === 'resolved' ? 'resolved' : '';
|
||||
this.filters.page = 1;
|
||||
this.loadAlerts();
|
||||
});
|
||||
|
||||
document.getElementById('filter-search')?.addEventListener('input',
|
||||
this.debounce((e) => {
|
||||
this.filters.search = e.target.value;
|
||||
this.filters.page = 1;
|
||||
this.loadAlerts();
|
||||
}, 300)
|
||||
);
|
||||
|
||||
document.getElementById('reset-filters')?.addEventListener('click', () => {
|
||||
this.resetFilters();
|
||||
});
|
||||
|
||||
// 分页
|
||||
document.getElementById('pagination')?.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('page-link')) {
|
||||
e.preventDefault();
|
||||
const page = parseInt(e.target.dataset.page);
|
||||
if (page) {
|
||||
this.filters.page = page;
|
||||
this.loadAlerts();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async startMonitor() {
|
||||
try {
|
||||
await api.monitor.start();
|
||||
await this.loadData();
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: '监控已启动'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Start monitor error:', error);
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: '启动监控失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async stopMonitor() {
|
||||
try {
|
||||
await api.monitor.stop();
|
||||
await this.loadData();
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: '监控已停止'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Stop monitor error:', error);
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: '停止监控失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async checkAlerts() {
|
||||
try {
|
||||
const result = await api.monitor.checkAlerts();
|
||||
await this.loadData();
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: `检查完成,发现 ${result.alerts_count || 0} 个新预警`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Check alerts error:', error);
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: '检查预警失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async saveRule() {
|
||||
const form = document.getElementById('rule-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
alert_type: formData.get('alert_type'),
|
||||
level: formData.get('level'),
|
||||
threshold: parseFloat(formData.get('threshold')),
|
||||
description: formData.get('description'),
|
||||
condition: formData.get('condition'),
|
||||
check_interval: parseInt(formData.get('check_interval')),
|
||||
cooldown: parseInt(formData.get('cooldown')),
|
||||
enabled: formData.has('enabled')
|
||||
};
|
||||
|
||||
try {
|
||||
await api.rules.create(data);
|
||||
await this.loadData();
|
||||
|
||||
// 关闭模态框
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('ruleModal'));
|
||||
modal.hide();
|
||||
|
||||
// 重置表单
|
||||
form.reset();
|
||||
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: '规则创建成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Save rule error:', error);
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: '创建规则失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async editRule(name) {
|
||||
// TODO: 实现编辑规则功能
|
||||
store.dispatch('showToast', {
|
||||
type: 'info',
|
||||
message: '编辑功能开发中'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRule(name) {
|
||||
const confirmed = await confirm({
|
||||
title: '删除规则',
|
||||
message: `确定要删除规则 "${name}" 吗?`
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.rules.delete(name);
|
||||
await this.loadData();
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: '规则已删除'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete rule error:', error);
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: '删除规则失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAlert(alertId) {
|
||||
try {
|
||||
await api.alerts.resolve(alertId, { resolved_by: 'admin', resolution: '手动解决' });
|
||||
await this.loadAlerts();
|
||||
store.dispatch('showToast', {
|
||||
type: 'success',
|
||||
message: '预警已解决'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Resolve alert error:', error);
|
||||
store.dispatch('showToast', {
|
||||
type: 'error',
|
||||
message: '解决预警失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
level: '',
|
||||
status: '',
|
||||
search: '',
|
||||
page: 1,
|
||||
per_page: 10
|
||||
};
|
||||
|
||||
// 重置筛选器UI
|
||||
document.getElementById('filter-level').value = '';
|
||||
document.getElementById('filter-status').value = '';
|
||||
document.getElementById('filter-search').value = '';
|
||||
|
||||
// 重新加载数据
|
||||
this.loadAlerts();
|
||||
}
|
||||
|
||||
async loadAlerts() {
|
||||
try {
|
||||
const response = await api.alerts.list(this.filters);
|
||||
this.updateAlertsList(response);
|
||||
} catch (error) {
|
||||
console.error('Load alerts error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">加载失败</h4>
|
||||
<p>${error.message || '未知错误'}</p>
|
||||
<hr>
|
||||
<button class="btn btn-outline-danger" onclick="location.reload()">
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露到全局供内联事件使用
|
||||
window.alertsPage = null;
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 聊天页面组件
|
||||
*/
|
||||
|
||||
export default class Chat {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">智能对话</h1>
|
||||
<p class="page-subtitle">WebSocket实时聊天</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">聊天页面</h4>
|
||||
<p class="text-muted">该功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
/**
|
||||
* 仪表板页面组件
|
||||
*/
|
||||
|
||||
import { api } from '../core/api.js';
|
||||
import { formatDate, formatRelativeTime } from '../core/utils.js';
|
||||
import store from '../core/store.js';
|
||||
|
||||
export default class Dashboard {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.charts = {};
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
await this.loadData();
|
||||
this.bindEvents();
|
||||
} catch (error) {
|
||||
console.error('Dashboard init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">仪表板</h1>
|
||||
<p class="page-subtitle">系统概览与实时监控</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="refresh-dashboard">
|
||||
<i class="fas fa-sync-alt me-2"></i>刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card success">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="resolved-orders">0</div>
|
||||
<div class="stat-label">今日已解决工单</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card warning">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="pending-orders">0</div>
|
||||
<div class="stat-label">待处理工单</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card info">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="active-alerts">0</div>
|
||||
<div class="stat-label">活跃预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="satisfaction-rate">0%</div>
|
||||
<div class="stat-label">满意度</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">工单趋势</h5>
|
||||
<select class="form-select form-select-sm" id="trend-period" style="width: auto;">
|
||||
<option value="7">最近7天</option>
|
||||
<option value="30" selected>最近30天</option>
|
||||
<option value="90">最近90天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="orders-trend-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">工单分类分布</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="category-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最新活动 -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">最新工单</h5>
|
||||
<a href="/workorders" class="btn btn-sm btn-outline-primary">查看全部</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush" id="recent-workorders">
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner spinner-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">最新预警</h5>
|
||||
<a href="/alerts" class="btn btn-sm btn-outline-primary">查看全部</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush" id="recent-alerts">
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner spinner-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
// 加载仪表板数据
|
||||
const [analytics, recentWorkorders, recentAlerts, health] = await Promise.all([
|
||||
api.monitor.analytics({ days: 30 }),
|
||||
api.workorders.list({ page: 1, per_page: 5, sort: 'created_at', order: 'desc' }),
|
||||
api.alerts.list({ page: 1, per_page: 5, sort: 'created_at', order: 'desc' }),
|
||||
api.system.health()
|
||||
]);
|
||||
|
||||
// 更新统计数据
|
||||
this.updateStats(analytics, health);
|
||||
|
||||
// 更新图表
|
||||
this.updateCharts(analytics);
|
||||
|
||||
// 更新最新工单列表
|
||||
this.updateRecentWorkorders(recentWorkorders.workorders || []);
|
||||
|
||||
// 更新最新预警列表
|
||||
this.updateRecentAlerts(recentAlerts.alerts || []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load dashboard data error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
updateStats(analytics, health) {
|
||||
// 更新统计卡片
|
||||
const stats = analytics.data || {};
|
||||
|
||||
document.getElementById('resolved-orders').textContent = stats.resolved_orders || 0;
|
||||
document.getElementById('pending-orders').textContent = health.open_workorders || 0;
|
||||
document.getElementById('active-alerts').textContent = Object.values(health.active_alerts_by_level || {}).reduce((a, b) => a + b, 0);
|
||||
|
||||
const satisfaction = stats.satisfaction_avg ? (stats.satisfaction_avg * 100).toFixed(1) : 0;
|
||||
document.getElementById('satisfaction-rate').textContent = `${satisfaction}%`;
|
||||
}
|
||||
|
||||
updateCharts(analytics) {
|
||||
const data = analytics.data || {};
|
||||
|
||||
// 更新工单趋势图
|
||||
this.updateTrendChart(data.trend || []);
|
||||
|
||||
// 更新分类分布图
|
||||
this.updateCategoryChart(data.categories || {});
|
||||
}
|
||||
|
||||
updateTrendChart(trendData) {
|
||||
const ctx = document.getElementById('orders-trend-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
// 销毁旧图表
|
||||
if (this.charts.trend) {
|
||||
this.charts.trend.destroy();
|
||||
}
|
||||
|
||||
this.charts.trend = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map(item => item.date),
|
||||
datasets: [{
|
||||
label: '工单数',
|
||||
data: trendData.map(item => item.count),
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateCategoryChart(categoryData) {
|
||||
const ctx = document.getElementById('category-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
// 销毁旧图表
|
||||
if (this.charts.category) {
|
||||
this.charts.category.destroy();
|
||||
}
|
||||
|
||||
const labels = Object.keys(categoryData);
|
||||
const data = Object.values(categoryData);
|
||||
|
||||
this.charts.category = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: [
|
||||
'#007bff',
|
||||
'#28a745',
|
||||
'#ffc107',
|
||||
'#dc3545',
|
||||
'#6c757d'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateRecentWorkorders(workorders) {
|
||||
const container = document.getElementById('recent-workorders');
|
||||
if (!container) return;
|
||||
|
||||
if (!workorders.length) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="fas fa-inbox fa-2x mb-2"></i>
|
||||
<p>暂无工单</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = workorders.map(workorder => `
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="/workorders/${workorder.id}" class="text-decoration-none">
|
||||
${workorder.title}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">
|
||||
${workorder.category || '未分类'} •
|
||||
${formatRelativeTime(workorder.created_at)}
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-${this.getStatusColor(workorder.status)}">
|
||||
${this.getStatusText(workorder.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
updateRecentAlerts(alerts) {
|
||||
const container = document.getElementById('recent-alerts');
|
||||
if (!container) return;
|
||||
|
||||
if (!alerts.length) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="fas fa-check-circle fa-2x mb-2"></i>
|
||||
<p>暂无预警</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = alerts.map(alert => `
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">${alert.message}</h6>
|
||||
<small class="text-muted">
|
||||
${alert.rule_name} •
|
||||
${formatRelativeTime(alert.created_at)}
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-${this.getLevelColor(alert.level)}">
|
||||
${alert.level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
getStatusColor(status) {
|
||||
const colors = {
|
||||
'open': 'danger',
|
||||
'in_progress': 'warning',
|
||||
'resolved': 'success',
|
||||
'closed': 'secondary'
|
||||
};
|
||||
return colors[status] || 'secondary';
|
||||
}
|
||||
|
||||
getStatusText(status) {
|
||||
const texts = {
|
||||
'open': '待处理',
|
||||
'in_progress': '处理中',
|
||||
'resolved': '已解决',
|
||||
'closed': '已关闭'
|
||||
};
|
||||
return texts[status] || status;
|
||||
}
|
||||
|
||||
getLevelColor(level) {
|
||||
const colors = {
|
||||
'critical': 'danger',
|
||||
'error': 'danger',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
return colors[level] || 'secondary';
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 刷新按钮
|
||||
const refreshBtn = document.getElementById('refresh-dashboard');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// 趋势周期选择
|
||||
const periodSelect = document.getElementById('trend-period');
|
||||
if (periodSelect) {
|
||||
periodSelect.addEventListener('change', (e) => {
|
||||
this.changeTrendPeriod(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 定时刷新(每5分钟)
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.refresh();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const refreshBtn = document.getElementById('refresh-dashboard');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = true;
|
||||
const icon = refreshBtn.querySelector('i');
|
||||
icon.classList.add('fa-spin');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
console.error('Refresh error:', error);
|
||||
} finally {
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = false;
|
||||
const icon = refreshBtn.querySelector('i');
|
||||
icon.classList.remove('fa-spin');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async changeTrendPeriod(days) {
|
||||
try {
|
||||
const analytics = await api.monitor.analytics({ days: parseInt(days) });
|
||||
this.updateTrendChart(analytics.data?.trend || []);
|
||||
} catch (error) {
|
||||
console.error('Change trend period error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">加载失败</h4>
|
||||
<p>${error.message || '未知错误'}</p>
|
||||
<hr>
|
||||
<button class="btn btn-outline-danger" onclick="location.reload()">
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// 清理图表
|
||||
Object.values(this.charts).forEach(chart => {
|
||||
if (chart) chart.destroy();
|
||||
});
|
||||
|
||||
// 清理定时器
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
/**
|
||||
* 飞书同步页面组件
|
||||
*/
|
||||
|
||||
export default class Feishu {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
this.loadSyncStatus();
|
||||
} catch (error) {
|
||||
console.error('Feishu init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">飞书同步</h1>
|
||||
<p class="page-subtitle">与飞书多维表格进行数据同步</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 同步配置 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-cog me-2"></i>同步配置
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="feishu-config-form">
|
||||
<div class="mb-3">
|
||||
<label for="app_id" class="form-label">App ID</label>
|
||||
<input type="text" class="form-control" id="app_id" name="app_id"
|
||||
placeholder="飞书应用的App ID">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="app_secret" class="form-label">App Secret</label>
|
||||
<input type="password" class="form-control" id="app_secret" name="app_secret"
|
||||
placeholder="飞书应用的App Secret">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="app_token" class="form-label">App Token</label>
|
||||
<input type="text" class="form-control" id="app_token" name="app_token"
|
||||
placeholder="多维表格的App Token">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="table_id" class="form-label">Table ID</label>
|
||||
<input type="text" class="form-control" id="table_id" name="table_id"
|
||||
placeholder="数据表的Table ID">
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary" id="save-config-btn">
|
||||
<i class="fas fa-save me-2"></i>保存配置
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="test-connection-btn">
|
||||
<i class="fas fa-plug me-2"></i>测试连接
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同步状态 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-info-circle me-2"></i>同步状态
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="sync-status" class="text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同步操作 -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-sync me-2"></i>同步操作
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-success" id="sync-from-feishu-btn">
|
||||
<i class="fas fa-download me-2"></i>从飞书同步
|
||||
</button>
|
||||
<button class="btn btn-primary" id="preview-data-btn">
|
||||
<i class="fas fa-eye me-2"></i>预览飞书数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字段映射 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-link me-2"></i>字段映射
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-outline-primary mb-3" id="discover-fields-btn">
|
||||
<i class="fas fa-search me-2"></i>发现字段
|
||||
</button>
|
||||
<div id="field-discovery-result"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-outline-secondary mb-3" id="mapping-status-btn">
|
||||
<i class="fas fa-list me-2"></i>映射状态
|
||||
</button>
|
||||
<div id="mapping-status-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据预览 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-table me-2"></i>数据预览
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="data-preview" class="text-muted">
|
||||
点击"预览飞书数据"查看数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 配置表单提交
|
||||
const configForm = document.getElementById('feishu-config-form');
|
||||
configForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveConfig();
|
||||
});
|
||||
|
||||
// 测试连接
|
||||
document.getElementById('test-connection-btn').addEventListener('click', () => {
|
||||
this.testConnection();
|
||||
});
|
||||
|
||||
// 从飞书同步
|
||||
document.getElementById('sync-from-feishu-btn').addEventListener('click', () => {
|
||||
this.syncFromFeishu();
|
||||
});
|
||||
|
||||
// 预览数据
|
||||
document.getElementById('preview-data-btn').addEventListener('click', () => {
|
||||
this.previewData();
|
||||
});
|
||||
|
||||
// 发现字段
|
||||
document.getElementById('discover-fields-btn').addEventListener('click', () => {
|
||||
this.discoverFields();
|
||||
});
|
||||
|
||||
// 映射状态
|
||||
document.getElementById('mapping-status-btn').addEventListener('click', () => {
|
||||
this.getMappingStatus();
|
||||
});
|
||||
|
||||
// 加载配置
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('app_id').value = data.config.app_id || '';
|
||||
document.getElementById('app_secret').value = data.config.app_secret || '';
|
||||
document.getElementById('app_token').value = data.config.app_token || '';
|
||||
document.getElementById('table_id').value = data.config.table_id || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig() {
|
||||
const formData = new FormData(document.getElementById('feishu-config-form'));
|
||||
const config = {
|
||||
app_id: formData.get('app_id'),
|
||||
app_secret: formData.get('app_secret'),
|
||||
app_token: formData.get('app_token'),
|
||||
table_id: formData.get('table_id')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('配置保存成功', 'success');
|
||||
}
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.error || '配置保存失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/test-connection');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('连接测试成功', 'success');
|
||||
}
|
||||
// 显示字段信息
|
||||
if (data.fields) {
|
||||
console.log('飞书表格字段:', data.fields);
|
||||
}
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.message || '连接测试失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('测试连接失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadSyncStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/status');
|
||||
const data = await response.json();
|
||||
|
||||
const statusDiv = document.getElementById('sync-status');
|
||||
if (data.success) {
|
||||
const status = data.status;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="mb-2">
|
||||
<strong>最后同步:</strong> ${status.last_sync || '从未同步'}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<strong>同步状态:</strong> ${status.is_syncing ? '同步中' : '空闲'}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<strong>总记录数:</strong> ${status.total_records || 0}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
statusDiv.innerHTML = '<span class="text-danger">获取状态失败</span>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取同步状态失败:', error);
|
||||
document.getElementById('sync-status').innerHTML = '<span class="text-danger">获取状态失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async syncFromFeishu() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/sync-from-feishu', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
generate_ai_suggestions: true,
|
||||
limit: 50
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.message, 'success');
|
||||
}
|
||||
this.loadSyncStatus(); // 重新加载状态
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.error || '同步失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('同步失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async previewData() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/preview-feishu-data');
|
||||
const data = await response.json();
|
||||
|
||||
const previewDiv = document.getElementById('data-preview');
|
||||
if (data.success && data.preview_data.length > 0) {
|
||||
let html = `<div class="table-responsive"><table class="table table-sm">`;
|
||||
html += '<thead><tr><th>记录ID</th><th>字段数据</th></tr></thead><tbody>';
|
||||
|
||||
data.preview_data.forEach(item => {
|
||||
html += `<tr>
|
||||
<td>${item.record_id}</td>
|
||||
<td><pre class="small">${JSON.stringify(item.fields, null, 2)}</pre></td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
previewDiv.innerHTML = html;
|
||||
} else {
|
||||
previewDiv.innerHTML = '<span class="text-muted">暂无数据</span>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('预览数据失败:', error);
|
||||
document.getElementById('data-preview').innerHTML = '<span class="text-danger">预览失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async discoverFields() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/field-mapping/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ limit: 5 })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const resultDiv = document.getElementById('field-discovery-result');
|
||||
if (data.success) {
|
||||
const report = data.discovery_report;
|
||||
let html = '<h6>字段发现报告</h6>';
|
||||
|
||||
if (Object.keys(report).length > 0) {
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
Object.entries(report).forEach(([field, info]) => {
|
||||
html += `<li class="list-group-item">
|
||||
<strong>${field}</strong>: ${info.suggestion || '未知'}
|
||||
<br><small class="text-muted">置信度: ${(info.confidence * 100).toFixed(1)}%</small>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
} else {
|
||||
html += '<p class="text-muted">未发现可映射的字段</p>';
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
} else {
|
||||
resultDiv.innerHTML = '<span class="text-danger">字段发现失败</span>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('字段发现失败:', error);
|
||||
document.getElementById('field-discovery-result').innerHTML = '<span class="text-danger">字段发现失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async getMappingStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/feishu-sync/field-mapping/status');
|
||||
const data = await response.json();
|
||||
|
||||
const resultDiv = document.getElementById('mapping-status-result');
|
||||
if (data.success) {
|
||||
const status = data.status;
|
||||
let html = '<h6>映射状态</h6>';
|
||||
|
||||
if (status.mappings && status.mappings.length > 0) {
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
status.mappings.forEach(mapping => {
|
||||
html += `<li class="list-group-item">
|
||||
<strong>${mapping.feishu_field}</strong> → ${mapping.local_field}
|
||||
<br><small class="text-muted">优先级: ${mapping.priority}</small>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
} else {
|
||||
html += '<p class="text-muted">暂无字段映射</p>';
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
} else {
|
||||
resultDiv.innerHTML = '<span class="text-danger">获取映射状态失败</span>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取映射状态失败:', error);
|
||||
document.getElementById('mapping-status-result').innerHTML = '<span class="text-danger">获取映射状态失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<h4>页面加载失败</h4>
|
||||
<p class="text-muted">${error.message || '未知错误'}</p>
|
||||
<button class="btn btn-primary" onclick="location.reload()">
|
||||
<i class="fas fa-redo me-2"></i>重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,673 +0,0 @@
|
||||
/**
|
||||
* 知识库页面组件
|
||||
*/
|
||||
|
||||
export default class Knowledge {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.currentPage = 1; // 初始化当前页码
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
await Promise.all([
|
||||
this.loadKnowledgeList(),
|
||||
this.loadStats()
|
||||
]);
|
||||
}
|
||||
|
||||
async loadStats() {
|
||||
try {
|
||||
const response = await fetch('/api/knowledge/stats');
|
||||
if (response.ok) {
|
||||
const stats = await response.json();
|
||||
|
||||
// 更新统计数据显示
|
||||
const totalEl = this.container.querySelector('#stat-total');
|
||||
const activeEl = this.container.querySelector('#stat-active');
|
||||
const catsEl = this.container.querySelector('#stat-categories');
|
||||
const confEl = this.container.querySelector('#stat-confidence');
|
||||
|
||||
if (totalEl) totalEl.textContent = stats.total_entries || 0;
|
||||
if (activeEl) activeEl.textContent = stats.active_entries || 0; // 后端现在返回的是已验证数量
|
||||
if (catsEl) catsEl.textContent = Object.keys(stats.category_distribution || {}).length;
|
||||
if (confEl) confEl.textContent = ((stats.average_confidence || 0) * 100).toFixed(0) + '%';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="page-title">知识库</h1>
|
||||
<p class="page-subtitle">管理和维护知识条目</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" id="btn-import-file">
|
||||
<i class="fas fa-file-import me-2"></i>导入文件
|
||||
</button>
|
||||
<input type="file" id="file-input" style="display: none;" accept=".txt,.md">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="row row-cards mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="bg-primary text-white avatar">
|
||||
<i class="fas fa-book"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="font-weight-medium" id="stat-total">0</div>
|
||||
<div class="text-muted">总条目</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="bg-green text-white avatar">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="font-weight-medium" id="stat-active">0</div>
|
||||
<div class="text-muted">已验证</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="bg-blue text-white avatar">
|
||||
<i class="fas fa-tags"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="font-weight-medium" id="stat-categories">0</div>
|
||||
<div class="text-muted">分类数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="bg-yellow text-white avatar">
|
||||
<i class="fas fa-star"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="font-weight-medium" id="stat-confidence">0%</div>
|
||||
<div class="text-muted">平均置信度</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">知识条目列表</h3>
|
||||
<div class="card-options">
|
||||
<div class="btn-group me-2 d-none" id="batch-actions">
|
||||
<button class="btn btn-success btn-sm" id="btn-batch-verify">
|
||||
<i class="fas fa-check me-1"></i>验证
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm" id="btn-batch-unverify">
|
||||
<i class="fas fa-times me-1"></i>取消验证
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" id="btn-batch-delete">
|
||||
<i class="fas fa-trash me-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="搜索知识..." id="search-input">
|
||||
<button class="btn btn-secondary" id="btn-search">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" class="form-check-input" id="check-all">
|
||||
</th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>问题/主题</th>
|
||||
<th>内容预览</th>
|
||||
<th style="width: 150px;">分类</th>
|
||||
<th style="width: 100px;">置信度</th>
|
||||
<th style="width: 100px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="knowledge-list-body">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">正在加载数据...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center mt-3" id="pagination-container">
|
||||
<!-- 分页控件 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 导入文件按钮
|
||||
const fileInput = this.container.querySelector('#file-input');
|
||||
const importBtn = this.container.querySelector('#btn-import-file');
|
||||
|
||||
importBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
await this.uploadFile(e.target.files[0]);
|
||||
// 清空选择,允许再次选择同名文件
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
const searchInput = this.container.querySelector('#search-input');
|
||||
const searchBtn = this.container.querySelector('#btn-search');
|
||||
|
||||
const performSearch = () => {
|
||||
const query = searchInput.value.trim();
|
||||
this.loadKnowledgeList(1, query);
|
||||
};
|
||||
|
||||
searchBtn.addEventListener('click', performSearch);
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// 批量操作按钮
|
||||
const batchVerifyBtn = this.container.querySelector('#btn-batch-verify');
|
||||
if (batchVerifyBtn) {
|
||||
batchVerifyBtn.addEventListener('click', () => this.batchAction('verify'));
|
||||
}
|
||||
|
||||
const batchUnverifyBtn = this.container.querySelector('#btn-batch-unverify');
|
||||
if (batchUnverifyBtn) {
|
||||
batchUnverifyBtn.addEventListener('click', () => this.batchAction('unverify'));
|
||||
}
|
||||
|
||||
const batchDeleteBtn = this.container.querySelector('#btn-batch-delete');
|
||||
if (batchDeleteBtn) {
|
||||
batchDeleteBtn.addEventListener('click', () => this.batchAction('delete'));
|
||||
}
|
||||
|
||||
// 全选复选框
|
||||
const checkAll = this.container.querySelector('#check-all');
|
||||
if (checkAll) {
|
||||
checkAll.addEventListener('change', (e) => {
|
||||
const checks = this.container.querySelectorAll('.item-check');
|
||||
checks.forEach(check => check.checked = e.target.checked);
|
||||
this.updateBatchButtons();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bindCheckboxEvents() {
|
||||
const checks = this.container.querySelectorAll('.item-check');
|
||||
checks.forEach(check => {
|
||||
check.addEventListener('change', () => {
|
||||
this.updateBatchButtons();
|
||||
// 如果有一个未选中,取消全选选中状态
|
||||
if (!check.checked) {
|
||||
const checkAll = this.container.querySelector('#check-all');
|
||||
if (checkAll) checkAll.checked = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateBatchButtons() {
|
||||
const checkedCount = this.container.querySelectorAll('.item-check:checked').length;
|
||||
const actionsGroup = this.container.querySelector('#batch-actions');
|
||||
|
||||
if (actionsGroup) {
|
||||
if (checkedCount > 0) {
|
||||
actionsGroup.classList.remove('d-none');
|
||||
// 更新删除按钮文本
|
||||
const deleteBtn = this.container.querySelector('#btn-batch-delete');
|
||||
if (deleteBtn) deleteBtn.innerHTML = `<i class="fas fa-trash me-1"></i>删除 (${checkedCount})`;
|
||||
} else {
|
||||
actionsGroup.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async batchDeleteKnowledge() {
|
||||
const checks = this.container.querySelectorAll('.item-check:checked');
|
||||
const ids = Array.from(checks).map(check => parseInt(check.dataset.id));
|
||||
|
||||
console.log('Deleting IDs:', ids);
|
||||
|
||||
if (ids.length === 0) {
|
||||
alert('请先选择要删除的知识条目');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除选中的 ${ids.length} 条知识吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/knowledge/batch_delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids: ids })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
alert(result.message || '删除成功');
|
||||
// 重置全选状态
|
||||
const checkAll = this.container.querySelector('#check-all');
|
||||
if (checkAll) checkAll.checked = false;
|
||||
this.updateBatchDeleteButton();
|
||||
// 刷新列表和统计(保持当前页)
|
||||
await Promise.all([
|
||||
this.loadKnowledgeList(this.currentPage),
|
||||
this.loadStats()
|
||||
]);
|
||||
} else {
|
||||
alert(`删除失败: ${result.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除出错:', error);
|
||||
alert('批量删除出错,请查看控制台');
|
||||
}
|
||||
}
|
||||
|
||||
async loadKnowledgeList(page = null, query = '') {
|
||||
// 如果未指定页码,使用当前页码,默认为 1
|
||||
const targetPage = page || this.currentPage || 1;
|
||||
this.currentPage = targetPage;
|
||||
|
||||
const tbody = this.container.querySelector('#knowledge-list-body');
|
||||
|
||||
// 柔性加载:不立即清空,而是降低透明度并显示加载态
|
||||
// 这可以防止表格高度塌陷导致的视觉跳动
|
||||
tbody.style.opacity = '0.5';
|
||||
tbody.style.transition = 'opacity 0.2s';
|
||||
|
||||
// 如果表格是空的(第一次加载),则显示加载占位符
|
||||
if (!tbody.hasChildNodes() || tbody.children.length === 0 || tbody.querySelector('.text-center')) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4"><div class="spinner-border text-primary" role="status"></div><div class="mt-2">加载中...</div></td></tr>';
|
||||
tbody.style.opacity = '1';
|
||||
}
|
||||
|
||||
try {
|
||||
let url = `/api/knowledge?page=${targetPage}&per_page=10`;
|
||||
if (query) {
|
||||
url = `/api/knowledge/search?q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
|
||||
tbody.innerHTML = '';
|
||||
tbody.style.opacity = '1'; // 恢复不透明
|
||||
|
||||
// 处理搜索结果(通常是数组)和分页结果(包含 items)的差异
|
||||
let items = [];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else if (result.items) {
|
||||
items = result.items;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">暂无知识条目</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// ... (渲染逻辑保持不变)
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// 验证状态图标
|
||||
let statusBadge = '';
|
||||
if (item.is_verified) {
|
||||
statusBadge = '<span class="text-success ms-2" title="已验证"><i class="fas fa-check-circle"></i></span>';
|
||||
}
|
||||
|
||||
// 验证操作按钮
|
||||
let verifyBtn = '';
|
||||
if (item.is_verified) {
|
||||
verifyBtn = `
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-warning btn-unverify" data-id="${item.id}" title="取消验证">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
verifyBtn = `
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-success btn-verify" data-id="${item.id}" title="验证通过">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td><input type="checkbox" class="form-check-input item-check" data-id="${item.id}"></td>
|
||||
<td>${(targetPage - 1) * 10 + index + 1}</td>
|
||||
<td>
|
||||
<div class="text-truncate" style="max-width: 200px;" title="${item.question}">
|
||||
${item.question}
|
||||
${statusBadge}
|
||||
</div>
|
||||
</td>
|
||||
<td><div class="text-truncate" style="max-width: 300px;" title="${item.answer}">${item.answer}</div></td>
|
||||
<td><span class="badge bg-blue-lt">${item.category || '未分类'}</span></td>
|
||||
<td>${(item.confidence_score * 100).toFixed(0)}%</td>
|
||||
<td>
|
||||
${verifyBtn}
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-danger btn-delete" data-id="${item.id}" title="删除">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
// 绑定验证/取消验证事件
|
||||
const verifyActionBtn = tr.querySelector('.btn-verify, .btn-unverify');
|
||||
if (verifyActionBtn) {
|
||||
verifyActionBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const isVerify = verifyActionBtn.classList.contains('btn-verify');
|
||||
await this.toggleVerify(item.id, isVerify, tr);
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定删除事件
|
||||
const deleteBtn = tr.querySelector('.btn-delete');
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.deleteKnowledge(item.id);
|
||||
});
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// 重新绑定复选框事件
|
||||
this.bindCheckboxEvents();
|
||||
|
||||
// 渲染分页
|
||||
if (result.pages && result.pages > 1) {
|
||||
this.renderPagination(result);
|
||||
} else {
|
||||
this.container.querySelector('#pagination-container').innerHTML = '';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载知识列表失败:', error);
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4 text-danger">加载失败: ${error.message}</td></tr>`;
|
||||
tbody.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// 显示上传中提示
|
||||
const importBtn = this.container.querySelector('#btn-import-file');
|
||||
const originalText = importBtn.innerHTML;
|
||||
importBtn.disabled = true;
|
||||
importBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 上传中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/knowledge/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
alert(`文件上传成功!共提取 ${result.knowledge_count} 条知识。`);
|
||||
// 刷新列表和统计
|
||||
await Promise.all([
|
||||
this.loadKnowledgeList(),
|
||||
this.loadStats()
|
||||
]);
|
||||
} else {
|
||||
alert(`上传失败: ${result.error || result.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传文件出错:', error);
|
||||
alert('上传文件出错,请查看控制台');
|
||||
} finally {
|
||||
importBtn.disabled = false;
|
||||
importBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleVerify(id, isVerify, trElement = null) {
|
||||
const action = isVerify ? 'verify' : 'unverify';
|
||||
const url = `/api/knowledge/${action}/${id}`;
|
||||
|
||||
try {
|
||||
// 如果有 trElement,先显示加载状态
|
||||
let originalBtnHtml = '';
|
||||
let actionBtn = null;
|
||||
if (trElement) {
|
||||
actionBtn = trElement.querySelector('.btn-verify, .btn-unverify');
|
||||
if (actionBtn) {
|
||||
originalBtnHtml = actionBtn.innerHTML;
|
||||
actionBtn.disabled = true;
|
||||
actionBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// 如果提供了 DOM 元素,直接更新 DOM,避免刷新整个列表导致跳动
|
||||
if (trElement) {
|
||||
this.updateRowStatus(trElement, id, isVerify);
|
||||
// 后台静默刷新统计数据
|
||||
this.loadStats();
|
||||
} else {
|
||||
// 仅刷新列表和统计,不跳转页面
|
||||
await Promise.all([
|
||||
this.loadKnowledgeList(this.currentPage),
|
||||
this.loadStats()
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
alert(`${isVerify ? '验证' : '取消验证'}失败: ${result.message}`);
|
||||
// 恢复按钮状态
|
||||
if (actionBtn) {
|
||||
actionBtn.disabled = false;
|
||||
actionBtn.innerHTML = originalBtnHtml;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作出错:', error);
|
||||
// 恢复按钮状态
|
||||
if (trElement) {
|
||||
const actionBtn = trElement.querySelector('.btn-verify, .btn-unverify');
|
||||
if (actionBtn) {
|
||||
actionBtn.disabled = false;
|
||||
// 简单恢复,无法精确还原之前的图标
|
||||
actionBtn.innerHTML = isVerify ? '<i class="fas fa-check"></i>' : '<i class="fas fa-times"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateRowStatus(tr, id, isVerified) {
|
||||
// 1. 更新问题列的状态图标
|
||||
const questionCell = tr.cells[2]; // 第3列是问题
|
||||
const questionDiv = questionCell.querySelector('div');
|
||||
|
||||
// 移除旧的徽章
|
||||
const oldBadge = questionDiv.querySelector('.text-success');
|
||||
if (oldBadge) oldBadge.remove();
|
||||
|
||||
// 如果是验证通过,添加徽章
|
||||
if (isVerified) {
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = 'text-success ms-2';
|
||||
statusBadge.title = '已验证';
|
||||
statusBadge.innerHTML = '<i class="fas fa-check-circle"></i>';
|
||||
questionDiv.appendChild(statusBadge);
|
||||
}
|
||||
|
||||
// 2. 更新操作按钮
|
||||
const actionCell = tr.cells[6]; // 第7列是操作
|
||||
const actionBtn = actionCell.querySelector('.btn-verify, .btn-unverify');
|
||||
|
||||
if (actionBtn) {
|
||||
// 创建新按钮
|
||||
const newBtn = document.createElement('button');
|
||||
newBtn.type = 'button';
|
||||
newBtn.className = `btn btn-sm btn-icon btn-outline-${isVerified ? 'warning' : 'success'} ${isVerified ? 'btn-unverify' : 'btn-verify'}`;
|
||||
newBtn.dataset.id = id;
|
||||
newBtn.title = isVerified ? '取消验证' : '验证通过';
|
||||
newBtn.innerHTML = `<i class="fas fa-${isVerified ? 'times' : 'check'}"></i>`;
|
||||
|
||||
// 重新绑定事件
|
||||
newBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.toggleVerify(id, !isVerified, tr);
|
||||
});
|
||||
|
||||
// 替换旧按钮
|
||||
actionBtn.replaceWith(newBtn);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteKnowledge(id) {
|
||||
if (!confirm('确定要删除这条知识吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/delete/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// 刷新列表和统计(保持当前页)
|
||||
await Promise.all([
|
||||
this.loadKnowledgeList(this.currentPage),
|
||||
this.loadStats()
|
||||
]);
|
||||
} else {
|
||||
alert(`删除失败: ${result.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除出错:', error);
|
||||
alert('删除出错,请查看控制台');
|
||||
}
|
||||
}
|
||||
|
||||
renderPagination(pagination) {
|
||||
const { page, pages } = pagination;
|
||||
const container = this.container.querySelector('#pagination-container');
|
||||
|
||||
let html = '<ul class="pagination">';
|
||||
|
||||
// 上一页
|
||||
html += `
|
||||
<li class="page-item ${page === 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" data-page="${page - 1}" tabindex="-1">上一页</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// 页码 (只显示当前页附近的页码)
|
||||
const startPage = Math.max(1, page - 2);
|
||||
const endPage = Math.min(pages, page + 2);
|
||||
|
||||
if (startPage > 1) {
|
||||
html += '<li class="page-item"><a class="page-link" href="#" data-page="1">1</a></li>';
|
||||
if (startPage > 2) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
html += `
|
||||
<li class="page-item ${i === page ? 'active' : ''}">
|
||||
<a class="page-link" href="#" data-page="${i}">${i}</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
if (endPage < pages) {
|
||||
if (endPage < pages - 1) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
html += `<li class="page-item"><a class="page-link" href="#" data-page="${pages}">${pages}</a></li>`;
|
||||
}
|
||||
|
||||
// 下一页
|
||||
html += `
|
||||
<li class="page-item ${page === pages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" data-page="${page + 1}">下一页</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
html += '</ul>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// 绑定点击事件
|
||||
container.querySelectorAll('.page-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const newPage = parseInt(e.target.dataset.page);
|
||||
if (newPage && newPage !== page && newPage >= 1 && newPage <= pages) {
|
||||
this.loadKnowledgeList(newPage);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* 登录页面组件
|
||||
*/
|
||||
|
||||
export default class Login {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
} catch (error) {
|
||||
console.error('Login init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">用户登录</h1>
|
||||
<p class="page-subtitle">请输入您的账号信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="login-form">
|
||||
<!-- 用户名 -->
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">用户名</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control" id="username"
|
||||
name="username" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密码</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fas fa-lock"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control" id="password"
|
||||
name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 记住我 -->
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember"
|
||||
name="remember">
|
||||
<label class="form-check-label" for="remember">
|
||||
记住我
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div id="login-error" class="alert alert-danger d-none"></div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary" id="login-btn">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>登录
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他登录方式 -->
|
||||
<div class="text-center mt-3">
|
||||
<p class="text-muted">
|
||||
还没有账号?<a href="#" class="text-decoration-none">立即注册</a>
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<a href="#" class="text-decoration-none">忘记密码?</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const form = document.getElementById('login-form');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 获取表单数据
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('username');
|
||||
const password = formData.get('password');
|
||||
const remember = formData.get('remember');
|
||||
|
||||
// 显示加载状态
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>登录中...';
|
||||
errorDiv.classList.add('d-none');
|
||||
|
||||
try {
|
||||
// 调用登录API
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
remember
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 登录成功
|
||||
// 保存token到sessionStorage(会话级别)
|
||||
sessionStorage.setItem('token', data.token);
|
||||
|
||||
// 如果选择记住我,也保存到localStorage
|
||||
if (remember) {
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('remember', 'true');
|
||||
}
|
||||
|
||||
// 更新应用状态
|
||||
if (window.store) {
|
||||
window.store.commit('SET_USER', data.user);
|
||||
window.store.commit('SET_LOGIN', true);
|
||||
window.store.commit('SET_TOKEN', data.token);
|
||||
}
|
||||
|
||||
// 显示成功提示
|
||||
if (window.showToast) {
|
||||
window.showToast('登录成功', 'success');
|
||||
}
|
||||
|
||||
// 跳转到仪表板
|
||||
if (window.router) {
|
||||
window.router.push('/');
|
||||
} else {
|
||||
// 如果路由器还没初始化,直接跳转
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
// 登录失败
|
||||
errorDiv.textContent = data.message || '用户名或密码错误';
|
||||
errorDiv.classList.remove('d-none');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
errorDiv.textContent = '网络错误,请稍后重试';
|
||||
errorDiv.classList.remove('d-none');
|
||||
} finally {
|
||||
// 恢复按钮状态
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt me-2"></i>登录';
|
||||
}
|
||||
});
|
||||
|
||||
// 检查本地存储中的登录状态
|
||||
const rememberedUser = localStorage.getItem('remember');
|
||||
if (rememberedUser === 'true') {
|
||||
const user = localStorage.getItem('user');
|
||||
if (user) {
|
||||
try {
|
||||
const userData = JSON.parse(user);
|
||||
document.getElementById('username').value = userData.username || '';
|
||||
document.getElementById('remember').checked = true;
|
||||
} catch (e) {
|
||||
console.error('Error parsing remembered user:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<h4>页面加载失败</h4>
|
||||
<p class="text-muted">${error.message || '未知错误'}</p>
|
||||
<button class="btn btn-primary" onclick="location.reload()">
|
||||
<i class="fas fa-redo me-2"></i>重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 系统监控页面组件
|
||||
*/
|
||||
|
||||
export default class Monitoring {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">系统监控</h1>
|
||||
<p class="page-subtitle">系统性能与状态监控</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-chart-line fa-3x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">系统监控页面</h4>
|
||||
<p class="text-muted">该功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* 404页面组件
|
||||
*/
|
||||
|
||||
export default class NotFound {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
} catch (error) {
|
||||
console.error('NotFound init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">404 - 页面未找到</h1>
|
||||
<p class="page-subtitle">抱歉,您访问的页面不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<div class="card">
|
||||
<div class="card-body py-5">
|
||||
<div class="error-illustration mb-4">
|
||||
<i class="fas fa-search fa-5x text-muted"></i>
|
||||
</div>
|
||||
<h4 class="mb-3">页面不存在</h4>
|
||||
<p class="text-muted mb-4">
|
||||
看起来您访问的页面已经被移除、名称已更改或暂时不可用。
|
||||
</p>
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||
<button class="btn btn-primary" onclick="window.router && window.router.push('/') || (window.location.href = '/')">
|
||||
<i class="fas fa-home me-2"></i>返回首页
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="window.history.back()">
|
||||
<i class="fas fa-arrow-left me-2"></i>返回上一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速导航 -->
|
||||
<div class="row mt-5">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3">快速导航</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<a href="#" class="text-decoration-none">
|
||||
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-tachometer-alt fa-2x text-primary mb-2"></i>
|
||||
<h6 class="card-title">仪表板</h6>
|
||||
<p class="card-text text-muted small">系统概览</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<a href="#" class="text-decoration-none">
|
||||
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-tasks fa-2x text-success mb-2"></i>
|
||||
<h6 class="card-title">工单管理</h6>
|
||||
<p class="card-text text-muted small">工单处理</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<a href="#" class="text-decoration-none">
|
||||
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-bell fa-2x text-warning mb-2"></i>
|
||||
<h6 class="card-title">预警管理</h6>
|
||||
<p class="card-text text-muted small">系统预警</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<a href="#" class="text-decoration-none">
|
||||
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-book fa-2x text-info mb-2"></i>
|
||||
<h6 class="card-title">知识库</h6>
|
||||
<p class="card-text text-muted small">知识文档</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 绑定导航链接事件
|
||||
const links = this.container.querySelectorAll('a[href^="#"]');
|
||||
links.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const href = link.getAttribute('href');
|
||||
if (href === '#home' && window.router) {
|
||||
window.router.push('/');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<h4>页面加载失败</h4>
|
||||
<p class="text-muted">${error.message || '未知错误'}</p>
|
||||
<button class="btn btn-primary" onclick="location.reload()">
|
||||
<i class="fas fa-redo me-2"></i>重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
/**
|
||||
* 系统设置页面组件
|
||||
*/
|
||||
|
||||
export default class Settings {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">系统配置与管理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 系统信息 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-info-circle me-2"></i>系统信息
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="system-info" class="text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库状态 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-database me-2"></i>数据库状态
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="db-status" class="text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统设置 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-sliders-h me-2"></i>系统配置
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="system-settings-form">
|
||||
<!-- 预警规则设置 -->
|
||||
<div class="mb-4">
|
||||
<h6>预警规则设置</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">默认检查间隔 (秒)</label>
|
||||
<input type="number" class="form-control" id="check-interval" min="30" max="3600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">默认冷却时间 (秒)</label>
|
||||
<input type="number" class="form-control" id="cooldown" min="60" max="86400">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM配置 -->
|
||||
<div class="mb-4">
|
||||
<h6>LLM配置</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API提供商</label>
|
||||
<select class="form-select" id="llm-provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="qwen">通义千问</option>
|
||||
<option value="claude">Claude</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">默认模型</label>
|
||||
<input type="text" class="form-control" id="default-model" placeholder="gpt-3.5-turbo">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API密钥</label>
|
||||
<input type="password" class="form-control" id="api-key" placeholder="输入API密钥">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他设置 -->
|
||||
<div class="mb-4">
|
||||
<h6>其他设置</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="enable-analytics">
|
||||
<label class="form-check-label" for="enable-analytics">
|
||||
启用数据分析
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="enable-websocket">
|
||||
<label class="form-check-label" for="enable-websocket">
|
||||
启用WebSocket实时通信
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="enable-pwa">
|
||||
<label class="form-check-label" for="enable-pwa">
|
||||
启用PWA离线支持
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="enable-error-reporting">
|
||||
<label class="form-check-label" for="enable-error-reporting">
|
||||
启用错误报告
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>保存设置
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统操作 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-tools me-2"></i>系统操作
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>数据管理</h6>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-warning" id="backup-data-btn">
|
||||
<i class="fas fa-download me-2"></i>备份数据
|
||||
</button>
|
||||
<button class="btn btn-danger" id="clear-cache-btn">
|
||||
<i class="fas fa-trash me-2"></i>清理缓存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>系统维护</h6>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-info" id="check-health-btn">
|
||||
<i class="fas fa-heartbeat me-2"></i>健康检查
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="restart-services-btn">
|
||||
<i class="fas fa-redo me-2"></i>重启服务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 设置表单提交
|
||||
const settingsForm = document.getElementById('system-settings-form');
|
||||
settingsForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
// 系统操作按钮
|
||||
document.getElementById('backup-data-btn').addEventListener('click', () => {
|
||||
this.backupData();
|
||||
});
|
||||
|
||||
document.getElementById('clear-cache-btn').addEventListener('click', () => {
|
||||
this.clearCache();
|
||||
});
|
||||
|
||||
document.getElementById('check-health-btn').addEventListener('click', () => {
|
||||
this.checkHealth();
|
||||
});
|
||||
|
||||
document.getElementById('restart-services-btn').addEventListener('click', () => {
|
||||
this.restartServices();
|
||||
});
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
// 加载系统信息
|
||||
const healthResponse = await fetch('/api/health');
|
||||
const healthData = await healthResponse.json();
|
||||
|
||||
const systemInfoDiv = document.getElementById('system-info');
|
||||
if (healthData) {
|
||||
systemInfoDiv.innerHTML = `
|
||||
<div class="mb-2"><strong>版本:</strong> ${healthData.version || '1.0.0'}</div>
|
||||
<div class="mb-2"><strong>运行时间:</strong> ${this.formatUptime(healthData.uptime || 0)}</div>
|
||||
<div class="mb-2"><strong>健康评分:</strong> ${healthData.health_score || 0}/100</div>
|
||||
<div class="mb-2"><strong>活跃会话:</strong> ${healthData.active_sessions || 0}</div>
|
||||
<div class="mb-2"><strong>处理中的工单:</strong> ${healthData.processing_workorders || 0}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 加载数据库状态
|
||||
const dbStatusDiv = document.getElementById('db-status');
|
||||
if (healthData.db_status) {
|
||||
const dbStatus = healthData.db_status;
|
||||
dbStatusDiv.innerHTML = `
|
||||
<div class="mb-2"><strong>状态:</strong> <span class="text-${dbStatus.connection_ok ? 'success' : 'danger'}">${dbStatus.connection_ok ? '正常' : '异常'}</span></div>
|
||||
<div class="mb-2"><strong>类型:</strong> ${dbStatus.type || '未知'}</div>
|
||||
<div class="mb-2"><strong>版本:</strong> ${dbStatus.version || '未知'}</div>
|
||||
<div class="mb-2"><strong>连接数:</strong> ${dbStatus.active_connections || 0}</div>
|
||||
`;
|
||||
} else {
|
||||
dbStatusDiv.innerHTML = '<div class="text-muted">无法获取数据库状态</div>';
|
||||
}
|
||||
|
||||
// 加载配置设置 (这里应该从后端API获取)
|
||||
this.loadConfigSettings();
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error);
|
||||
document.getElementById('system-info').innerHTML = '<div class="text-danger">加载失败</div>';
|
||||
document.getElementById('db-status').innerHTML = '<div class="text-danger">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfigSettings() {
|
||||
// 这里应该从后端API加载配置设置
|
||||
// 暂时设置一些默认值
|
||||
try {
|
||||
document.getElementById('check-interval').value = '300';
|
||||
document.getElementById('cooldown').value = '3600';
|
||||
document.getElementById('llm-provider').value = 'qwen';
|
||||
document.getElementById('default-model').value = 'qwen-turbo';
|
||||
document.getElementById('enable-analytics').checked = true;
|
||||
document.getElementById('enable-websocket').checked = true;
|
||||
document.getElementById('enable-pwa').checked = true;
|
||||
document.getElementById('enable-error-reporting').checked = false;
|
||||
} catch (error) {
|
||||
console.error('加载配置设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const settings = {
|
||||
alert_rules: {
|
||||
check_interval: parseInt(document.getElementById('check-interval').value),
|
||||
cooldown: parseInt(document.getElementById('cooldown').value)
|
||||
},
|
||||
llm: {
|
||||
provider: document.getElementById('llm-provider').value,
|
||||
model: document.getElementById('default-model').value,
|
||||
api_key: document.getElementById('api-key').value
|
||||
},
|
||||
features: {
|
||||
analytics: document.getElementById('enable-analytics').checked,
|
||||
websocket: document.getElementById('enable-websocket').checked,
|
||||
pwa: document.getElementById('enable-pwa').checked,
|
||||
error_reporting: document.getElementById('enable-error-reporting').checked
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 这里应该调用后端API保存设置
|
||||
console.log('保存设置:', settings);
|
||||
|
||||
if (window.showToast) {
|
||||
window.showToast('设置保存成功', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('设置保存失败', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async backupData() {
|
||||
try {
|
||||
// 这里应该调用后端备份API
|
||||
console.log('开始备份数据...');
|
||||
|
||||
if (window.showToast) {
|
||||
window.showToast('数据备份功能开发中', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('备份数据失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('备份失败', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
if (confirm('确定要清理所有缓存吗?')) {
|
||||
try {
|
||||
// 清理本地存储
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// 清理Service Worker缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
}
|
||||
|
||||
if (window.showToast) {
|
||||
window.showToast('缓存清理完成', 'success');
|
||||
}
|
||||
|
||||
// 刷新页面
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('清理缓存失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('清理缓存失败', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
const data = await response.json();
|
||||
|
||||
if (data) {
|
||||
const healthScore = data.health_score || 0;
|
||||
const status = healthScore >= 80 ? 'success' : healthScore >= 60 ? 'warning' : 'error';
|
||||
const message = `系统健康评分: ${healthScore}/100`;
|
||||
|
||||
if (window.showToast) {
|
||||
window.showToast(message, status);
|
||||
}
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast('健康检查失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('健康检查失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('健康检查失败', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restartServices() {
|
||||
if (confirm('确定要重启系统服务吗?这可能会暂时中断服务。')) {
|
||||
try {
|
||||
// 这里应该调用后端重启API
|
||||
console.log('重启服务...');
|
||||
|
||||
if (window.showToast) {
|
||||
window.showToast('服务重启功能开发中', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重启服务失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('重启失败', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatUptime(seconds) {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时 ${minutes}分钟`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分钟`;
|
||||
} else {
|
||||
return `${minutes}分钟`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
/**
|
||||
* 车辆数据页面组件
|
||||
*/
|
||||
|
||||
export default class Vehicle {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
this.loadVehicleData();
|
||||
} catch (error) {
|
||||
console.error('Vehicle init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">车辆数据管理</h1>
|
||||
<p class="page-subtitle">查看和管理车辆实时数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询条件 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-search me-2"></i>数据查询
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="vehicle_id" class="form-label">车辆ID</label>
|
||||
<input type="text" class="form-control" id="vehicle_id"
|
||||
placeholder="输入车辆ID">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="vehicle_vin" class="form-label">车架号(VIN)</label>
|
||||
<input type="text" class="form-control" id="vehicle_vin"
|
||||
placeholder="输入车架号">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="data_type" class="form-label">数据类型</label>
|
||||
<select class="form-select" id="data_type">
|
||||
<option value="">全部类型</option>
|
||||
<option value="location">位置信息</option>
|
||||
<option value="status">状态信息</option>
|
||||
<option value="fault">故障信息</option>
|
||||
<option value="performance">性能数据</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="limit" class="form-label">显示数量</label>
|
||||
<select class="form-select" id="limit">
|
||||
<option value="10">10条</option>
|
||||
<option value="50">50条</option>
|
||||
<option value="100">100条</option>
|
||||
<option value="500">500条</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary me-2" id="search-btn">
|
||||
<i class="fas fa-search me-2"></i>查询
|
||||
</button>
|
||||
<button class="btn btn-success me-2" id="init-sample-btn">
|
||||
<i class="fas fa-plus me-2"></i>初始化示例数据
|
||||
</button>
|
||||
<button class="btn btn-info" id="add-data-btn">
|
||||
<i class="fas fa-plus-circle me-2"></i>添加数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-table me-2"></i>车辆数据列表
|
||||
</h5>
|
||||
<div id="data-count" class="text-muted small">共 0 条数据</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover" id="vehicle-data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>车辆ID</th>
|
||||
<th>车架号</th>
|
||||
<th>数据类型</th>
|
||||
<th>数据内容</th>
|
||||
<th>时间戳</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vehicle-data-body">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加数据模态框 -->
|
||||
<div class="modal fade" id="addDataModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">添加车辆数据</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="add-data-form">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="add_vehicle_id" class="form-label">车辆ID *</label>
|
||||
<input type="text" class="form-control" id="add_vehicle_id"
|
||||
name="vehicle_id" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="add_vehicle_vin" class="form-label">车架号(VIN)</label>
|
||||
<input type="text" class="form-control" id="add_vehicle_vin"
|
||||
name="vehicle_vin">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="add_data_type" class="form-label">数据类型 *</label>
|
||||
<select class="form-select" id="add_data_type" name="data_type" required>
|
||||
<option value="location">位置信息</option>
|
||||
<option value="status">状态信息</option>
|
||||
<option value="fault">故障信息</option>
|
||||
<option value="performance">性能数据</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label for="add_data_value" class="form-label">数据内容 (JSON格式) *</label>
|
||||
<textarea class="form-control" id="add_data_value" name="data_value"
|
||||
rows="6" placeholder='例如:{"latitude": 39.9042, "longitude": 116.4074}'
|
||||
required></textarea>
|
||||
<div class="form-text">请输入有效的JSON格式数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="save-data-btn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 查询按钮
|
||||
document.getElementById('search-btn').addEventListener('click', () => {
|
||||
this.loadVehicleData();
|
||||
});
|
||||
|
||||
// 初始化示例数据
|
||||
document.getElementById('init-sample-btn').addEventListener('click', () => {
|
||||
this.initSampleData();
|
||||
});
|
||||
|
||||
// 添加数据按钮
|
||||
document.getElementById('add-data-btn').addEventListener('click', () => {
|
||||
this.showAddDataModal();
|
||||
});
|
||||
|
||||
// 保存数据
|
||||
document.getElementById('save-data-btn').addEventListener('click', () => {
|
||||
this.saveVehicleData();
|
||||
});
|
||||
|
||||
// 输入框回车查询
|
||||
['vehicle_id', 'vehicle_vin'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.loadVehicleData();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async loadVehicleData() {
|
||||
const vehicleId = document.getElementById('vehicle_id').value.trim();
|
||||
const vehicleVin = document.getElementById('vehicle_vin').value.trim();
|
||||
const dataType = document.getElementById('data_type').value;
|
||||
const limit = document.getElementById('limit').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (vehicleId) params.append('vehicle_id', vehicleId);
|
||||
if (vehicleVin) params.append('vehicle_vin', vehicleVin);
|
||||
if (dataType) params.append('data_type', dataType);
|
||||
if (limit) params.append('limit', limit);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/vehicle/data?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
this.renderVehicleData(data);
|
||||
document.getElementById('data-count').textContent = `共 ${data.length} 条数据`;
|
||||
} else {
|
||||
this.showErrorInTable('数据格式错误');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载车辆数据失败:', error);
|
||||
this.showErrorInTable('加载数据失败');
|
||||
}
|
||||
}
|
||||
|
||||
renderVehicleData(data) {
|
||||
const tbody = document.getElementById('vehicle-data-body');
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">
|
||||
<i class="fas fa-inbox me-2"></i>暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => {
|
||||
const timestamp = new Date(item.timestamp).toLocaleString();
|
||||
let dataValue = item.data_value;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(dataValue);
|
||||
dataValue = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
// 如果不是JSON格式,保持原样
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.id}</td>
|
||||
<td>${item.vehicle_id}</td>
|
||||
<td>${item.vehicle_vin || '-'}</td>
|
||||
<td><span class="badge bg-primary">${item.data_type}</span></td>
|
||||
<td>
|
||||
<pre class="small mb-0" style="max-height: 100px; overflow: hidden;">${dataValue}</pre>
|
||||
</td>
|
||||
<td>${timestamp}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info me-1" onclick="showDataDetails(${item.id})">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
showErrorInTable(message) {
|
||||
const tbody = document.getElementById('vehicle-data-body');
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-danger">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>${message}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async initSampleData() {
|
||||
if (!confirm('确定要初始化示例车辆数据吗?这将会添加一些测试数据。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/vehicle/init-sample-data', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('示例数据初始化成功', 'success');
|
||||
}
|
||||
this.loadVehicleData();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(data.message || '初始化失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化示例数据失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showAddDataModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('addDataModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async saveVehicleData() {
|
||||
const form = document.getElementById('add-data-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
vehicle_id: formData.get('vehicle_id'),
|
||||
vehicle_vin: formData.get('vehicle_vin') || null,
|
||||
data_type: formData.get('data_type'),
|
||||
data_value: formData.get('data_value')
|
||||
};
|
||||
|
||||
// 验证JSON格式
|
||||
try {
|
||||
JSON.parse(data.data_value);
|
||||
} catch (e) {
|
||||
if (window.showToast) {
|
||||
window.showToast('数据内容必须是有效的JSON格式', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/vehicle/data', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast('数据添加成功', 'success');
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('addDataModal'));
|
||||
modal.hide();
|
||||
|
||||
// 清空表单
|
||||
form.reset();
|
||||
|
||||
// 重新加载数据
|
||||
this.loadVehicleData();
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(result.message || '添加失败', 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存数据失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('网络错误', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<h4>页面加载失败</h4>
|
||||
<p class="text-muted">${error.message || '未知错误'}</p>
|
||||
<button class="btn btn-primary" onclick="location.reload()">
|
||||
<i class="fas fa-redo me-2"></i>重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局函数,用于查看数据详情
|
||||
window.showDataDetails = function(id) {
|
||||
// 这里可以实现查看详细数据的功能
|
||||
console.log('查看数据详情:', id);
|
||||
if (window.showToast) {
|
||||
window.showToast('详情查看功能开发中', 'info');
|
||||
}
|
||||
};
|
||||
@@ -1,549 +0,0 @@
|
||||
/**
|
||||
* 工单管理页面组件
|
||||
*/
|
||||
|
||||
export default class WorkOrders {
|
||||
constructor(container, route) {
|
||||
this.container = container;
|
||||
this.route = route;
|
||||
this.currentPage = 1;
|
||||
this.perPage = 20;
|
||||
this.currentStatus = 'all';
|
||||
this.searchQuery = '';
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
await this.loadWorkOrders();
|
||||
} catch (error) {
|
||||
console.error('WorkOrders init error:', error);
|
||||
this.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">工单管理</h1>
|
||||
<p class="page-subtitle">工单列表与管理</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="create-workorder-btn">
|
||||
<i class="fas fa-plus me-2"></i>新建工单
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">状态筛选</label>
|
||||
<select class="form-select" id="status-filter">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="open">待处理</option>
|
||||
<option value="in_progress">处理中</option>
|
||||
<option value="resolved">已解决</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">搜索</label>
|
||||
<input type="text" class="form-control" id="search-input"
|
||||
placeholder="搜索工单标题、描述或ID...">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-outline-secondary w-100" id="search-btn">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工单列表 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">工单列表</h5>
|
||||
<div id="workorder-count" class="text-muted small">共 0 个工单</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover" id="workorders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>类别</th>
|
||||
<th>优先级</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workorders-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>加载中...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<nav id="pagination-nav" class="mt-4" style="display: none;">
|
||||
<ul class="pagination justify-content-center" id="pagination-list">
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工单详情模态框 -->
|
||||
<div class="modal fade" id="workorder-detail-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-file-alt text-primary me-2"></i>工单详情
|
||||
<span class="badge bg-secondary ms-2" id="modal-status-badge">加载中...</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="row g-0 h-100">
|
||||
<!-- 左侧:基本信息 -->
|
||||
<div class="col-md-7 border-end p-4">
|
||||
<h3 id="modal-title" class="mb-3">工单标题</h3>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label text-muted small">工单ID</label>
|
||||
<div class="fw-bold" id="modal-id">-</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label text-muted small">优先级</label>
|
||||
<div id="modal-priority">-</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label text-muted small">分类</label>
|
||||
<div id="modal-category">-</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label text-muted small">创建时间</label>
|
||||
<div id="modal-created-at">-</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label text-muted small">用户/VIN</label>
|
||||
<div id="modal-user">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">问题描述</label>
|
||||
<div class="bg-light p-3 rounded" id="modal-description" style="min-height: 80px;">
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold text-primary">
|
||||
<i class="fas fa-robot me-1"></i>AI 智能分析与建议
|
||||
</label>
|
||||
<div class="card border-primary-lt">
|
||||
<div class="card-body bg-azure-lt" id="modal-ai-analysis">
|
||||
暂无 AI 分析
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:对话历史/详细记录 -->
|
||||
<div class="col-md-5 bg-light p-0 d-flex flex-column" style="height: 600px;">
|
||||
<div class="p-3 border-bottom bg-white">
|
||||
<h6 class="mb-0 fw-bold"><i class="fas fa-history me-2"></i>处理记录 / 对话历史</h6>
|
||||
</div>
|
||||
<div class="flex-grow-1 p-3 overflow-auto" id="modal-chat-history">
|
||||
<!-- 聊天记录将动态插入这里 -->
|
||||
<div class="text-center text-muted mt-5">
|
||||
<i class="fas fa-comments fa-2x mb-3"></i>
|
||||
<p>暂无相关对话记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
<button type="button" class="btn btn-primary" id="modal-edit-btn">编辑工单</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 状态筛选
|
||||
document.getElementById('status-filter').addEventListener('change', () => {
|
||||
this.currentStatus = document.getElementById('status-filter').value;
|
||||
this.currentPage = 1;
|
||||
this.loadWorkOrders();
|
||||
});
|
||||
|
||||
// 搜索
|
||||
document.getElementById('search-btn').addEventListener('click', () => {
|
||||
this.searchQuery = document.getElementById('search-input').value.trim();
|
||||
this.currentPage = 1;
|
||||
this.loadWorkOrders();
|
||||
});
|
||||
|
||||
document.getElementById('search-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('search-btn').click();
|
||||
}
|
||||
});
|
||||
|
||||
// 新建工单
|
||||
document.getElementById('create-workorder-btn').addEventListener('click', () => {
|
||||
this.showCreateWorkOrderModal();
|
||||
});
|
||||
}
|
||||
|
||||
async loadWorkOrders() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
per_page: this.perPage,
|
||||
status: this.currentStatus,
|
||||
search: this.searchQuery
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/workorders?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
this.renderWorkOrders(data.workorders || []);
|
||||
this.renderPagination(data.pagination || {});
|
||||
document.getElementById('workorder-count').textContent = `共 ${data.total || 0} 个工单`;
|
||||
} else {
|
||||
this.showErrorInTable(data.message || '加载工单失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载工单失败:', error);
|
||||
this.showErrorInTable('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
renderWorkOrders(workorders) {
|
||||
const tbody = document.getElementById('workorders-tbody');
|
||||
|
||||
if (workorders.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">
|
||||
<i class="fas fa-inbox me-2"></i>暂无工单
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workorders.map(workorder => {
|
||||
const statusBadge = this.getStatusBadge(workorder.status);
|
||||
const priorityBadge = this.getPriorityBadge(workorder.priority);
|
||||
const createTime = new Date(workorder.created_at).toLocaleString();
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${workorder.order_id || workorder.id}</td>
|
||||
<td>
|
||||
<div class="fw-bold">${workorder.title}</div>
|
||||
<small class="text-muted">${workorder.description?.substring(0, 50) || ''}...</small>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">${workorder.category || '未分类'}</span></td>
|
||||
<td>${priorityBadge}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${createTime}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="viewWorkOrder(${workorder.id})">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="editWorkOrder(${workorder.id})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" onclick="deleteWorkOrder(${workorder.id})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
renderPagination(pagination) {
|
||||
const nav = document.getElementById('pagination-nav');
|
||||
const list = document.getElementById('pagination-list');
|
||||
|
||||
if (!pagination || pagination.total_pages <= 1) {
|
||||
nav.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
nav.style.display = 'block';
|
||||
|
||||
let html = '';
|
||||
|
||||
// 上一页
|
||||
if (pagination.has_prev) {
|
||||
html += `<li class="page-item"><a class="page-link" href="#" onclick="changePage(${pagination.page - 1})">上一页</a></li>`;
|
||||
}
|
||||
|
||||
// 页码
|
||||
for (let i = Math.max(1, pagination.page - 2); i <= Math.min(pagination.total_pages, pagination.page + 2); i++) {
|
||||
const activeClass = i === pagination.page ? 'active' : '';
|
||||
html += `<li class="page-item ${activeClass}"><a class="page-link" href="#" onclick="changePage(${i})">${i}</a></li>`;
|
||||
}
|
||||
|
||||
// 下一页
|
||||
if (pagination.has_next) {
|
||||
html += `<li class="page-item"><a class="page-link" href="#" onclick="changePage(${pagination.page + 1})">下一页</a></li>`;
|
||||
}
|
||||
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
getStatusBadge(status) {
|
||||
const statusMap = {
|
||||
'open': '<span class="badge bg-danger">待处理</span>',
|
||||
'in_progress': '<span class="badge bg-warning">处理中</span>',
|
||||
'resolved': '<span class="badge bg-success">已解决</span>',
|
||||
'closed': '<span class="badge bg-secondary">已关闭</span>'
|
||||
};
|
||||
return statusMap[status] || `<span class="badge bg-light">${status}</span>`;
|
||||
}
|
||||
|
||||
getPriorityBadge(priority) {
|
||||
const priorityMap = {
|
||||
'low': '<span class="badge bg-info">低</span>',
|
||||
'medium': '<span class="badge bg-warning">中</span>',
|
||||
'high': '<span class="badge bg-danger">高</span>',
|
||||
'urgent': '<span class="badge bg-dark">紧急</span>'
|
||||
};
|
||||
return priorityMap[priority] || `<span class="badge bg-light">${priority}</span>`;
|
||||
}
|
||||
|
||||
showErrorInTable(message) {
|
||||
const tbody = document.getElementById('workorders-tbody');
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-danger">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>${message}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
showCreateWorkOrderModal() {
|
||||
// 这里应该显示创建工单的模态框
|
||||
if (window.showToast) {
|
||||
window.showToast('创建工单功能开发中', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
showError(error) {
|
||||
this.container.innerHTML = `
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<h4>页面加载失败</h4>
|
||||
<p class="text-muted">${error.message || '未知错误'}</p>
|
||||
<button class="btn btn-primary" onclick="location.reload()">
|
||||
<i class="fas fa-redo me-2"></i>重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局函数供表格操作使用
|
||||
window.viewWorkOrder = async function(id) {
|
||||
try {
|
||||
// 显示模态框(先显示加载状态)
|
||||
const modalEl = document.getElementById('workorder-detail-modal');
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
|
||||
// 重置内容
|
||||
document.getElementById('modal-status-badge').innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
document.getElementById('modal-ai-analysis').innerHTML = '<div class="spinner-border spinner-border-sm text-primary"></div> 正在分析...';
|
||||
document.getElementById('modal-chat-history').innerHTML = '<div class="text-center mt-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
|
||||
|
||||
// 获取详情
|
||||
const response = await fetch(`/api/workorders/${id}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
alert('获取工单详情失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const wo = result.workorder;
|
||||
|
||||
// 填充基本信息
|
||||
document.getElementById('modal-title').textContent = wo.title;
|
||||
document.getElementById('modal-id').textContent = wo.order_id || wo.id;
|
||||
document.getElementById('modal-category').textContent = wo.category || '-';
|
||||
document.getElementById('modal-created-at').textContent = new Date(wo.created_at).toLocaleString();
|
||||
document.getElementById('modal-user').textContent = wo.user_id || '-';
|
||||
document.getElementById('modal-description').textContent = wo.description || '无描述';
|
||||
|
||||
// 状态徽章
|
||||
const statusMap = {
|
||||
'open': '<span class="badge bg-danger">待处理</span>',
|
||||
'in_progress': '<span class="badge bg-warning">处理中</span>',
|
||||
'resolved': '<span class="badge bg-success">已解决</span>',
|
||||
'closed': '<span class="badge bg-secondary">已关闭</span>'
|
||||
};
|
||||
document.getElementById('modal-status-badge').innerHTML = statusMap[wo.status] || wo.status;
|
||||
|
||||
// 优先级
|
||||
const priorityMap = {
|
||||
'low': '<span class="badge bg-info">低</span>',
|
||||
'medium': '<span class="badge bg-warning">中</span>',
|
||||
'high': '<span class="badge bg-danger">高</span>',
|
||||
'urgent': '<span class="badge bg-dark">紧急</span>'
|
||||
};
|
||||
document.getElementById('modal-priority').innerHTML = priorityMap[wo.priority] || wo.priority;
|
||||
|
||||
// AI 分析/建议
|
||||
if (wo.resolution) {
|
||||
document.getElementById('modal-ai-analysis').innerHTML = `
|
||||
<div style="white-space: pre-wrap; font-family: inherit;">${wo.resolution}</div>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('modal-ai-analysis').textContent = '暂无 AI 分析建议';
|
||||
}
|
||||
|
||||
// 渲染对话/处理历史 (模拟数据或真实数据)
|
||||
const historyContainer = document.getElementById('modal-chat-history');
|
||||
// 这里假设后端返回的详情中包含 history 或 timeline
|
||||
// 如果没有,暂时显示描述作为第一条记录
|
||||
|
||||
let historyHtml = '';
|
||||
|
||||
// 模拟一条初始记录
|
||||
historyHtml += `
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="badge bg-blue-lt me-2">用户</span>
|
||||
<small class="text-muted">${new Date(wo.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
<div class="bg-white p-3 border rounded">
|
||||
${wo.description}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (wo.timeline && wo.timeline.length > 0) {
|
||||
// 如果有真实的时间轴数据
|
||||
historyHtml = wo.timeline.map(item => `
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="badge bg-${item.type === 'ai' ? 'purple-lt' : 'blue-lt'} me-2">
|
||||
${item.author || (item.type === 'ai' ? 'AI 助手' : '系统')}
|
||||
</span>
|
||||
<small class="text-muted">${new Date(item.timestamp).toLocaleString()}</small>
|
||||
</div>
|
||||
<div class="bg-${item.type === 'ai' ? 'azure-lt' : 'white'} p-3 border rounded">
|
||||
${item.content}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
historyContainer.innerHTML = historyHtml;
|
||||
|
||||
// 绑定编辑按钮
|
||||
document.getElementById('modal-edit-btn').onclick = () => {
|
||||
modal.hide();
|
||||
editWorkOrder(id);
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error('查看工单详情失败', e);
|
||||
alert('查看详情失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.editWorkOrder = function(id) {
|
||||
if (window.showToast) {
|
||||
window.showToast(`编辑工单 ${id} 功能开发中`, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteWorkOrder = function(id) {
|
||||
if (!confirm(`确定要删除工单 ${id} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workorders/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
if (window.showToast) {
|
||||
window.showToast(result.message || `工单 ${id} 删除成功`, 'success');
|
||||
}
|
||||
// 通知当前页面刷新工单列表(保持当前页)
|
||||
const event = new CustomEvent('workorder-deleted', { detail: { id } });
|
||||
document.dispatchEvent(event);
|
||||
} else {
|
||||
if (window.showToast) {
|
||||
window.showToast(result.message || '删除工单失败', 'error');
|
||||
} else {
|
||||
alert(result.message || '删除工单失败');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除工单失败:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('删除失败,请检查网络或查看控制台日志', 'error');
|
||||
} else {
|
||||
alert('删除失败,请检查网络或查看控制台日志');
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
window.changePage = function(page) {
|
||||
// 重新加载当前页面实例
|
||||
const event = new CustomEvent('changePage', { detail: { page } });
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
// 监听删除事件,触发当前 WorkOrders 列表刷新(保持当前页)
|
||||
document.addEventListener('workorder-deleted', () => {
|
||||
const event = new CustomEvent('reloadWorkOrders');
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* 统一API服务
|
||||
* 提供所有API调用的统一接口
|
||||
*/
|
||||
|
||||
class ApiService {
|
||||
constructor() {
|
||||
this.baseURL = '';
|
||||
this.defaultTimeout = 30000; // 30秒超时
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
timeout: options.timeout || this.defaultTimeout,
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`API请求失败: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 健康检查相关
|
||||
async getHealth() {
|
||||
return this.request('/api/health');
|
||||
}
|
||||
|
||||
// 预警相关
|
||||
async getAlerts() {
|
||||
return this.request('/api/alerts');
|
||||
}
|
||||
|
||||
async createAlert(alertData) {
|
||||
return this.request('/api/alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(alertData)
|
||||
});
|
||||
}
|
||||
|
||||
async updateAlert(alertId, alertData) {
|
||||
return this.request(`/api/alerts/${alertId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(alertData)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAlert(alertId) {
|
||||
return this.request(`/api/alerts/${alertId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// 规则相关
|
||||
async getRules() {
|
||||
return this.request('/api/rules');
|
||||
}
|
||||
|
||||
async createRule(ruleData) {
|
||||
return this.request('/api/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ruleData)
|
||||
});
|
||||
}
|
||||
|
||||
async updateRule(ruleId, ruleData) {
|
||||
return this.request(`/api/rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(ruleData)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRule(ruleId) {
|
||||
return this.request(`/api/rules/${ruleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// 监控相关
|
||||
async getMonitorStatus() {
|
||||
return this.request('/api/monitor/status');
|
||||
}
|
||||
|
||||
async startMonitoring() {
|
||||
return this.request('/api/monitor/start', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
async stopMonitoring() {
|
||||
return this.request('/api/monitor/stop', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
// Agent相关
|
||||
async getAgentStatus() {
|
||||
return this.request('/api/agent/status');
|
||||
}
|
||||
|
||||
async toggleAgent(enabled) {
|
||||
return this.request('/api/agent/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
}
|
||||
|
||||
async getAgentHistory(limit = 50) {
|
||||
return this.request(`/api/agent/action-history?limit=${limit}`);
|
||||
}
|
||||
|
||||
// 车辆数据相关
|
||||
async getVehicleData(params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return this.request(`/api/vehicle/data?${queryString}`);
|
||||
}
|
||||
|
||||
async addVehicleData(data) {
|
||||
return this.request('/api/vehicle/data', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// 对话相关
|
||||
async createChatSession(data) {
|
||||
return this.request('/api/chat/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async sendChatMessage(data) {
|
||||
return this.request('/api/chat/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async getChatHistory(sessionId) {
|
||||
return this.request(`/api/chat/history/${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局API服务实例
|
||||
const apiService = new ApiService();
|
||||
@@ -327,6 +327,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -327,6 +327,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat_http.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -650,6 +650,6 @@
|
||||
<!-- 脚本 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user