feat: 优化飞书集成、知识库、Agent、工单管理及AI建议功能,统一前端对话字体样式并移除工单模板文件。

This commit is contained in:
zhaojie
2026-02-11 22:53:08 +08:00
parent f8f3738134
commit d0dd18342f
31 changed files with 1384 additions and 211 deletions

Binary file not shown.

View File

@@ -29,6 +29,7 @@ class AgentState(Enum):
PLANNING = "planning"
EXECUTING = "executing"
LEARNING = "learning"
PROCESSING = "processing"
ERROR = "error"
class AgentCore:

View File

@@ -6,10 +6,12 @@ TSP Agent助手 - 简化版本
import logging
import asyncio
import json
from typing import Dict, Any, List, Optional
from datetime import datetime
from src.config.unified_config import get_config
from src.agent.llm_client import LLMManager
from src.web.service_manager import service_manager
logger = logging.getLogger(__name__)
@@ -304,23 +306,66 @@ class TSPAgentAssistant:
logger.error(f"获取LLM使用统计失败: {e}")
return {}
def process_message_agent_sync(self, message: str, user_id: str = "admin",
work_order_id: Optional[int] = None,
enable_proactive: bool = True) -> Dict[str, Any]:
"""处理消息(同步桥接)"""
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(self.process_message_agent(message, user_id, work_order_id, enable_proactive))
except Exception as e:
logger.error(f"同步处理消息失败: {e}")
return {"error": str(e)}
async def process_message_agent(self, message: str, user_id: str = "admin",
work_order_id: Optional[int] = None,
enable_proactive: bool = True) -> Dict[str, Any]:
"""处理消息"""
"""处理消息 (实战化)"""
try:
# 简化的消息处理
logger.info(f"Agent收到消息: {message}")
# 1. 识别意图和推荐工具
prompt = f"用户消息: {message}\n请分析用户意图,并从工具列表中选择最合适的工具。工具列表: {json.dumps(self.get_available_tools())}\n请直接返回你的分析和建议响应。"
response_text = await self.llm_manager.generate(prompt)
# 2. 模拟动作生成
actions = []
if "工单" in message or "查询" in message:
actions.append({"type": "tool_call", "tool": "search_work_order", "status": "suggested"})
return {
"success": True,
"message": f"Agent收到消息: {message}",
"response": response_text,
"actions": actions,
"user_id": user_id,
"work_order_id": work_order_id,
"status": "completed",
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"处理消息失败: {e}")
return {"error": str(e)}
def execute_tool_sync(self, tool_name: str, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
"""执行工具(同步桥接)"""
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(self.execute_tool(tool_name, parameters))
except Exception as e:
return {"error": str(e)}
def trigger_sample_actions_sync(self) -> Dict[str, Any]:
"""触发示例动作(同步桥接)"""
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(self.trigger_sample_actions())
except Exception as e:
return {"success": False, "error": str(e)}
async def trigger_sample_actions(self) -> Dict[str, Any]:
"""触发示例动作"""
try:
@@ -336,7 +381,7 @@ class TSPAgentAssistant:
logger.error(f"触发示例动作失败: {e}")
return {"success": False, "error": str(e)}
def process_file_to_knowledge(self, file_path: str, filename: str) -> Dict[str, Any]:
async def process_file_to_knowledge(self, file_path: str, filename: str) -> Dict[str, Any]:
"""处理文件并生成知识库"""
try:
import os
@@ -356,20 +401,39 @@ class TSPAgentAssistant:
logger.info(f"文件读取成功: {filename}, 字符数={len(content)}")
# 使用简化的知识提取
# 使用LLM进行知识提取 (异步调用)
logger.info(f"正在对文件内容进行 AI 知识提取...")
knowledge_entries = self._extract_knowledge_from_content(content, filename)
knowledge_entries = await self._extract_knowledge_from_content(content, filename)
logger.info(f"知识提取完成: 共提取出 {len(knowledge_entries)} 个潜在条目")
# 保存到知识库
saved_count = 0
# 获取知识库管理器
try:
knowledge_manager = service_manager.get_assistant().knowledge_manager
except Exception as e:
logger.error(f"无法获取知识库管理器: {e}")
knowledge_manager = None
for i, entry in enumerate(knowledge_entries):
try:
logger.info(f"正在保存知识条目 [{i+1}/{len(knowledge_entries)}]: {entry.get('question', '')[:30]}...")
# 这里在实际项目中应当注入知识库管理器的保存逻辑
# 但在当前简化版本中仅记录日志
if knowledge_manager:
# 实际保存到数据库
knowledge_manager.add_knowledge_entry(
question=entry.get('question'),
answer=entry.get('answer'),
category=entry.get('category', '文档导入'),
confidence_score=entry.get('confidence_score', 0.8)
)
saved_count += 1
else:
# 如果无法获取管理器,仅记录日志(降级处理)
logger.warning("知识库管理器不可用,跳过保存")
except Exception as save_error:
logger.error(f"保存知识条目 {i+1} 时出错: {save_error}")
@@ -402,25 +466,70 @@ class TSPAgentAssistant:
logger.error(f"读取文件失败: {e}")
return ""
def _extract_knowledge_from_content(self, content: str, filename: str) -> List[Dict[str, Any]]:
"""从内容中提取知识"""
async def _extract_knowledge_from_content(self, content: str, filename: str) -> List[Dict[str, Any]]:
"""从内容中提取知识 - 使用LLM"""
try:
# 简化的知识提取逻辑
entries = []
# 限制内容长度避免超出token限制
# 假设每个汉字2个token保留前8000个字符作为上下文
truncated_content = content[:8000]
if len(content) > 8000:
truncated_content += "\n...(后续内容已省略)"
# 按段落分割内容
paragraphs = content.split('\n\n')
prompt = f"""
你是一个专业的知识库构建助手。请分析以下文档内容,提取出关键的"问题""答案"对,用于构建知识库。
for i, paragraph in enumerate(paragraphs[:5]): # 最多提取5个
if len(paragraph.strip()) > 20: # 过滤太短的段落
entries.append({
"question": f"关于{filename}的问题{i+1}",
"answer": paragraph.strip(),
"category": "文档知识",
"confidence_score": 0.7
文档文件名:{filename}
文档内容:
{truncated_content}
要求:
1. 提取文档中的核心知识点,转化为"问题(question)""答案(answer)"的形式。
2. "问题"应该清晰明确,方便用户搜索。
3. "答案"应该准确、完整,直接回答问题。
4. "分类(category)"请根据内容自动归类(如:故障排查、操作指南、系统配置、业务流程等)。
5. 输出格式必须是合法的 JSON 数组不要包含Markdown标记。
JSON格式示例
[
{{"question": "如何重置密码?", "answer": "请访问设置页面,点击重置密码按钮...", "category": "操作指南"}},
{{"question": "系统支持哪些浏览器?", "answer": "支持Chrome, Edge, Firefox...", "category": "系统配置"}}
]
"""
# 调用LLM生成
logger.info("正在调用LLM进行知识提取...")
response_text = await self.llm_manager.generate(prompt, temperature=0.3)
# 清理响应中的Markdown标记如果存在
cleaned_text = response_text.strip()
if cleaned_text.startswith("```json"):
cleaned_text = cleaned_text[7:]
if cleaned_text.startswith("```"):
cleaned_text = cleaned_text[3:]
if cleaned_text.endswith("```"):
cleaned_text = cleaned_text[:-3]
cleaned_text = cleaned_text.strip()
# 解析JSON
try:
entries = json.loads(cleaned_text)
except json.JSONDecodeError:
# 尝试修复常见的JSON错误
logger.warning(f"JSON解析失败尝试简单修复: {cleaned_text[:100]}...")
# 这里可以添加更复杂的修复逻辑,或者直接记录错误
return []
# 验证和标准化
valid_entries = []
for entry in entries:
if isinstance(entry, dict) and "question" in entry and "answer" in entry:
valid_entries.append({
"question": entry["question"],
"answer": entry["answer"],
"category": entry.get("category", "文档导入"),
"confidence_score": 0.9 # LLM生成的置信度较高
})
return entries
return valid_entries
except Exception as e:
logger.error(f"提取知识失败: {e}")

View File

@@ -168,6 +168,29 @@ class CacheManager:
)
del self.memory_cache[oldest_key]
def check_and_set_message_processed(self, message_id: str, ttl: int = 300) -> bool:
"""
检查消息是否已处理,如果未处理则标记为已处理
Args:
message_id: 消息ID
ttl: 过期时间默认5分钟
Returns:
bool: True 表示已处理重复消息False 表示未处理(新消息)
"""
key = f"msg_processed:{message_id}"
# 使用锁确保原子性(针对内存缓存)
with self.cache_lock:
# 1. 检查是否存在
if self.get(key):
return True
# 2. 如果不存在,则标记为已处理
self.set(key, 1, ttl)
return False
def get_stats(self) -> Dict[str, Any]:
"""获取缓存统计信息"""
with self.cache_lock:
@@ -222,7 +245,6 @@ class DatabaseCache:
return result
return wrapper
# 全局缓存管理器实例
cache_manager = CacheManager()

View File

@@ -92,7 +92,9 @@ class QueryOptimizer:
'confidence_score': conv.confidence_score,
'work_order_id': conv.work_order_id,
'ip_address': conv.ip_address,
'invocation_method': conv.invocation_method
'invocation_method': conv.invocation_method,
# 构造用户显示名称如果有IP则显示IP否则显示匿名
'user_id': f"{conv.ip_address} ({conv.invocation_method})" if conv.ip_address else "匿名"
})
# 记录查询时间

View File

@@ -293,27 +293,27 @@ class AISuggestionService:
if is_first_suggestion:
prompt += """
要求:
1. 详细分析问题描述,识别可能的根本原因
2. 基于当前处理进度,判断问题处于哪个阶段
3. 提供具体的排查步骤和技术指导
4. 建议需要收集哪些技术信息(如日志、配置、版本等)
5. 如果需要,可以建议进站处理的具体项目
6. 语言专业,包含技术细节,方便技术人员理解
7. 建议格式要清晰,便于执行和跟踪
1. 输出格式必须严格遵守:"时间:当前问题现状,下一步做法"
例如:"0210无TBOX登录记录需要用户进站抓取TBOX日志分析。"
2. "时间"使用当前的MMDD格式如0210
3. "当前问题现状"要基于问题描述和处理记录总结,简明扼要
4. "下一步做法"如果是首次建议,只提供远程排查步骤(如检查网络、重启等),不要提进站
5. 总字数控制在100字以内不要分行不要列点
6. 绝对不要使用"建议您""您可以"等客套话,直接陈述做法
提供完整的分析和建议:"""
按照上述格式生成建议:"""
else:
prompt += """
要求:
1. 基于已有处理记录和当前进度,分析问题进展情况
2. 判断之前的处理步骤是否有效,找出可能的遗漏点
3. 根据问题发展阶段提供更深入的技术解决方案
4. 如果远程处理无效,明确说明需要哪些线下技术支持
5. 详细说明进站后需要执行的具体诊断和修复步骤
6. 包含技术参数、工具要求和注意事项
7. 便于技术人员快速理解问题状态和下一步行动
1. 输出格式必须严格遵守:"时间:当前问题现状,下一步做法"
例如:"0210无TBOX登录记录需要用户进站抓取TBOX日志分析。"
2. "时间"使用当前的MMDD格式如0210
3. "当前问题现状"要简述之前的处理结果(如远程无效)
4. "下一步做法"可以包含进站抓取日志、联系技术支持等深入方案
5. 总字数控制在100字以内不要分行不要列点
6. 绝对不要使用"建议您""您可以"等客套话,直接陈述做法
提供针对性的深入分析和处理建议:"""
按照上述格式生成建议:"""
return prompt

View File

@@ -12,6 +12,7 @@ from lark_oapi.api.im.v1 import P2ImMessageReceiveV1, ReplyMessageRequest, Reply
from src.config.unified_config import get_config
from src.web.service_manager import service_manager
from src.core.cache_manager import cache_manager
logger = logging.getLogger(__name__)
@@ -66,15 +67,30 @@ class FeishuLongConnService:
message_id = message.message_id
chat_id = message.chat_id
message_type = message.message_type
chat_type = getattr(message, 'chat_type', 'unknown') # 获取会话类型
content = message.content
sender = event.sender
logger.info(f"📋 消息详情:")
# 获取发送者ID和群信息
sender_id = sender.sender_id.user_id
try:
tenant_key = sender.sender_id.tenant_key
except:
tenant_key = "unknown"
# 详细日志记录
logger.info(f"📋 消息详情 [长连接]:")
logger.info(f" - 消息ID: {message_id}")
logger.info(f" - 群聊ID: {chat_id}")
logger.info(f" - 发送者ID: {sender.sender_id.user_id}")
logger.info(f" - 会话类型: {'群聊(group)' if chat_type == 'group' else '私聊(p2p)' if chat_type == 'p2p' else chat_type}")
logger.info(f" - 会话ID: {chat_id}")
logger.info(f" - 发送者ID: {sender_id}")
logger.info(f" - 租户Key: {tenant_key}")
logger.info(f" - 消息类型: {message_type}")
logger.info(f" - 原始内容: {content}")
# 消息去重检查
if cache_manager.check_and_set_message_processed(message_id):
logger.warning(f"🔁 消息 {message_id} 已被处理过可能是Webhook已处理跳过")
return
# 只处理文本消息
if message_type != "text":
@@ -145,8 +161,8 @@ class FeishuLongConnService:
response_data = chat_manager.process_message(
session_id=session_id,
user_message=text_content,
ip_address=None,
invocation_method="feishu_longconn"
ip_address=f"Feishu:{sender_id}",
invocation_method=f"Feishu({chat_type})"
)
logger.info(f"📊 处理结果: {response_data.get('success')}")

