Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Support Intel Pro - 智能客服洞察系统</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- Vue 3 --> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <!-- ECharts --> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> | |
| <!-- FontAwesome --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| body { font-family: 'Inter', system-ui, sans-serif; background-color: #f3f4f6; } | |
| .card { background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); } | |
| .status-new { background-color: #dbeafe; color: #1e40af; } | |
| .status-open { background-color: #fef3c7; color: #92400e; } | |
| .status-resolved { background-color: #d1fae5; color: #065f46; } | |
| .priority-high { color: #dc2626; font-weight: bold; } | |
| .priority-medium { color: #d97706; } | |
| .priority-low { color: #059669; } | |
| .fade-enter-active, .fade-leave-active { transition: opacity 0.5s; } | |
| .fade-enter-from, .fade-leave-to { opacity: 0; } | |
| .list-move { transition: transform 0.5s; } | |
| </style> | |
| </head> | |
| <body class="text-gray-800 bg-gray-50"> | |
| <div id="app" class="min-h-screen flex flex-col font-sans"> | |
| <!-- Header --> | |
| <header class="bg-white border-b border-gray-200 sticky top-0 z-50"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex justify-between items-center"> | |
| <div class="flex items-center gap-3"> | |
| <div class="bg-indigo-600 text-white p-2 rounded-lg"> | |
| <i class="fa-solid fa-headset text-xl"></i> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-bold text-gray-900 tracking-tight">Support Intel Pro</h1> | |
| <p class="text-xs text-gray-500">客服洞察 + 智能坐席 Agent</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <button @click="triggerUpload" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm"> | |
| <i class="fa-solid fa-cloud-upload"></i> 上传日志 | |
| </button> | |
| <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden"> | |
| <button @click="openAgentPanel" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm"> | |
| <i class="fa-solid fa-comments"></i> 智能客服 Agent | |
| </button> | |
| <button @click="simulateTraffic" :disabled="isSimulating" | |
| class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i :class="['fa-solid', isSimulating ? 'fa-spinner fa-spin' : 'fa-bolt']"></i> | |
| ${ isSimulating ? '模拟接入中...' : '模拟工单接入' } | |
| </button> | |
| <div class="hidden sm:flex items-center text-sm text-gray-500 bg-gray-100 px-3 py-1.5 rounded-full"> | |
| <i class="fa-regular fa-clock mr-2"></i> ${ currentTime } | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| <!-- Left Column: Stats & Feed (8 cols) --> | |
| <div class="lg:col-span-8 flex flex-col gap-6"> | |
| <!-- KPI Cards --> | |
| <div class="grid grid-cols-2 sm:grid-cols-4 gap-4"> | |
| <div class="card p-5 border-l-4 border-blue-500 flex flex-col justify-between hover:shadow-md transition-shadow"> | |
| <div class="text-sm text-gray-500 font-medium">待处理工单</div> | |
| <div class="mt-2 flex items-baseline gap-2"> | |
| <span class="text-3xl font-bold text-gray-900">${ stats.open_tickets }</span> | |
| <span class="text-xs text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">+${ newTicketsCount }</span> | |
| </div> | |
| </div> | |
| <div class="card p-5 border-l-4 border-red-500 flex flex-col justify-between hover:shadow-md transition-shadow"> | |
| <div class="text-sm text-gray-500 font-medium">高危/紧急</div> | |
| <div class="mt-2"> | |
| <span class="text-3xl font-bold text-red-600">${ stats.high_priority_risk }</span> | |
| </div> | |
| </div> | |
| <div class="card p-5 border-l-4 border-green-500 flex flex-col justify-between hover:shadow-md transition-shadow"> | |
| <div class="text-sm text-gray-500 font-medium">客户满意度</div> | |
| <div class="mt-2"> | |
| <span class="text-3xl font-bold text-gray-900">${ stats.customer_happiness }</span> | |
| <span class="text-sm text-gray-400">/100</span> | |
| </div> | |
| </div> | |
| <div class="card p-5 border-l-4 border-indigo-500 flex flex-col justify-between hover:shadow-md transition-shadow"> | |
| <div class="text-sm text-gray-500 font-medium">在线坐席</div> | |
| <div class="mt-2"> | |
| <span class="text-3xl font-bold text-gray-900">${ activeAgents }</span> | |
| <span class="text-sm text-gray-400">/5</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Ticket List --> | |
| <div class="card flex-1 flex flex-col min-h-[600px] border border-gray-200 shadow-sm overflow-hidden"> | |
| <div class="p-4 border-b border-gray-100 flex justify-between items-center bg-white"> | |
| <h2 class="font-bold text-gray-800 flex items-center gap-2"> | |
| <i class="fa-solid fa-list-ul text-indigo-500"></i> 实时工单流 | |
| </h2> | |
| <div class="flex items-center gap-2"> | |
| <span v-if="uploadStatus" class="text-sm text-green-600 animate-pulse mr-2"> | |
| <i class="fa-solid fa-check-circle"></i> ${ uploadStatus } | |
| </span> | |
| <span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full border border-gray-200"> | |
| <span class="w-2 h-2 bg-green-500 rounded-full inline-block mr-1"></span> Live | |
| </span> | |
| </div> | |
| </div> | |
| <div class="overflow-y-auto flex-1 p-4 bg-gray-50/50"> | |
| <transition-group name="list" tag="div" class="space-y-4"> | |
| <div v-for="ticket in tickets" :key="ticket.id" | |
| class="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-lg transition-all cursor-pointer relative group hover:border-indigo-200"> | |
| <div class="flex justify-between items-start mb-3"> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <span :class="['text-xs px-2.5 py-1 rounded-full font-semibold border', getPriorityClass(ticket.priority)]"> | |
| ${ ticket.priority === 'High' ? '🔥 ' : '' }${ ticket.priority } | |
| </span> | |
| <span class="text-xs bg-gray-100 text-gray-600 px-2.5 py-1 rounded-full border border-gray-200"> | |
| ${ ticket.category } | |
| </span> | |
| <span v-if="ticket.status === 'New'" class="text-xs bg-blue-100 text-blue-700 px-2.5 py-1 rounded-full border border-blue-200 animate-pulse"> | |
| New | |
| </span> | |
| </div> | |
| <span class="text-xs text-gray-400 font-mono">${ formatTime(ticket.created_at) }</span> | |
| </div> | |
| <h3 class="font-bold text-gray-900 text-lg mb-2 group-hover:text-indigo-600 transition-colors">${ ticket.subject }</h3> | |
| <p class="text-sm text-gray-600 mb-4 leading-relaxed line-clamp-3">${ ticket.content }</p> | |
| <div v-if="ticket.attachments && ticket.attachments.length > 0" class="mb-4 flex gap-2"> | |
| <div v-for="att in ticket.attachments" class="text-xs bg-gray-100 border px-2 py-1 rounded text-gray-600 flex items-center gap-1"> | |
| <i class="fa-solid fa-paperclip"></i> ${ att } | |
| </div> | |
| </div> | |
| <div class="flex justify-between items-center text-xs text-gray-500 border-t border-gray-100 pt-3"> | |
| <div class="flex items-center gap-4"> | |
| <div class="flex items-center gap-2"> | |
| <div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-xs font-bold text-gray-500"> | |
| ${ ticket.customer_name.charAt(0) } | |
| </div> | |
| <span class="font-medium">${ ticket.customer_name }</span> | |
| </div> | |
| <span v-if="ticket.sentiment_score < 0" class="text-red-500 font-medium bg-red-50 px-2 py-0.5 rounded"> | |
| <i class="fa-solid fa-face-frown mr-1"></i>不满 | |
| </span> | |
| <span v-else-if="ticket.sentiment_score > 0.5" class="text-green-500 font-medium bg-green-50 px-2 py-0.5 rounded"> | |
| <i class="fa-solid fa-face-smile mr-1"></i>愉快 | |
| </span> | |
| </div> | |
| <div class="flex items-center gap-3"> | |
| <span v-if="ticket.assigned_to" class="flex items-center gap-1.5 bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-full border border-indigo-100"> | |
| <i class="fa-solid fa-headset text-xs"></i> | |
| ${ ticket.assigned_to } | |
| </span> | |
| <button v-if="ticket.status !== 'Resolved'" @click.stop="resolveTicket(ticket.id)" | |
| class="bg-white hover:bg-green-50 text-gray-400 hover:text-green-600 border border-gray-200 hover:border-green-200 px-3 py-1 rounded-full transition-all shadow-sm"> | |
| <i class="fa-solid fa-check mr-1"></i>解决 | |
| </button> | |
| <span v-else class="text-green-600 font-medium flex items-center bg-green-50 px-3 py-1 rounded-full"> | |
| <i class="fa-solid fa-check-circle mr-1"></i>已解决 | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| </transition-group> | |
| <!-- Empty State --> | |
| <div v-if="tickets.length === 0" class="text-center py-20 text-gray-400"> | |
| <i class="fa-solid fa-inbox text-4xl mb-4 text-gray-300"></i> | |
| <p>暂无工单,点击右上角模拟接入</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Column: Charts (4 cols) --> | |
| <div class="lg:col-span-4 flex flex-col gap-6"> | |
| <!-- Agent Status --> | |
| <div class="card p-5 border border-gray-200 shadow-sm"> | |
| <h3 class="font-bold text-gray-800 mb-5 flex items-center justify-between"> | |
| <span><i class="fa-solid fa-users-gear mr-2 text-indigo-500"></i>坐席状态</span> | |
| <span class="text-xs bg-gray-100 px-2 py-1 rounded text-gray-500">Live Load</span> | |
| </h3> | |
| <div class="space-y-5"> | |
| <div v-for="agent in stats.agents" :key="agent.id" class="flex items-center justify-between group"> | |
| <div class="flex items-center gap-3"> | |
| <div class="relative"> | |
| <img v-if="agent.avatar" :src="agent.avatar" class="w-10 h-10 rounded-full bg-gray-100 border-2 border-white shadow-sm" alt="Avatar"> | |
| <div v-else class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-bold"> | |
| ${ agent.name.charAt(0) } | |
| </div> | |
| <span :class="['absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white', | |
| agent.status === 'online' ? 'bg-green-500' : (agent.status === 'busy' ? 'bg-orange-500' : 'bg-gray-400')]"> | |
| </span> | |
| </div> | |
| <div> | |
| <div class="text-sm font-bold text-gray-900">${ agent.name }</div> | |
| <div class="text-xs text-gray-500">${ agent.role }</div> | |
| </div> | |
| </div> | |
| <div class="text-right w-24"> | |
| <div class="flex justify-between text-xs text-gray-500 mb-1"> | |
| <span>负载</span> | |
| <span class="font-mono">${ agent.load }</span> | |
| </div> | |
| <div class="w-full h-2 bg-gray-100 rounded-full overflow-hidden"> | |
| <div :class="['h-full transition-all duration-500 rounded-full', agent.load > 3 ? 'bg-red-500' : 'bg-indigo-500']" | |
| :style="{ width: Math.min(agent.load * 20, 100) + '%' }"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Charts Grid --> | |
| <div class="card p-5 border border-gray-200 shadow-sm"> | |
| <h3 class="font-bold text-gray-800 mb-4 flex items-center"> | |
| <i class="fa-solid fa-chart-pie mr-2 text-purple-500"></i>情绪分布 | |
| </h3> | |
| <div id="sentimentChart" class="w-full h-56"></div> | |
| </div> | |
| <div class="card p-5 border border-gray-200 shadow-sm"> | |
| <h3 class="font-bold text-gray-800 mb-4 flex items-center"> | |
| <i class="fa-solid fa-layer-group mr-2 text-orange-500"></i>优先级占比 | |
| </h3> | |
| <div id="priorityChart" class="w-full h-56"></div> | |
| </div> | |
| <!-- System Info --> | |
| <div class="bg-gray-100 rounded-lg p-4 text-xs text-gray-500 text-center"> | |
| <p>Support Intel Pro v2.0.1</p> | |
| <p class="mt-1">Powered by Flask & Vue 3</p> | |
| </div> | |
| </div> | |
| </main> | |
| <div v-if="agentOpen" class="fixed inset-0 z-[100]"> | |
| <div class="absolute inset-0 bg-black/30" @click="closeAgentPanel"></div> | |
| <div class="absolute right-0 top-0 h-full w-full max-w-6xl bg-white shadow-2xl flex"> | |
| <div class="w-80 border-r border-gray-200 bg-gray-50 flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 bg-white"> | |
| <div class="flex items-start justify-between gap-3"> | |
| <div> | |
| <div class="font-bold text-gray-900">智能客服 Agent</div> | |
| <div class="text-xs text-gray-500">硅基流 ${ agentHealth.model || '' }</div> | |
| </div> | |
| <button @click="closeAgentPanel" class="text-gray-400 hover:text-gray-700"> | |
| <i class="fa-solid fa-xmark text-lg"></i> | |
| </button> | |
| </div> | |
| <div class="mt-3 flex items-center gap-2 text-xs"> | |
| <span :class="['inline-flex items-center gap-2 px-2 py-1 rounded-full border', agentHealth.hasKey ? 'bg-green-50 text-green-700 border-green-200' : 'bg-red-50 text-red-700 border-red-200']"> | |
| <span :class="['w-2 h-2 rounded-full', agentHealth.hasKey ? 'bg-green-500' : 'bg-red-500']"></span> | |
| ${ agentHealth.hasKey ? '服务正常' : '缺少 API Key' } | |
| </span> | |
| <span class="text-gray-400">${ agentHealth.baseUrl || '' }</span> | |
| </div> | |
| <button @click="agentNewConversation" class="mt-4 w-full bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"> | |
| <i class="fa-solid fa-plus"></i> 新建会话 | |
| </button> | |
| </div> | |
| <div class="px-4 pt-4 text-xs text-gray-500 font-medium">历史会话</div> | |
| <div class="p-4 pt-2 overflow-y-auto flex-1 space-y-2"> | |
| <button v-for="c in agentConversations" :key="c.id" | |
| @click="agentOpenConversation(c.id)" | |
| :class="['w-full text-left px-3 py-2 rounded-lg border transition-colors', agentConversationId === c.id ? 'bg-indigo-50 border-indigo-200' : 'bg-white border-gray-200 hover:border-indigo-200']"> | |
| <div class="text-sm font-semibold text-gray-900 truncate">${ c.title || '新会话' }</div> | |
| <div class="text-xs text-gray-500 mt-1">${ c.updatedAt ? formatAgentTime(c.updatedAt) : '' }</div> | |
| </button> | |
| <div v-if="agentConversations.length === 0" class="text-xs text-gray-400 text-center py-6"> | |
| 暂无会话记录 | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex-1 flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 flex items-center justify-between gap-3 bg-white"> | |
| <div> | |
| <div class="font-bold text-gray-900">${ agentTitle || '新会话' }</div> | |
| <div class="text-xs text-gray-500">${ agentMeta || '' }</div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button @click="agentExport" :disabled="!agentConversationId" | |
| class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-3 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i class="fa-solid fa-download"></i> 导出 JSON | |
| </button> | |
| </div> | |
| </div> | |
| <div ref="agentChatBox" class="flex-1 overflow-y-auto p-5 bg-gray-50"> | |
| <div class="max-w-3xl mx-auto space-y-4"> | |
| <div v-for="m in agentMessages" :key="m.id" :class="m.role === 'user' ? 'flex justify-end' : 'flex justify-start'"> | |
| <div :class="['max-w-[86%] rounded-2xl px-4 py-3 border shadow-sm', m.role === 'user' ? 'bg-indigo-600 text-white border-indigo-500' : 'bg-white text-gray-900 border-gray-200']"> | |
| <div class="whitespace-pre-wrap text-sm leading-relaxed">${ m.content }</div> | |
| <div class="mt-2 text-[11px] flex items-center justify-between gap-3" :class="m.role === 'user' ? 'text-indigo-100' : 'text-gray-400'"> | |
| <span>${ m.createdAt ? formatAgentTime(m.createdAt) : '' }</span> | |
| <div v-if="m.role === 'assistant' && m.id && agentConversationId" class="flex items-center gap-1"> | |
| <button v-for="n in 5" :key="n" @click="agentRate(m.id, n)" | |
| :disabled="!!agentRated[m.id]" | |
| :class="['w-5 h-5 rounded border flex items-center justify-center text-[10px] transition-colors', agentRated[m.id] ? 'opacity-50 cursor-not-allowed' : 'hover:border-yellow-300', (agentRated[m.id] ? agentRated[m.id] : 0) >= n ? 'bg-yellow-400 border-yellow-400 text-white' : 'bg-white border-gray-200 text-gray-600']"> | |
| <i class="fa-solid fa-star"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="agentUsageText" class="max-w-3xl mx-auto text-xs text-gray-400 text-right"> | |
| ${ agentUsageText } | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 bg-white"> | |
| <div class="max-w-3xl mx-auto"> | |
| <div class="flex flex-wrap gap-2 mb-3"> | |
| <button v-for="q in agentQuickPrompts" :key="q.label" @click="agentFill(q.template)" | |
| class="text-xs bg-white border border-gray-200 hover:border-indigo-200 text-gray-700 px-3 py-1.5 rounded-full transition-colors"> | |
| ${ q.label } | |
| </button> | |
| </div> | |
| <div class="flex gap-3"> | |
| <textarea v-model="agentInput" @keydown.enter.exact.prevent="agentSend" @keydown.enter.shift.exact.stop | |
| class="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm outline-none focus:ring-4 focus:ring-indigo-100 focus:border-indigo-300 resize-none" | |
| rows="2" placeholder="输入你的问题(例如:如何查询订单物流?)"></textarea> | |
| <button @click="agentSend" :disabled="agentSending || !agentInput.trim()" | |
| class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl text-sm font-medium transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i :class="['fa-solid', agentSending ? 'fa-spinner fa-spin' : 'fa-paper-plane']"></i> | |
| ${ agentSending ? '发送中' : '发送' } | |
| </button> | |
| </div> | |
| <div class="mt-2 text-xs text-gray-400"> | |
| 提示:涉及隐私信息时,仅需提供必要字段(如订单号/手机号后 4 位)。 | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted, computed, watch, nextTick } = Vue; | |
| createApp({ | |
| delimiters: ['${', '}'], | |
| setup() { | |
| const tickets = ref([]); | |
| const stats = ref({ open_tickets: 0, high_priority_risk: 0, customer_happiness: 0, agents: [] }); | |
| const isSimulating = ref(false); | |
| const currentTime = ref(''); | |
| const newTicketsCount = ref(0); | |
| const fileInput = ref(null); | |
| const uploadStatus = ref(''); | |
| const agentOpen = ref(false); | |
| const agentHealth = ref({ ok: false, hasKey: false, model: '', baseUrl: '' }); | |
| const agentConversations = ref([]); | |
| const agentConversationId = ref(''); | |
| const agentMessages = ref([]); | |
| const agentInput = ref(''); | |
| const agentSending = ref(false); | |
| const agentTitle = ref('新会话'); | |
| const agentMeta = ref(''); | |
| const agentUsageText = ref(''); | |
| const agentRated = ref({}); | |
| const agentChatBox = ref(null); | |
| const agentQuickPrompts = ref([ | |
| { label: '查询物流', template: '我想查询物流,订单号后4位是____,手机号后4位是____。' }, | |
| { label: '退款/退货', template: '我想申请退款/退货,订单号后4位是____,原因是____,是否需要提供照片?' }, | |
| { label: '开票', template: '我需要开具电子发票:抬头类型____,抬头名称____,税号____,邮箱____。' }, | |
| { label: '登录问题', template: '我登录失败,手机号后4位是____,提示错误是____。' } | |
| ]); | |
| let sentimentChart = null; | |
| let priorityChart = null; | |
| // Time update | |
| setInterval(() => { | |
| const now = new Date(); | |
| currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false }); | |
| }, 1000); | |
| const activeAgents = computed(() => { | |
| return stats.value.agents ? stats.value.agents.filter(a => a.status === 'online').length : 0; | |
| }); | |
| const triggerUpload = () => { | |
| fileInput.value.click(); | |
| }; | |
| const handleFileUpload = async (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const response = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| uploadStatus.value = `已上传: ${data.filename}`; | |
| setTimeout(() => uploadStatus.value = '', 3000); | |
| // Simulate adding an attachment to the latest ticket just for demo | |
| if(tickets.value.length > 0) { | |
| if(!tickets.value[0].attachments) tickets.value[0].attachments = []; | |
| tickets.value[0].attachments.push(data.filename); | |
| } | |
| } else { | |
| alert('上传失败: ' + (data.error || '未知错误')); | |
| } | |
| } catch (error) { | |
| console.error('Error uploading file:', error); | |
| alert('上传出错'); | |
| } | |
| // Reset input | |
| event.target.value = ''; | |
| }; | |
| const fetchData = async () => { | |
| try { | |
| const [ticketsRes, statsRes] = await Promise.all([ | |
| fetch('/api/tickets'), | |
| fetch('/api/stats') | |
| ]); | |
| if (ticketsRes.ok && statsRes.ok) { | |
| const newTickets = await ticketsRes.json(); | |
| stats.value = await statsRes.json(); | |
| // Simple diff to show "new" badge logic if needed | |
| if (newTickets.length > tickets.value.length && tickets.value.length > 0) { | |
| newTicketsCount.value = newTickets.length - tickets.value.length; | |
| setTimeout(() => newTicketsCount.value = 0, 5000); | |
| } | |
| tickets.value = newTickets; | |
| updateCharts(); | |
| } | |
| } catch (e) { | |
| console.error("Fetch error", e); | |
| } | |
| }; | |
| const simulateTraffic = async () => { | |
| if (isSimulating.value) return; | |
| isSimulating.value = true; | |
| try { | |
| await fetch('/api/simulate', { method: 'POST' }); | |
| await fetchData(); | |
| } finally { | |
| setTimeout(() => isSimulating.value = false, 500); | |
| } | |
| }; | |
| const resolveTicket = async (id) => { | |
| try { | |
| await fetch('/api/resolve', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ id }) | |
| }); | |
| await fetchData(); | |
| } catch (e) { | |
| console.error("Resolve error", e); | |
| } | |
| }; | |
| const getPriorityClass = (p) => { | |
| if (p === 'High') return 'bg-red-50 text-red-700 border-red-200'; | |
| if (p === 'Medium') return 'bg-orange-50 text-orange-700 border-orange-200'; | |
| return 'bg-green-50 text-green-700 border-green-200'; | |
| }; | |
| const formatTime = (t) => { | |
| if (!t) return ''; | |
| return t.split(' ')[1]; // HH:MM:SS | |
| }; | |
| const formatAgentTime = (iso) => { | |
| if (!iso) return ''; | |
| const d = new Date(iso); | |
| if (Number.isNaN(d.getTime())) return ''; | |
| const y = d.getFullYear(); | |
| const m = String(d.getMonth() + 1).padStart(2, '0'); | |
| const dd = String(d.getDate()).padStart(2, '0'); | |
| const hh = String(d.getHours()).padStart(2, '0'); | |
| const mm = String(d.getMinutes()).padStart(2, '0'); | |
| return `${y}-${m}-${dd} ${hh}:${mm}`; | |
| }; | |
| const agentScrollToBottom = async () => { | |
| await nextTick(); | |
| if (agentChatBox.value) { | |
| agentChatBox.value.scrollTop = agentChatBox.value.scrollHeight; | |
| } | |
| }; | |
| const fetchAgentHealth = async () => { | |
| try { | |
| const res = await fetch('/api/health'); | |
| const data = await res.json(); | |
| agentHealth.value = data; | |
| } catch { | |
| agentHealth.value = { ok: false, hasKey: false, model: '', baseUrl: '' }; | |
| } | |
| }; | |
| const fetchAgentConversations = async () => { | |
| try { | |
| const res = await fetch('/api/conversations'); | |
| const data = await res.json(); | |
| agentConversations.value = data.items || []; | |
| } catch { | |
| agentConversations.value = []; | |
| } | |
| }; | |
| const agentNewConversation = async () => { | |
| agentConversationId.value = ''; | |
| agentTitle.value = '新会话'; | |
| agentMeta.value = ''; | |
| agentUsageText.value = ''; | |
| agentRated.value = {}; | |
| agentMessages.value = [{ | |
| id: 'welcome', | |
| role: 'assistant', | |
| content: '你好,我是智能客服 Agent。\\n\\n你可以直接描述问题,或点击下方快捷意图。\\n\\n为了保护隐私,请仅提供必要字段(如订单号/手机号后 4 位)。', | |
| createdAt: new Date().toISOString() | |
| }]; | |
| await agentScrollToBottom(); | |
| }; | |
| const agentOpenConversation = async (id) => { | |
| try { | |
| const res = await fetch(`/api/conversations/${encodeURIComponent(id)}`); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| const conv = data.conversation; | |
| agentConversationId.value = conv.id; | |
| agentTitle.value = conv.title || '新会话'; | |
| agentMeta.value = conv.updatedAt ? `更新:${formatAgentTime(conv.updatedAt)}` : ''; | |
| agentUsageText.value = ''; | |
| agentRated.value = {}; | |
| agentMessages.value = (conv.messages || []).map(m => ({ | |
| id: m.id, | |
| role: m.role, | |
| content: m.content, | |
| createdAt: m.createdAt, | |
| usage: m.usage | |
| })); | |
| await agentScrollToBottom(); | |
| } finally { | |
| await fetchAgentConversations(); | |
| } | |
| }; | |
| const agentExport = () => { | |
| if (!agentConversationId.value) return; | |
| const a = document.createElement('a'); | |
| a.href = `/api/export/${encodeURIComponent(agentConversationId.value)}`; | |
| a.download = `conversation-${agentConversationId.value}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| }; | |
| const agentFill = (t) => { | |
| agentInput.value = t; | |
| }; | |
| const agentRate = async (messageId, rating) => { | |
| if (!agentConversationId.value) return; | |
| if (agentRated.value[messageId]) return; | |
| agentRated.value = { ...agentRated.value, [messageId]: rating }; | |
| const note = window.prompt('可选:补充一句反馈(可留空)', '') ?? ''; | |
| try { | |
| await fetch('/api/feedback', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ conversationId: agentConversationId.value, messageId, rating, note }) | |
| }); | |
| } catch {} | |
| }; | |
| const agentSend = async () => { | |
| const text = (agentInput.value || '').trim(); | |
| if (!text) return; | |
| agentInput.value = ''; | |
| agentUsageText.value = ''; | |
| agentMessages.value = [...agentMessages.value, { id: `u-${Date.now()}`, role: 'user', content: text, createdAt: new Date().toISOString() }]; | |
| const pendingId = `p-${Date.now()}`; | |
| agentMessages.value = [...agentMessages.value, { id: pendingId, role: 'assistant', content: '正在思考…', createdAt: new Date().toISOString() }]; | |
| await agentScrollToBottom(); | |
| agentSending.value = true; | |
| try { | |
| const res = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ conversationId: agentConversationId.value || undefined, message: text }) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.error || '请求失败'); | |
| agentConversationId.value = data.conversationId; | |
| agentTitle.value = data.conversation?.title || agentTitle.value; | |
| agentMeta.value = data.conversation?.updatedAt ? `更新:${formatAgentTime(data.conversation.updatedAt)}` : ''; | |
| const usage = data.assistantMessage?.usage; | |
| if (usage) { | |
| agentUsageText.value = `tokens:prompt ${usage.prompt_tokens ?? '-'} / completion ${usage.completion_tokens ?? '-'} / total ${usage.total_tokens ?? '-'}`; | |
| } | |
| agentMessages.value = agentMessages.value.map(m => { | |
| if (m.id !== pendingId) return m; | |
| return { | |
| id: data.assistantMessage.id, | |
| role: 'assistant', | |
| content: data.assistantMessage.content, | |
| createdAt: data.assistantMessage.createdAt, | |
| usage: data.assistantMessage.usage | |
| }; | |
| }); | |
| await fetchAgentConversations(); | |
| await agentScrollToBottom(); | |
| } catch (e) { | |
| agentMessages.value = agentMessages.value.map(m => m.id === pendingId ? { ...m, content: `请求失败:${String(e?.message || e)}` } : m); | |
| await agentScrollToBottom(); | |
| } finally { | |
| agentSending.value = false; | |
| } | |
| }; | |
| const openAgentPanel = async () => { | |
| agentOpen.value = true; | |
| await fetchAgentHealth(); | |
| await fetchAgentConversations(); | |
| if (!agentMessages.value.length) await agentNewConversation(); | |
| }; | |
| const closeAgentPanel = () => { | |
| agentOpen.value = false; | |
| }; | |
| // Charts | |
| const initCharts = () => { | |
| const chartDom1 = document.getElementById('sentimentChart'); | |
| const chartDom2 = document.getElementById('priorityChart'); | |
| if(chartDom1) sentimentChart = echarts.init(chartDom1); | |
| if(chartDom2) priorityChart = echarts.init(chartDom2); | |
| }; | |
| const updateCharts = () => { | |
| if (!sentimentChart || !priorityChart) return; | |
| // Sentiment Logic | |
| let positive = 0, negative = 0, neutral = 0; | |
| tickets.value.forEach(t => { | |
| if (t.sentiment_score > 0.3) positive++; | |
| else if (t.sentiment_score < -0.3) negative++; | |
| else neutral++; | |
| }); | |
| sentimentChart.setOption({ | |
| tooltip: { trigger: 'item' }, | |
| legend: { bottom: '0', left: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 10 } }, | |
| series: [{ | |
| name: 'Sentiment', | |
| type: 'pie', | |
| radius: ['40%', '70%'], | |
| center: ['50%', '45%'], | |
| avoidLabelOverlap: false, | |
| itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 }, | |
| label: { show: false }, | |
| data: [ | |
| { value: positive, name: '正面', itemStyle: { color: '#10b981' } }, | |
| { value: neutral, name: '中性', itemStyle: { color: '#9ca3af' } }, | |
| { value: negative, name: '负面', itemStyle: { color: '#ef4444' } } | |
| ] | |
| }] | |
| }); | |
| // Priority Logic | |
| let high = 0, medium = 0, low = 0; | |
| tickets.value.forEach(t => { | |
| if (t.priority === 'High') high++; | |
| else if (t.priority === 'Medium') medium++; | |
| else low++; | |
| }); | |
| priorityChart.setOption({ | |
| tooltip: { trigger: 'item' }, | |
| legend: { bottom: '0', left: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 10 } }, | |
| series: [{ | |
| name: 'Priority', | |
| type: 'pie', | |
| radius: '70%', | |
| center: ['50%', '45%'], | |
| data: [ | |
| { value: high, name: '高危', itemStyle: { color: '#dc2626' } }, | |
| { value: medium, name: '中等', itemStyle: { color: '#f59e0b' } }, | |
| { value: low, name: '普通', itemStyle: { color: '#10b981' } } | |
| ], | |
| label: { show: false } | |
| }] | |
| }); | |
| }; | |
| onMounted(() => { | |
| nextTick(() => { | |
| initCharts(); | |
| fetchData(); | |
| window.addEventListener('resize', () => { | |
| sentimentChart && sentimentChart.resize(); | |
| priorityChart && priorityChart.resize(); | |
| }); | |
| }); | |
| // Auto poll every 5 seconds | |
| setInterval(fetchData, 5000); | |
| // Simulate random incoming traffic every 10s | |
| setInterval(() => { | |
| if(Math.random() > 0.8) simulateTraffic(); | |
| }, 10000); | |
| }); | |
| return { | |
| tickets, stats, isSimulating, currentTime, activeAgents, newTicketsCount, fileInput, uploadStatus, | |
| simulateTraffic, resolveTicket, getPriorityClass, formatTime, triggerUpload, handleFileUpload, | |
| agentOpen, agentHealth, agentConversations, agentConversationId, agentMessages, agentInput, agentSending, | |
| agentTitle, agentMeta, agentUsageText, agentRated, agentChatBox, agentQuickPrompts, | |
| openAgentPanel, closeAgentPanel, agentNewConversation, agentOpenConversation, agentExport, agentSend, agentFill, agentRate, formatAgentTime | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |