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" PLANNING = "planning"
EXECUTING = "executing" EXECUTING = "executing"
LEARNING = "learning" LEARNING = "learning"
PROCESSING = "processing"
ERROR = "error" ERROR = "error"
class AgentCore: class AgentCore:

View File

@@ -6,10 +6,12 @@ TSP Agent助手 - 简化版本
import logging import logging
import asyncio import asyncio
import json
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from datetime import datetime from datetime import datetime
from src.config.unified_config import get_config from src.config.unified_config import get_config
from src.agent.llm_client import LLMManager from src.agent.llm_client import LLMManager
from src.web.service_manager import service_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -304,22 +306,65 @@ class TSPAgentAssistant:
logger.error(f"获取LLM使用统计失败: {e}") logger.error(f"获取LLM使用统计失败: {e}")
return {} 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", async def process_message_agent(self, message: str, user_id: str = "admin",
work_order_id: Optional[int] = None, work_order_id: Optional[int] = None,
enable_proactive: bool = True) -> Dict[str, Any]: enable_proactive: bool = True) -> Dict[str, Any]:
"""处理消息""" """处理消息 (实战化)"""
try: 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 { return {
"success": True, "success": True,
"message": f"Agent收到消息: {message}", "response": response_text,
"actions": actions,
"user_id": user_id, "user_id": user_id,
"work_order_id": work_order_id, "work_order_id": work_order_id,
"status": "completed",
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
except Exception as e: except Exception as e:
logger.error(f"处理消息失败: {e}") logger.error(f"处理消息失败: {e}")
return {"error": str(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]: async def trigger_sample_actions(self) -> Dict[str, Any]:
"""触发示例动作""" """触发示例动作"""
@@ -336,7 +381,7 @@ class TSPAgentAssistant:
logger.error(f"触发示例动作失败: {e}") logger.error(f"触发示例动作失败: {e}")
return {"success": False, "error": str(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: try:
import os import os
@@ -356,20 +401,39 @@ class TSPAgentAssistant:
logger.info(f"文件读取成功: {filename}, 字符数={len(content)}") logger.info(f"文件读取成功: {filename}, 字符数={len(content)}")
# 使用简化的知识提取 # 使用LLM进行知识提取 (异步调用)
logger.info(f"正在对文件内容进行 AI 知识提取...") 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)} 个潜在条目") logger.info(f"知识提取完成: 共提取出 {len(knowledge_entries)} 个潜在条目")
# 保存到知识库 # 保存到知识库
saved_count = 0 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): for i, entry in enumerate(knowledge_entries):
try: try:
logger.info(f"正在保存知识条目 [{i+1}/{len(knowledge_entries)}]: {entry.get('question', '')[:30]}...") logger.info(f"正在保存知识条目 [{i+1}/{len(knowledge_entries)}]: {entry.get('question', '')[:30]}...")
# 这里在实际项目中应当注入知识库管理器的保存逻辑
# 但在当前简化版本中仅记录日志 if knowledge_manager:
saved_count += 1 # 实际保存到数据库
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: except Exception as save_error:
logger.error(f"保存知识条目 {i+1} 时出错: {save_error}") logger.error(f"保存知识条目 {i+1} 时出错: {save_error}")
@@ -402,26 +466,71 @@ class TSPAgentAssistant:
logger.error(f"读取文件失败: {e}") logger.error(f"读取文件失败: {e}")
return "" 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: try:
# 简化的知识提取逻辑 # 限制内容长度避免超出token限制
entries = [] # 假设每个汉字2个token保留前8000个字符作为上下文
truncated_content = content[:8000]
# 按段落分割内容 if len(content) > 8000:
paragraphs = content.split('\n\n') truncated_content += "\n...(后续内容已省略)"
for i, paragraph in enumerate(paragraphs[:5]): # 最多提取5个 prompt = f"""
if len(paragraph.strip()) > 20: # 过滤太短的段落 你是一个专业的知识库构建助手。请分析以下文档内容,提取出关键的"问题""答案"对,用于构建知识库。
entries.append({
"question": f"关于{filename}的问题{i+1}", 文档文件名:{filename}
"answer": paragraph.strip(), 文档内容:
"category": "文档知识", {truncated_content}
"confidence_score": 0.7
要求:
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: except Exception as e:
logger.error(f"提取知识失败: {e}") logger.error(f"提取知识失败: {e}")
return [] return []

View File

@@ -161,13 +161,36 @@ class CacheManager:
"""淘汰最旧的缓存""" """淘汰最旧的缓存"""
if not self.memory_cache: if not self.memory_cache:
return return
oldest_key = min( oldest_key = min(
self.memory_cache.keys(), self.memory_cache.keys(),
key=lambda k: self.memory_cache[k]['created_at'] key=lambda k: self.memory_cache[k]['created_at']
) )
del self.memory_cache[oldest_key] 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]: def get_stats(self) -> Dict[str, Any]:
"""获取缓存统计信息""" """获取缓存统计信息"""
with self.cache_lock: with self.cache_lock:
@@ -218,11 +241,10 @@ class DatabaseCache:
logger.debug(f"缓存未命中: {cache_key}") logger.debug(f"缓存未命中: {cache_key}")
result = func(*args, **kwargs) result = func(*args, **kwargs)
self.cache_manager.set(cache_key, result, self.ttl) self.cache_manager.set(cache_key, result, self.ttl)
return result return result
return wrapper return wrapper
# 全局缓存管理器实例 # 全局缓存管理器实例
cache_manager = CacheManager() cache_manager = CacheManager()

View File

@@ -92,7 +92,9 @@ class QueryOptimizer:
'confidence_score': conv.confidence_score, 'confidence_score': conv.confidence_score,
'work_order_id': conv.work_order_id, 'work_order_id': conv.work_order_id,
'ip_address': conv.ip_address, '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: if is_first_suggestion:
prompt += """ prompt += """
要求: 要求:
1. 详细分析问题描述,识别可能的根本原因 1. 输出格式必须严格遵守:"时间:当前问题现状,下一步做法"
2. 基于当前处理进度,判断问题处于哪个阶段 例如:"0210无TBOX登录记录需要用户进站抓取TBOX日志分析。"
3. 提供具体的排查步骤和技术指导 2. "时间"使用当前的MMDD格式如0210
4. 建议需要收集哪些技术信息(如日志、配置、版本等) 3. "当前问题现状"要基于问题描述和处理记录总结,简明扼要
5. 如果需要,可以建议进站处理的具体项目 4. "下一步做法"如果是首次建议,只提供远程排查步骤(如检查网络、重启等),不要提进站
6. 语言专业,包含技术细节,方便技术人员理解 5. 总字数控制在100字以内不要分行不要列点
7. 建议格式要清晰,便于执行和跟踪 6. 绝对不要使用"建议您""您可以"等客套话,直接陈述做法
提供完整的分析和建议:""" 按照上述格式生成建议:"""
else: else:
prompt += """ prompt += """
要求: 要求:
1. 基于已有处理记录和当前进度,分析问题进展情况 1. 输出格式必须严格遵守:"时间:当前问题现状,下一步做法"
2. 判断之前的处理步骤是否有效,找出可能的遗漏点 例如:"0210无TBOX登录记录需要用户进站抓取TBOX日志分析。"
3. 根据问题发展阶段提供更深入的技术解决方案 2. "时间"使用当前的MMDD格式如0210
4. 如果远程处理无效,明确说明需要哪些线下技术支持 3. "当前问题现状"要简述之前的处理结果(如远程无效)
5. 详细说明进站后需要执行的具体诊断和修复步骤 4. "下一步做法"可以包含进站抓取日志、联系技术支持等深入方案
6. 包含技术参数、工具要求和注意事项 5. 总字数控制在100字以内不要分行不要列点
7. 便于技术人员快速理解问题状态和下一步行动 6. 绝对不要使用"建议您""您可以"等客套话,直接陈述做法
提供针对性的深入分析和处理建议:""" 按照上述格式生成建议:"""
return prompt 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.config.unified_config import get_config
from src.web.service_manager import service_manager from src.web.service_manager import service_manager
from src.core.cache_manager import cache_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,15 +67,30 @@ class FeishuLongConnService:
message_id = message.message_id message_id = message.message_id
chat_id = message.chat_id chat_id = message.chat_id
message_type = message.message_type message_type = message.message_type
chat_type = getattr(message, 'chat_type', 'unknown') # 获取会话类型
content = message.content content = message.content
sender = event.sender 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: {message_id}")
logger.info(f" - 群聊ID: {chat_id}") logger.info(f" - 会话类型: {'群聊(group)' if chat_type == 'group' else '私聊(p2p)' if chat_type == 'p2p' else chat_type}")
logger.info(f" - 发送者ID: {sender.sender_id.user_id}") 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" - 消息类型: {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": if message_type != "text":
@@ -145,8 +161,8 @@ class FeishuLongConnService:
response_data = chat_manager.process_message( response_data = chat_manager.process_message(
session_id=session_id, session_id=session_id,
user_message=text_content, user_message=text_content,
ip_address=None, ip_address=f"Feishu:{sender_id}",
invocation_method="feishu_longconn" invocation_method=f"Feishu({chat_type})"
) )
logger.info(f"📊 处理结果: {response_data.get('success')}") logger.info(f"📊 处理结果: {response_data.get('success')}")

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import threading
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from src.integrations.feishu_service import FeishuService from src.integrations.feishu_service import FeishuService
from src.web.service_manager import service_manager from src.web.service_manager import service_manager
from src.core.cache_manager import cache_manager
# 初始化日志 # 初始化日志
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,11 +36,21 @@ def _process_message_in_background(app, event_data: dict):
message_id = message.get('message_id') message_id = message.get('message_id')
chat_id = message.get('chat_id') chat_id = message.get('chat_id')
chat_type = message.get('chat_type', 'unknown')
if not message_id or not chat_id: if not message_id or not chat_id:
logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}") logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}")
return 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字符串,需要再次解析 # 内容是一个JSON字符串,需要再次解析
try: try:
content_json = json.loads(message.get('content', '{}')) content_json = json.loads(message.get('content', '{}'))

View File

@@ -11,6 +11,7 @@ import logging
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from src.agent_assistant import TSPAgentAssistant from src.agent_assistant import TSPAgentAssistant
from src.web.service_manager import service_manager 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 from src.web.error_handlers import handle_api_errors, create_error_response, create_success_response
knowledge_bp = Blueprint('knowledge', __name__, url_prefix='/api/knowledge') knowledge_bp = Blueprint('knowledge', __name__, url_prefix='/api/knowledge')
@@ -98,7 +99,16 @@ def upload_knowledge_file():
try: try:
file.save(temp_path) file.save(temp_path)
assistant = get_agent_assistant() 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) return jsonify(result)
finally: finally:
try: try:
@@ -108,6 +118,78 @@ def upload_knowledge_file():
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning(f"清理临时文件失败: {cleanup_error}") 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']) @knowledge_bp.route('/delete/<int:knowledge_id>', methods=['DELETE'])
@handle_api_errors @handle_api_errors
def delete_knowledge(knowledge_id): def delete_knowledge(knowledge_id):

View File

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

View File

@@ -20,7 +20,7 @@ body {
.btn-group .btn:hover { .btn-group .btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
} }
/* 小尺寸按钮优化 */ /* 小尺寸按钮优化 */
@@ -97,7 +97,7 @@ body {
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background-color: rgba(0,0,0,0.8); background-color: rgba(0, 0, 0, 0.8);
color: white; color: white;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -153,9 +153,17 @@ body {
} }
@keyframes pulse { @keyframes pulse {
0% { opacity: 1; } 0% {
50% { opacity: 0.5; } opacity: 1;
100% { opacity: 1; } }
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
} }
.health-dot.normal { .health-dot.normal {
@@ -176,8 +184,13 @@ body {
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
/* 可点击统计数字样式 */ /* 可点击统计数字样式 */
@@ -239,23 +252,23 @@ body {
height: 1.75rem; height: 1.75rem;
padding: 0.25rem 0.375rem; padding: 0.25rem 0.375rem;
} }
.btn-sm i { .btn-sm i {
font-size: 0.75rem; font-size: 0.75rem;
} }
.btn-group .btn:not(:last-child) { .btn-group .btn:not(:last-child) {
margin-right: 0.125rem; margin-right: 0.125rem;
} }
.table .btn-group .btn { .table .btn-group .btn {
margin: 0 0.0625rem; margin: 0 0.0625rem;
} }
.clickable-stat { .clickable-stat {
font-size: 1.5rem; font-size: 1.5rem;
} }
.modal-xl { .modal-xl {
max-width: 95vw; max-width: 95vw;
} }
@@ -321,7 +334,7 @@ body {
.alert-card:hover { .alert-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
} }
.alert-card.critical { .alert-card.critical {
@@ -401,7 +414,7 @@ body {
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
/* 按钮样式 */ /* 按钮样式 */
@@ -427,8 +440,13 @@ body {
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
/* 响应式设计 */ /* 响应式设计 */
@@ -436,13 +454,13 @@ body {
.container-fluid { .container-fluid {
padding: 0 15px; padding: 0 15px;
} }
.score-circle { .score-circle {
width: 80px; width: 80px;
height: 80px; height: 80px;
font-size: 1.2rem; font-size: 1.2rem;
} }
.card-body { .card-body {
padding: 1rem; padding: 1rem;
} }
@@ -493,9 +511,11 @@ body {
0% { 0% {
box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.7); box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.7);
} }
70% { 70% {
box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); box-shadow: 0 0 0 10px rgba(40, 167, 69, 0);
} }
100% { 100% {
box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); box-shadow: 0 0 0 0 rgba(40, 167, 69, 0);
} }
@@ -505,7 +525,7 @@ body {
.modal-content { .modal-content {
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
box-shadow: 0 10px 30px rgba(0,0,0,0.3); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
} }
.modal-header { .modal-header {
@@ -520,7 +540,7 @@ body {
/* 表格样式 */ /* 表格样式 */
.table-hover tbody tr:hover { .table-hover tbody tr:hover {
background-color: rgba(0,123,255,0.1); background-color: rgba(0, 123, 255, 0.1);
} }
/* 空状态样式 */ /* 空状态样式 */
@@ -649,20 +669,20 @@ body {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.alert-controls .form-select { .alert-controls .form-select {
min-width: auto; min-width: auto;
} }
.alert-card .d-flex { .alert-card .d-flex {
flex-direction: column; flex-direction: column;
} }
.alert-card .ms-3 { .alert-card .ms-3 {
margin-left: 0 !important; margin-left: 0 !important;
margin-top: 10px; margin-top: 10px;
} }
.alert-data { .alert-data {
font-size: 10px; font-size: 10px;
max-height: 80px; max-height: 80px;
@@ -700,7 +720,7 @@ body {
.preset-card:hover { .preset-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #007bff; border-color: #007bff;
} }
@@ -796,15 +816,15 @@ body {
.preset-card .card-body { .preset-card .card-body {
padding: 1rem; padding: 1rem;
} }
.preset-card h6 { .preset-card h6 {
font-size: 0.9rem; font-size: 0.9rem;
} }
.preset-card p { .preset-card p {
font-size: 0.8rem; font-size: 0.8rem;
} }
.preset-params .badge { .preset-params .badge {
font-size: 0.65rem; font-size: 0.65rem;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
@@ -863,8 +883,27 @@ body {
.preset-preview .preview-param span { .preset-preview .preview-param span {
color: #6c757d; color: #6c757d;
font-size: 0.8rem; 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建议与人工描述优化样式 */
.ai-suggestion-section { .ai-suggestion-section {
background: linear-gradient(135deg, #f8f9ff, #e8f2ff); background: linear-gradient(135deg, #f8f9ff, #e8f2ff);
@@ -1087,13 +1126,18 @@ body {
left: -100%; left: -100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: shimmer 1.5s infinite; animation: shimmer 1.5s infinite;
} }
@keyframes shimmer { @keyframes shimmer {
0% { left: -100%; } 0% {
100% { left: 100%; } left: -100%;
}
100% {
left: 100%;
}
} }
/* 按钮加载状态 */ /* 按钮加载状态 */
@@ -1122,9 +1166,17 @@ body {
} }
@keyframes successPulse { @keyframes successPulse {
0% { transform: scale(1); } 0% {
50% { transform: scale(1.05); } transform: scale(1);
100% { transform: scale(1); } }
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
} }
/* 工具提示优化 */ /* 工具提示优化 */
@@ -1176,94 +1228,49 @@ body {
padding: 15px; padding: 15px;
margin: 10px 0; margin: 10px 0;
} }
.ai-suggestion-header { .ai-suggestion-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
} }
.similarity-indicator { .similarity-indicator {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
} }
.action-buttons { .action-buttons {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.save-human-btn, .save-human-btn,
.approve-btn { .approve-btn {
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.tooltip-custom::before { .tooltip-custom::before {
font-size: 0.7rem; font-size: 0.7rem;
padding: 6px 10px; padding: 6px 10px;
} }
} }
/* 历史对话字体深度统一补丁 - V2 (修复编码) */
/* f<EFBFBD><EFBFBD><EFBFBD>pencaSGr7h_ */ .conversation-item,
.vehicle-data-card { .conversation-item h6,
background: linear-gradient(135deg, #e8f5e8, #f0f8f0); .conversation-item p,
border: 1px solid #4caf50; .conversation-item span,
border-radius: 10px; .conversation-item small,
margin: 10px 0; .conversation-item strong,
padding: 15px; #conversation-list * {
box-shadow: 0 2px 10px rgba(76, 175, 80, 0.1); font-family: var(--font-family-primary) !important;
} }
.vehicle-data-header { .conversation-preview p {
border-bottom: 1px solid #4caf50; font-size: var(--font-size-sm);
padding-bottom: 10px; line-height: var(--line-height-relaxed);
margin-bottom: 15px; color: var(--text-secondary);
} }
.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;
}

View File

@@ -196,7 +196,10 @@ class TSPDashboard {
this.paginationConfig = { this.paginationConfig = {
defaultPageSize: 10, defaultPageSize: 10,
pageSizeOptions: [5, 10, 20, 50], pageSizeOptions: [5, 10, 20, 50],
maxVisiblePages: 5 maxVisiblePages: 5,
currentKnowledgePage: 1, // 追踪知识库当前页
currentWorkOrderPage: 1, // 追踪工单当前页
currentConversationPage: 1 // 追踪对话历史当前页
}; };
this.init(); this.init();
@@ -2076,6 +2079,7 @@ class TSPDashboard {
// 知识库管理 // 知识库管理
async loadKnowledge(page = 1) { async loadKnowledge(page = 1) {
this.paginationConfig.currentKnowledgePage = page;
try { try {
const pageSize = this.getPageSize('knowledge-pagination'); const pageSize = this.getPageSize('knowledge-pagination');
const response = await fetch(`/api/knowledge?page=${page}&per_page=${pageSize}`); const response = await fetch(`/api/knowledge?page=${page}&per_page=${pageSize}`);
@@ -2317,7 +2321,7 @@ class TSPDashboard {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
this.showNotification('知识库验证成功', 'success'); this.showNotification('知识库验证成功', 'success');
this.loadKnowledge(); this.loadKnowledge(this.paginationConfig.currentKnowledgePage);
} else { } else {
this.showNotification('知识库验证失败', 'error'); this.showNotification('知识库验证失败', 'error');
} }
@@ -2339,7 +2343,7 @@ class TSPDashboard {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
this.showNotification('取消验证成功', 'success'); this.showNotification('取消验证成功', 'success');
this.loadKnowledge(); this.loadKnowledge(this.paginationConfig.currentKnowledgePage);
} else { } else {
this.showNotification('取消验证失败', 'error'); this.showNotification('取消验证失败', 'error');
} }
@@ -2361,8 +2365,8 @@ class TSPDashboard {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
this.showNotification('知识库删除成功', 'success'); this.showNotification('知识库条目已删除', 'success');
this.loadKnowledge(); this.loadKnowledge(this.paginationConfig.currentKnowledgePage);
} else { } else {
this.showNotification('知识库删除失败', 'error'); this.showNotification('知识库删除失败', 'error');
} }
@@ -2440,6 +2444,7 @@ class TSPDashboard {
// 工单管理 // 工单管理
async loadWorkOrders(page = 1, forceRefresh = false) { async loadWorkOrders(page = 1, forceRefresh = false) {
this.paginationConfig.currentWorkOrderPage = page;
const cacheKey = `workorders_page_${page}`; const cacheKey = `workorders_page_${page}`;
if (!forceRefresh && this.cache.has(cacheKey)) { if (!forceRefresh && this.cache.has(cacheKey)) {
@@ -2936,8 +2941,8 @@ class TSPDashboard {
if (data.success) { if (data.success) {
this.showNotification('工单删除成功', 'success'); this.showNotification('工单删除成功', 'success');
// 立即刷新工单列表和统计 // 立即刷新工单列表和统计 (使用当前页码并强制刷新)
await this.loadWorkOrders(); await this.loadWorkOrders(this.paginationConfig.currentWorkOrderPage, true);
await this.loadAnalytics(); await this.loadAnalytics();
} else { } else {
this.showNotification('删除工单失败: ' + (data.error || '未知错误'), 'error'); this.showNotification('删除工单失败: ' + (data.error || '未知错误'), 'error');
@@ -3233,12 +3238,37 @@ class TSPDashboard {
return; 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 mb-3 conversation-item" data-conversation-id="${conv.id}">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
<div> <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> <small class="text-muted">${new Date(conv.timestamp).toLocaleString()}</small>
</div> </div>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
@@ -3260,7 +3290,8 @@ class TSPDashboard {
</div> </div>
</div> </div>
</div> </div>
`).join(''); `;
}).join('');
container.innerHTML = html; container.innerHTML = html;
} }

View File

@@ -155,8 +155,9 @@ class App {
// 映射路由到页面文件 // 映射路由到页面文件
const pageFile = this.getPageFile(route.name); 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; const PageComponent = pageModule.default;
// 实例化页面组件 // 实例化页面组件

View File

@@ -6,28 +6,668 @@ export default class Knowledge {
constructor(container, route) { constructor(container, route) {
this.container = container; this.container = container;
this.route = route; this.route = route;
this.currentPage = 1; // 初始化当前页码
this.init(); this.init();
} }
async init() { async init() {
this.render(); 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() { render() {
this.container.innerHTML = ` this.container.innerHTML = `
<div class="page-header"> <div class="page-header d-flex justify-content-between align-items-center">
<h1 class="page-title">知识库</h1> <div>
<p class="page-subtitle">知识条目管理</p> <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>
<!-- 统计卡片 -->
<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">
<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="card-body">
<div class="text-center py-5"> <div class="table-responsive">
<i class="fas fa-book fa-3x text-muted mb-3"></i> <table class="table table-hover table-vcenter">
<h4 class="text-muted">知识库页面</h4> <thead>
<p class="text-muted">该功能正在开发中...</p> <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> </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> </nav>
</div> </div>
</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) { window.viewWorkOrder = async function(id) {
if (window.showToast) { try {
window.showToast(`查看工单 ${id} 功能开发中`, 'info'); // 显示模态框(先显示加载状态)
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,15 +500,50 @@ window.editWorkOrder = function(id) {
}; };
window.deleteWorkOrder = function(id) { window.deleteWorkOrder = function(id) {
if (confirm(`确定要删除工单 ${id} 吗?`)) { if (!confirm(`确定要删除工单 ${id} 吗?`)) {
if (window.showToast) { return;
window.showToast('删除功能开发中', 'info');
}
} }
(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) { window.changePage = function(page) {
// 重新加载当前页面实例 // 重新加载当前页面实例
const event = new CustomEvent('changePage', { detail: { page } }); const event = new CustomEvent('changePage', { detail: { page } });
document.dispatchEvent(event); 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://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://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="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/design-system.css') }}?v=1.0.1" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}?v=1.0.0" rel="stylesheet"> <link href="{{ url_for('static', filename='css/style.css') }}?v=1.0.1" rel="stylesheet">
<style> <style>
.sidebar { .sidebar {

View File

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

Binary file not shown.