View File

@@ -401,27 +401,35 @@ class KnowledgeManager:
"""获取知识库统计信息"""
try:
with db_manager.get_session() as session:
total_entries = session.query(KnowledgeEntry).count()
active_entries = session.query(KnowledgeEntry).filter(
# 只统计活跃(未删除)的条目
total_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True
).count()
# 按类别统计
# 统计已验证的条目
verified_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True,
KnowledgeEntry.is_verified == True
).count()
# 按类别统计(仅限活跃条目)
category_stats = session.query(
KnowledgeEntry.category,
session.query(KnowledgeEntry).filter(
KnowledgeEntry.category == KnowledgeEntry.category
).count()
func.count(KnowledgeEntry.id)
).filter(
KnowledgeEntry.is_active == True
).group_by(KnowledgeEntry.category).all()
# 平均置信度
# 平均置信度(仅限活跃条目)
avg_confidence = session.query(
func.avg(KnowledgeEntry.confidence_score)
).filter(
KnowledgeEntry.is_active == True
).scalar() or 0.0
return {
"total_entries": total_entries,
"active_entries": active_entries,
"active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改
"category_distribution": dict(category_stats),
"average_confidence": float(avg_confidence)
}

View File

@@ -49,8 +49,7 @@ def trigger_sample_action():
"""触发示例动作"""
try:
from src.web.service_manager import service_manager
import asyncio
result = asyncio.run(service_manager.get_agent_assistant().trigger_sample_actions())
result = service_manager.get_agent_assistant().trigger_sample_actions_sync()
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@@ -163,13 +162,12 @@ def agent_chat():
agent_assistant = service_manager.get_agent_assistant()
# 模拟Agent处理实际应该调用真正的Agent处理逻辑
import asyncio
result = asyncio.run(agent_assistant.process_message_agent(
result = agent_assistant.process_message_agent_sync(
message=message,
user_id=context.get('user_id', 'admin'),
work_order_id=None,
enable_proactive=True
))
)
return jsonify({
"success": True,
@@ -215,8 +213,7 @@ def execute_agent_tool():
if not tool_name:
return jsonify({"error": "缺少工具名称tool"}), 400
import asyncio
result = asyncio.run(service_manager.get_agent_assistant().agent_core.tool_manager.execute_tool(tool_name, parameters))
result = service_manager.get_agent_assistant().agent_core.tool_manager.execute_tool_sync(tool_name, parameters)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -9,6 +9,7 @@ import threading
from flask import Blueprint, request, jsonify, current_app
from src.integrations.feishu_service import FeishuService
from src.web.service_manager import service_manager
from src.core.cache_manager import cache_manager
# 初始化日志
logger = logging.getLogger(__name__)
@@ -35,11 +36,21 @@ def _process_message_in_background(app, event_data: dict):
message_id = message.get('message_id')
chat_id = message.get('chat_id')
chat_type = message.get('chat_type', 'unknown')
if not message_id or not chat_id:
logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}")
return
# 记录会话类型
chat_type_desc = '群聊(group)' if chat_type == 'group' else '私聊(p2p)' if chat_type == 'p2p' else chat_type
logger.info(f"[Feishu Bot] 收到 {chat_type_desc} 消息, ChatID: {chat_id}")
# 消息去重检查
if cache_manager.check_and_set_message_processed(message_id):
logger.warning(f"[Feishu Bot] 🔁 消息 {message_id} 已被处理过(可能是长连接已处理),跳过")
return
# 内容是一个JSON字符串,需要再次解析
try:
content_json = json.loads(message.get('content', '{}'))

View File

@@ -11,6 +11,7 @@ import logging
from flask import Blueprint, request, jsonify
from src.agent_assistant import TSPAgentAssistant
from src.web.service_manager import service_manager
from src.core.cache_manager import cache_manager
from src.web.error_handlers import handle_api_errors, create_error_response, create_success_response
knowledge_bp = Blueprint('knowledge', __name__, url_prefix='/api/knowledge')
@@ -98,7 +99,16 @@ def upload_knowledge_file():
try:
file.save(temp_path)
assistant = get_agent_assistant()
result = assistant.process_file_to_knowledge(temp_path, file.filename)
# 由于process_file_to_knowledge现在是异步的我们需要同步调用它
# 或者将整个视图函数改为异步Flask 2.0+支持)
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(assistant.process_file_to_knowledge(temp_path, file.filename))
loop.close()
cache_manager.clear()
return jsonify(result)
finally:
try:
@@ -108,6 +118,78 @@ def upload_knowledge_file():
logger = logging.getLogger(__name__)
logger.warning(f"清理临时文件失败: {cleanup_error}")
@knowledge_bp.route('/batch_delete', methods=['POST'])
@handle_api_errors
def batch_delete_knowledge():
"""批量删除知识库条目"""
data = request.get_json()
if not data or 'ids' not in data:
return create_error_response("缺少 ids 参数", 400)
ids = data['ids']
if not isinstance(ids, list):
return create_error_response("ids 必须是列表", 400)
knowledge_manager = service_manager.get_assistant().knowledge_manager
success_count = 0
fail_count = 0
for knowledge_id in ids:
if knowledge_manager.delete_knowledge_entry(knowledge_id):
success_count += 1
else:
fail_count += 1
return create_success_response(f"批量删除完成: 成功 {success_count} 条,失败 {fail_count}")
@knowledge_bp.route('/batch_verify', methods=['POST'])
@handle_api_errors
def batch_verify_knowledge():
"""批量验证知识库条目"""
data = request.get_json()
if not data or 'ids' not in data:
return create_error_response("缺少 ids 参数", 400)
ids = data['ids']
if not isinstance(ids, list):
return create_error_response("ids 必须是列表", 400)
knowledge_manager = service_manager.get_assistant().knowledge_manager
success_count = 0
fail_count = 0
for knowledge_id in ids:
if knowledge_manager.verify_knowledge_entry(knowledge_id, verified_by="admin"):
success_count += 1
else:
fail_count += 1
return create_success_response(f"批量验证完成: 成功 {success_count} 条,失败 {fail_count}")
@knowledge_bp.route('/batch_unverify', methods=['POST'])
@handle_api_errors
def batch_unverify_knowledge():
"""批量取消验证知识库条目"""
data = request.get_json()
if not data or 'ids' not in data:
return create_error_response("缺少 ids 参数", 400)
ids = data['ids']
if not isinstance(ids, list):
return create_error_response("ids 必须是列表", 400)
knowledge_manager = service_manager.get_assistant().knowledge_manager
success_count = 0
fail_count = 0
for knowledge_id in ids:
if knowledge_manager.unverify_knowledge_entry(knowledge_id):
success_count += 1
else:
fail_count += 1
return create_success_response(f"批量取消验证完成: 成功 {success_count} 条,失败 {fail_count}")
@knowledge_bp.route('/delete/<int:knowledge_id>', methods=['DELETE'])
@handle_api_errors
def delete_knowledge(knowledge_id):

View File

@@ -478,19 +478,42 @@ def import_workorders():
# 解析Excel文件
try:
df = pd.read_excel(upload_path)
if df is None or df.empty:
return jsonify({"error": "Excel文件内容为空或无法识别表格结构"}), 400
imported_workorders = []
skipped_rows = 0
# 处理每一行数据
for index, row in df.iterrows():
# 根据Excel列名映射到工单字段
title = str(row.get('标题', row.get('title', f'导入工单 {index + 1}')))
description = str(row.get('描述', row.get('description', '')))
category = str(row.get('分类', row.get('category', '技术问题')))
priority = str(row.get('优先级', row.get('priority', 'medium')))
status = str(row.get('状态', row.get('status', 'open')))
# 定义候选列名映射
col_mappings = {
'title': ['标题', 'title', '工单标题', '问题主体', 'Subject'],
'description': ['描述', 'description', '问题描述', '详细内容', 'Detail'],
'category': ['分类', 'category', '业务分类', '模块', 'Type'],
'priority': ['优先级', 'priority', '紧急程度', 'Level', 'Priority'],
'status': ['状态', 'status', '工单状态', 'State'],
'resolution': ['解决方案', 'resolution', '处理结果', 'Solution'],
'satisfaction': ['满意度', 'satisfaction_score', 'rating', 'Score']
}
def get_val(row_data, keys, default=''):
for k in keys:
if k in row_data:
val = row_data[k]
if pd.notna(val):
return str(val)
return default
title = get_val(row, col_mappings['title'], f'导入工单 {index + 1}')
description = get_val(row, col_mappings['description'], '')
category = get_val(row, col_mappings['category'], '技术问题')
priority = get_val(row, col_mappings['priority'], 'medium')
status = get_val(row, col_mappings['status'], 'open')
# 验证必填字段
if not title or title.strip() == '':
skipped_rows += 1
continue
# 生成唯一的工单ID
@@ -513,12 +536,14 @@ def import_workorders():
)
# 处理可选字段
if pd.notna(row.get('解决方案', row.get('resolution'))):
workorder.resolution = str(row.get('解决方案', row.get('resolution')))
res_val = get_val(row, col_mappings['resolution'], None)
if res_val:
workorder.resolution = res_val
if pd.notna(row.get('满意度', row.get('satisfaction_score'))):
score_val = get_val(row, col_mappings['satisfaction'], None)
if score_val:
try:
workorder.satisfaction_score = int(row.get('满意度', row.get('satisfaction_score')))
workorder.satisfaction_score = int(float(score_val))
except (ValueError, TypeError):
workorder.satisfaction_score = None
@@ -551,8 +576,9 @@ def import_workorders():
return jsonify({
"success": True,
"message": f"成功导入 {len(imported_workorders)} 个工单",
"message": f"成功导入 {len(imported_workorders)} 个工单" + (f",跳过 {skipped_rows} 行无效数据" if skipped_rows > 0 else ""),
"imported_count": len(imported_workorders),
"skipped_count": skipped_rows,
"workorders": imported_workorders
})

View File

@@ -153,9 +153,17 @@ body {
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.health-dot.normal {
@@ -176,8 +184,13 @@ body {
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 可点击统计数字样式 */
@@ -427,8 +440,13 @@ body {
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@@ -493,9 +511,11 @@ body {
0% {
box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(40, 167, 69, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(40, 167, 69, 0);
}
@@ -863,8 +883,27 @@ body {
.preset-preview .preview-param span {
color: #6c757d;
font-size: 0.8rem;
font-style: italic;
}
/* 历史对话字体深度统一补丁 */
.conversation-item,
.conversation-item h6,
.conversation-item p,
.conversation-item span,
.conversation-item small,
.conversation-item strong,
#conversation-list * {
font-family: var(--font-family-primary) !important;
}
.conversation-preview p {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--text-secondary);
}
/* AI建议与人工描述优化样式 */
.ai-suggestion-section {
background: linear-gradient(135deg, #f8f9ff, #e8f2ff);
@@ -1092,8 +1131,13 @@ body {
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
0% {
left: -100%;
}
100% {
left: 100%;
}
}
/* 按钮加载状态 */
@@ -1122,9 +1166,17 @@ body {
}
@keyframes successPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
/* 工具提示优化 */
@@ -1206,64 +1258,19 @@ body {
}
}
/* f<EFBFBD><EFBFBD><EFBFBD>pencaSGr7h_ */
.vehicle-data-card {
background: linear-gradient(135deg, #e8f5e8, #f0f8f0);
border: 1px solid #4caf50;
border-radius: 10px;
margin: 10px 0;
padding: 15px;
box-shadow: 0 2px 10px rgba(76, 175, 80, 0.1);
}
.vehicle-data-header {
border-bottom: 1px solid #4caf50;
padding-bottom: 10px;
margin-bottom: 15px;
}
.vehicle-data-header h5 {
color: #2e7d32;
margin: 0;
font-size: 1.1rem;
}
.vehicle-data-content {
padding: 0;
}
.vehicle-info {
background: white;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
border-left: 4px solid #4caf50;
}
.vehicle-info h6 {
color: #1976d2;
margin-bottom: 8px;
font-size: 1rem;
}
.vehicle-details p {
margin: 5px 0;
font-size: 0.9rem;
color: #333;
}
.vehicle-details strong {
color: #2e7d32;
}
.vehicle-error {
background: #ffebee;
color: #c62828;
padding: 10px;
border-radius: 5px;
border-left: 4px solid #f44336;
font-style: italic;
}
/* 历史对话字体深度统一补丁 - V2 (修复编码) */
.conversation-item,
.conversation-item h6,
.conversation-item p,
.conversation-item span,
.conversation-item small,
.conversation-item strong,
#conversation-list * {
font-family: var(--font-family-primary) !important;
}
.conversation-preview p {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--text-secondary);
}

View File

@@ -196,7 +196,10 @@ class TSPDashboard {
this.paginationConfig = {
defaultPageSize: 10,
pageSizeOptions: [5, 10, 20, 50],
maxVisiblePages: 5
maxVisiblePages: 5,
currentKnowledgePage: 1, // 追踪知识库当前页
currentWorkOrderPage: 1, // 追踪工单当前页
currentConversationPage: 1 // 追踪对话历史当前页
};
this.init();
@@ -2076,6 +2079,7 @@ class TSPDashboard {
// 知识库管理
async loadKnowledge(page = 1) {
this.paginationConfig.currentKnowledgePage = page;
try {
const pageSize = this.getPageSize('knowledge-pagination');
const response = await fetch(`/api/knowledge?page=${page}&per_page=${pageSize}`);
@@ -2317,7 +2321,7 @@ class TSPDashboard {
const data = await response.json();
if (data.success) {
this.showNotification('知识库验证成功', 'success');
this.loadKnowledge();
this.loadKnowledge(this.paginationConfig.currentKnowledgePage);
} else {
this.showNotification('知识库验证失败', 'error');
}
@@ -2339,7 +2343,7 @@ class TSPDashboard {
const data = await response.json();
if (data.success) {
this.showNotification('取消验证成功', 'success');
this.loadKnowledge();
this.loadKnowledge(this.paginationConfig.currentKnowledgePage);
} else {
this.showNotification('取消验证失败', 'error');
}
@@ -2361,8 +2365,8 @@ class TSPDashboard {
const data = await response.json();
if (data.success) {
this.showNotification('知识库删除成功', 'success');
this.loadKnowledge();
this.showNotification('知识库条目已删除', 'success');
this.loadKnowledge(this.paginationConfig.currentKnowledgePage);
} else {
this.showNotification('知识库删除失败', 'error');
}
@@ -2440,6 +2444,7 @@ class TSPDashboard {
// 工单管理
async loadWorkOrders(page = 1, forceRefresh = false) {
this.paginationConfig.currentWorkOrderPage = page;
const cacheKey = `workorders_page_${page}`;
if (!forceRefresh && this.cache.has(cacheKey)) {
@@ -2936,8 +2941,8 @@ class TSPDashboard {
if (data.success) {
this.showNotification('工单删除成功', 'success');
// 立即刷新工单列表和统计
await this.loadWorkOrders();
// 立即刷新工单列表和统计 (使用当前页码并强制刷新)
await this.loadWorkOrders(this.paginationConfig.currentWorkOrderPage, true);
await this.loadAnalytics();
} else {
this.showNotification('删除工单失败: ' + (data.error || '未知错误'), 'error');
@@ -3233,12 +3238,37 @@ class TSPDashboard {
return;
}
const html = conversations.map(conv => `
const html = conversations.map(conv => {
// 识别来源和类型
let sourceBadge = '';
const method = conv.invocation_method || '';
const userId = conv.user_id || '';
// 判断是否来自飞书 (根据调用方式或ID格式)
if (method.includes('feishu') || userId.startsWith('ou_')) {
sourceBadge = '<span class="badge bg-purple me-1">飞书</span>';
} else {
sourceBadge = '<span class="badge bg-blue me-1">Web</span>';
}
// 判断群聊/私聊
let typeBadge = '';
if (method.includes('group')) {
typeBadge = '<span class="badge bg-azure me-1">群聊</span>';
} else if (method.includes('p2p')) {
typeBadge = '<span class="badge bg-indigo me-1">私聊</span>';
}
return `
<div class="card mb-3 conversation-item" data-conversation-id="${conv.id}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="mb-1">用户: ${conv.user_id || '匿名'}</h6>
<h6 class="mb-1" style="font-family: var(--font-family-primary); font-weight: 600;">
${sourceBadge}
${typeBadge}
用户: ${userId || '匿名'}
</h6>
<small class="text-muted">${new Date(conv.timestamp).toLocaleString()}</small>
</div>
<div class="btn-group btn-group-sm">
@@ -3260,7 +3290,8 @@ class TSPDashboard {
</div>
</div>
</div>
`).join('');
`;
}).join('');
container.innerHTML = html;
}

View File

@@ -155,8 +155,9 @@ class App {
// 映射路由到页面文件
const pageFile = this.getPageFile(route.name);
// 动态导入页面组件
const pageModule = await import(`./pages/${pageFile}.js`);
// 动态导入页面组件 (添加版本号防止缓存)
const version = '1.0.2';
const pageModule = await import(`./pages/${pageFile}.js?v=${version}`);
const PageComponent = pageModule.default;
// 实例化页面组件

View File

@@ -6,28 +6,668 @@ 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">
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<h1 class="page-title">知识库</h1>
<p class="page-subtitle">知识条目管理</p>
<p class="page-subtitle">管理和维护知识条目</p>
</div>
<div class="card">
<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="text-center py-5">
<i class="fas fa-book fa-3x text-muted mb-3"></i>
<h4 class="text-muted">知识库页面</h4>
<p class="text-muted">该功能正在开发中...</p>
<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);
}
});
});
}
}

View File

@@ -108,6 +108,88 @@ export default class WorkOrders {
</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>
`;
}
@@ -302,9 +384,112 @@ export default class WorkOrders {
}
// 全局函数供表格操作使用
window.viewWorkOrder = function(id) {
if (window.showToast) {
window.showToast(`查看工单 ${id} 功能开发中`, 'info');
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);
}
};
@@ -315,11 +500,40 @@ window.editWorkOrder = function(id) {
};
window.deleteWorkOrder = function(id) {
if (confirm(`确定要删除工单 ${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('删除功能开发中', 'info');
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) {
@@ -327,3 +541,9 @@ 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);
});

View File

@@ -10,8 +10,8 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='css/design-system.css') }}?v=1.0.0" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}?v=1.0.0" rel="stylesheet">
<link href="{{ url_for('static', filename='css/design-system.css') }}?v=1.0.1" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}?v=1.0.1" rel="stylesheet">
<style>
.sidebar {

View File

@@ -126,7 +126,7 @@ def main():
websocket_thread = threading.Thread(target=start_websocket_server, daemon=True)
websocket_thread.start()
# 在单独线程中启动飞书长连接服务
# 在单独线程中启动飞书长连接服务已添加消息去重可与Webhook共存
feishu_thread = threading.Thread(target=start_feishu_longconn_service, daemon=True)
feishu_thread.start()

Binary file not shown.