feat: 新增渠道管理全量功能优化与日志诊断模块
1. 重构api请求统一处理接口返回错误,标准化错误抛出格式 2. 新增渠道类型常量与图标映射,清理冗余图标配置 3. 重构凭证状态逻辑,抽离为可复用composable工具 4. 优化WebSocket连接管理,新增断开状态标记防止重连异常 5. 新增渠道运行日志组件,支持实时查看与刷新日志 6. 新增渠道诊断报告功能,一键生成并复制渠道状态报告 7. 优化消息日志面板,支持正则搜索、多格式导出与消息重发 8. 新增统计图表对比功能,支持多渠道数据横向对比 9. 优化仪表盘统计字段映射,修正today_count与total_count字段名 10. 重构渠道启停逻辑,添加异常回滚与错误捕获 11. 新增批量注销渠道功能,优化配置保存后的重启提示 12. 优化轮询机制,新增页面可见性监听与指数退避重试策略
This commit is contained in:
parent
6ca611fead
commit
cb1b9ba889
@ -123,7 +123,14 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons
|
||||
// 检查Content-Type以确定如何处理响应
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json()
|
||||
const data = await response.json()
|
||||
if (data && typeof data.code === 'number' && data.code !== 0) {
|
||||
const error = new Error(data.message || '请求失败')
|
||||
error.code = data.code
|
||||
error.data = data.data
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
}
|
||||
return await response.text()
|
||||
} else if (responseType === 'text') {
|
||||
|
||||
@ -11,6 +11,7 @@ class ChannelWSClient {
|
||||
this.heartbeatInterval = heartbeatInterval
|
||||
this.heartbeatTimer = null
|
||||
this.pendingQueue = []
|
||||
this._disconnecting = false
|
||||
this.eventHandlers = {
|
||||
onChunk: null,
|
||||
onDone: null,
|
||||
@ -22,6 +23,7 @@ class ChannelWSClient {
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._disconnecting = false
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(`${this.url}?token=${this.token}`)
|
||||
|
||||
@ -41,6 +43,7 @@ class ChannelWSClient {
|
||||
this.ws.onclose = (event) => {
|
||||
this._stopHeartbeat()
|
||||
this._emitStateChange('disconnected')
|
||||
if (this._disconnecting) return
|
||||
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
@ -71,6 +74,7 @@ class ChannelWSClient {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._disconnecting = true
|
||||
this.maxReconnectAttempts = 0
|
||||
this._stopHeartbeat()
|
||||
this.ws?.close(1000, 'Client disconnect')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Check, X, AlertTriangle } from 'lucide-vue-next'
|
||||
import { Check, X, AlertTriangle, Loader, RefreshCw } from 'lucide-vue-next'
|
||||
import { channelApi } from '@/apis/channel_api'
|
||||
|
||||
const props = defineProps({
|
||||
@ -56,8 +56,18 @@ watch(() => props.channelId, () => {
|
||||
|
||||
<template>
|
||||
<div class="channel-ability-panel">
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<div v-else-if="loadError" class="error-hint">{{ loadError }}</div>
|
||||
<div v-if="loading" class="loading">
|
||||
<Loader :size="20" class="spinner" />
|
||||
<span>加载渠道能力数据...</span>
|
||||
</div>
|
||||
<div v-else-if="loadError" class="error-state">
|
||||
<AlertTriangle :size="20" />
|
||||
<span>{{ loadError }}</span>
|
||||
<button class="retry-btn" @click="loadActions">
|
||||
<RefreshCw :size="14" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="actions.length" class="ability-section">
|
||||
<h4 class="ability-title">消息动作能力</h4>
|
||||
@ -95,10 +105,46 @@ watch(() => props.channelId, () => {
|
||||
<style lang="less" scoped>
|
||||
.channel-ability-panel {
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
|
||||
.spinner {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: var(--color-warning-600);
|
||||
font-size: 14px;
|
||||
|
||||
.retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--color-warning-500);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-warning-600);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-warning-500);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ability-section {
|
||||
@ -183,12 +229,10 @@ watch(() => props.channelId, () => {
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-warning-600);
|
||||
font-size: 14px;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
334
web/src/components/channels/ChannelDebugLog.vue
Normal file
334
web/src/components/channels/ChannelDebugLog.vue
Normal file
@ -0,0 +1,334 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { Trash2, RefreshCw, ScrollText } from 'lucide-vue-next'
|
||||
import { channelApi } from '@/apis/channel_api'
|
||||
|
||||
const props = defineProps({
|
||||
channelId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const logs = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const logContainer = ref(null)
|
||||
const autoScroll = ref(true)
|
||||
const pollingTimer = ref(null)
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (!status) return ''
|
||||
const map = { error: 'log-error', timeout: 'log-warn', processing: 'log-info', success: 'log-ok' }
|
||||
return map[status] || ''
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const map = { processing: '处理中', success: '成功', error: '错误', timeout: '超时' }
|
||||
return map[status] || status || '-'
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await channelApi.getMessageLog(props.channelId, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
})
|
||||
const messages = res.data?.messages || []
|
||||
logs.value = messages.reverse()
|
||||
} catch (e) {
|
||||
error.value = e.message || '获取日志失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!autoScroll.value || !logContainer.value) return
|
||||
nextTick(() => {
|
||||
if (logContainer.value) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!logContainer.value) return
|
||||
const el = logContainer.value
|
||||
const threshold = 50
|
||||
autoScroll.value = el.scrollHeight - el.scrollTop - el.clientHeight < threshold
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollingTimer.value = setInterval(fetchLogs, 10000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
pollingTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(logs, scrollToBottom, { deep: true })
|
||||
|
||||
watch(() => props.channelId, (newId) => {
|
||||
if (newId) {
|
||||
clearLogs()
|
||||
fetchLogs()
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.channelId) {
|
||||
fetchLogs()
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channel-debug-log">
|
||||
<div class="debug-toolbar">
|
||||
<div class="debug-toolbar-left">
|
||||
<span class="debug-title">
|
||||
<ScrollText :size="14" />
|
||||
运行日志
|
||||
</span>
|
||||
<span class="debug-count">{{ logs.length }} 条</span>
|
||||
</div>
|
||||
<div class="debug-toolbar-right">
|
||||
<button class="debug-btn" :disabled="loading" @click="fetchLogs" title="刷新日志">
|
||||
<RefreshCw :size="14" :class="{ spinning: loading }" />
|
||||
</button>
|
||||
<button class="debug-btn" @click="clearLogs" title="清空日志">
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="logContainer"
|
||||
class="debug-log-container"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div v-if="loading && !logs.length" class="debug-loading">加载中...</div>
|
||||
<div v-else-if="error" class="debug-error">{{ error }}</div>
|
||||
<div v-else-if="!logs.length" class="debug-empty">暂无日志记录</div>
|
||||
<div v-else class="debug-log-list">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="debug-log-entry"
|
||||
:class="statusClass(log.status)"
|
||||
>
|
||||
<span class="log-time">{{ formatTime(log.created_at) }}</span>
|
||||
<span class="log-status">{{ statusLabel(log.status) }}</span>
|
||||
<span class="log-sender">{{ log.sender_user_id || '-' }}</span>
|
||||
<span class="log-content">{{ log.content_preview || '-' }}</span>
|
||||
<span v-if="log.response_time_ms != null" class="log-latency">{{ log.response_time_ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.channel-debug-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 480px;
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--gray-25);
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debug-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.debug-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.debug-count {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.debug-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.debug-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 4px;
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-500);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--main-color);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.debug-log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--gray-0);
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-loading,
|
||||
.debug-error,
|
||||
.debug-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.debug-error {
|
||||
color: var(--color-error-500);
|
||||
}
|
||||
|
||||
.debug-log-list {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.debug-log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 3px 12px;
|
||||
line-height: 1.6;
|
||||
border-bottom: 1px solid var(--gray-25);
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-10);
|
||||
}
|
||||
|
||||
&.log-error {
|
||||
background: var(--color-error-10, #fef2f2);
|
||||
.log-status { color: var(--color-error-500); font-weight: 600; }
|
||||
}
|
||||
|
||||
&.log-warn {
|
||||
background: var(--color-warning-10, #fffbeb);
|
||||
.log-status { color: var(--color-warning-500); font-weight: 600; }
|
||||
}
|
||||
|
||||
&.log-info {
|
||||
.log-status { color: var(--color-info-500); }
|
||||
}
|
||||
|
||||
&.log-ok {
|
||||
.log-status { color: var(--color-success-500); }
|
||||
}
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--gray-400);
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
min-width: 36px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.log-sender {
|
||||
color: var(--gray-600);
|
||||
min-width: 60px;
|
||||
max-width: 120px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
flex: 1;
|
||||
color: var(--gray-900);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.log-latency {
|
||||
color: var(--gray-400);
|
||||
min-width: 40px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@ -1,14 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { Download, X } from 'lucide-vue-next'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { Download, X, Send } from 'lucide-vue-next'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { channelApi } from '@/apis/channel_api'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import ChannelStatusBadge from './ChannelStatusBadge.vue'
|
||||
|
||||
const props = defineProps({
|
||||
channelId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const channelStore = useChannelStore()
|
||||
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
@ -22,11 +25,15 @@ const filters = ref({
|
||||
date_to: '',
|
||||
search: ''
|
||||
})
|
||||
const regexEnabled = ref(false)
|
||||
const combineMode = ref('and')
|
||||
const statusOptions = ['', 'processing', 'success', 'error', 'timeout']
|
||||
const typeOptions = ['', 'text', 'image', 'file', 'audio', 'video']
|
||||
const typeOptions = ['', 'text', 'image', 'file', 'voice', 'video', 'location', 'event']
|
||||
|
||||
const selectedMessage = ref(null)
|
||||
const exporting = ref(false)
|
||||
const exportFormat = ref('csv')
|
||||
const replaying = ref(false)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
@ -80,13 +87,62 @@ function responseTimeClass(ms) {
|
||||
return ms > 999 ? 'slow' : ''
|
||||
}
|
||||
|
||||
function highlightText(text, keyword) {
|
||||
if (!keyword || !text) return text
|
||||
try {
|
||||
const escaped = regexEnabled.value ? keyword : keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`(${escaped})`, 'gi')
|
||||
const parts = String(text).split(regex)
|
||||
return parts.map((part) => {
|
||||
if (regex.test(part)) {
|
||||
regex.lastIndex = 0
|
||||
return `<mark class="search-highlight">${part}</mark>`
|
||||
}
|
||||
return part
|
||||
}).join('')
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReplay() {
|
||||
const msg = selectedMessage.value
|
||||
if (!msg) return
|
||||
const content = msg.content_preview || ''
|
||||
if (!content) {
|
||||
message.warning('消息内容为空,无法重新发送')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认重新发送',
|
||||
content: `确定要重新发送以下消息内容吗?\n\n"${content.length > 100 ? content.slice(0, 100) + '...' : content}"`,
|
||||
okText: '确认发送',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
replaying.value = true
|
||||
try {
|
||||
channelStore.sendMessage(content, {})
|
||||
message.success('消息已发送')
|
||||
} catch (e) {
|
||||
message.error(e.message || '发送失败')
|
||||
} finally {
|
||||
replaying.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params = {}
|
||||
if (filters.value.status) params.status = filters.value.status
|
||||
if (filters.value.content_type) params.content_type = filters.value.content_type
|
||||
if (filters.value.date_from) params.date_from = filters.value.date_from
|
||||
if (filters.value.date_to) params.date_to = filters.value.date_to
|
||||
if (filters.value.search) params.search = filters.value.search
|
||||
if (filters.value.search) {
|
||||
params.search = filters.value.search
|
||||
params.regex = regexEnabled.value
|
||||
params.combine = combineMode.value
|
||||
}
|
||||
params.page = page.value
|
||||
params.page_size = pageSize.value
|
||||
return params
|
||||
@ -130,7 +186,7 @@ function closeDetail() {
|
||||
selectedMessage.value = null
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
async function handleExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const params = {}
|
||||
@ -140,18 +196,33 @@ async function exportCSV() {
|
||||
if (filters.value.date_to) params.date_to = filters.value.date_to
|
||||
if (filters.value.search) params.search = filters.value.search
|
||||
|
||||
const res = await channelApi.exportMessageLog(props.channelId, params)
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}))
|
||||
throw new Error(errData.detail || `导出失败 (${res.status})`)
|
||||
if (exportFormat.value === 'csv') {
|
||||
const res = await channelApi.exportMessageLog(props.channelId, params)
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}))
|
||||
throw new Error(errData.detail || `导出失败 (${res.status})`)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `messages_${props.channelId}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} else {
|
||||
const fetchParams = { ...params, page: 1, page_size: Math.max(total.value, 1000) }
|
||||
const res = await channelApi.getMessageLog(props.channelId, fetchParams)
|
||||
const exportData = res.data?.messages || []
|
||||
const jsonStr = JSON.stringify(exportData, null, 2)
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `messages_${props.channelId}_${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `messages_${props.channelId}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
message.success('导出成功')
|
||||
} catch (e) {
|
||||
message.error(e.message || '导出失败,请稍后重试')
|
||||
@ -203,16 +274,31 @@ watch(() => props.channelId, () => {
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
class="filter-input"
|
||||
class="filter-input search-input"
|
||||
placeholder="搜索..."
|
||||
@keydown.enter="applyFilters"
|
||||
/>
|
||||
|
||||
<label class="regex-toggle" title="启用正则表达式搜索">
|
||||
<input type="checkbox" v-model="regexEnabled" @change="applyFilters" />
|
||||
<span>正则</span>
|
||||
</label>
|
||||
|
||||
<select v-model="combineMode" class="filter-select combine-select" @change="applyFilters" title="多条件组合方式">
|
||||
<option value="and">AND</option>
|
||||
<option value="or">OR</option>
|
||||
</select>
|
||||
|
||||
<span class="toolbar-spacer"></span>
|
||||
|
||||
<button class="btn-export" :disabled="exporting" @click="exportCSV">
|
||||
<select v-model="exportFormat" class="filter-select export-format-select">
|
||||
<option value="csv">CSV</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
|
||||
<button class="btn-export" :disabled="exporting" @click="handleExport">
|
||||
<Download :size="14" />
|
||||
{{ exporting ? '导出中...' : '导出 CSV' }}
|
||||
{{ exporting ? '导出中...' : '导出' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -238,7 +324,7 @@ watch(() => props.channelId, () => {
|
||||
<tr v-for="msg in messages" :key="msg.id">
|
||||
<td class="cell-time">{{ formatTime(msg.created_at) }}</td>
|
||||
<td class="cell-content">{{ truncate(msg.sender_user_id, 20) }}</td>
|
||||
<td class="cell-content">{{ truncate(msg.content_preview, 60) }}</td>
|
||||
<td class="cell-content" v-html="highlightText(truncate(msg.content_preview, 60), filters.search)"></td>
|
||||
<td>{{ msg.content_type || 'text' }}</td>
|
||||
<td>
|
||||
<ChannelStatusBadge
|
||||
@ -286,9 +372,15 @@ watch(() => props.channelId, () => {
|
||||
<div class="detail-panel">
|
||||
<div class="detail-header">
|
||||
<h3>消息详情</h3>
|
||||
<button class="detail-close-btn" @click="closeDetail">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<div class="detail-header-actions">
|
||||
<button class="detail-replay-btn" :disabled="replaying" @click="handleReplay" title="重新发送此消息">
|
||||
<Send :size="14" />
|
||||
重新发送
|
||||
</button>
|
||||
<button class="detail-close-btn" @click="closeDetail">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-section">
|
||||
@ -332,11 +424,11 @@ watch(() => props.channelId, () => {
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>消息内容</h4>
|
||||
<pre class="detail-content">{{ selectedMessage.content_preview || '-' }}</pre>
|
||||
<pre class="detail-content" v-text="selectedMessage.content_preview || '-'"></pre>
|
||||
</div>
|
||||
<div class="detail-section" v-if="selectedMessage.reply_content_preview">
|
||||
<h4>回复内容</h4>
|
||||
<pre class="detail-content">{{ selectedMessage.reply_content_preview }}</pre>
|
||||
<pre class="detail-content" v-text="selectedMessage.reply_content_preview"></pre>
|
||||
</div>
|
||||
<div class="detail-section" v-if="selectedMessage.error_message">
|
||||
<h4>错误信息</h4>
|
||||
@ -376,6 +468,36 @@ watch(() => props.channelId, () => {
|
||||
|
||||
.toolbar-spacer { flex: 1; }
|
||||
|
||||
.search-input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.regex-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: var(--main-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover { color: var(--main-color); }
|
||||
}
|
||||
|
||||
.combine-select {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.export-format-select {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -451,6 +573,13 @@ watch(() => props.channelId, () => {
|
||||
tbody tr:hover { background: var(--gray-10); }
|
||||
|
||||
.cell-content { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.search-highlight {
|
||||
background: var(--color-warning-200, #fde68a);
|
||||
color: var(--gray-1000);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
.cell-time { font-size: 12px; color: var(--gray-600); }
|
||||
.cell-latency { font-family: monospace; }
|
||||
.cell-latency.slow { color: var(--color-error-500); font-weight: 500; }
|
||||
@ -539,6 +668,29 @@ watch(() => props.channelId, () => {
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-replay-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--main-color);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--main-color);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--main-color); color: #fff; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.detail-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { channelApi } from '@/apis/channel_api'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
|
||||
const props = defineProps({
|
||||
channelId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const channelStore = useChannelStore()
|
||||
|
||||
const period = ref('7d')
|
||||
const loading = ref(false)
|
||||
const compareMode = ref(false)
|
||||
const compareChannelIds = ref([])
|
||||
const compareData = ref({})
|
||||
const compareLoading = ref(false)
|
||||
const chartRefs = {
|
||||
dailyTrend: ref(null),
|
||||
latency: ref(null),
|
||||
@ -33,6 +40,19 @@ const periodOptions = [
|
||||
{ label: '30天', value: '30d' }
|
||||
]
|
||||
|
||||
const compareColorPalette = [
|
||||
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
||||
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'
|
||||
]
|
||||
|
||||
const availableChannels = computed(() => {
|
||||
const list = Object.values(channelStore.channels || {})
|
||||
return list.filter((ch) => ch.channel_id !== props.channelId)
|
||||
.map((ch) => ({ channel_id: ch.channel_id, display_name: ch.display_name || ch.channel_id }))
|
||||
})
|
||||
|
||||
const compareChannelLimit = 5
|
||||
|
||||
function initChart(refKey, option) {
|
||||
const el = chartRefs[refKey]?.value
|
||||
if (!el) return
|
||||
@ -199,6 +219,102 @@ function buildSuccessRateChart(data) {
|
||||
})
|
||||
}
|
||||
|
||||
function getChannelDisplayName(channelId) {
|
||||
const ch = channelStore.channels?.[channelId]
|
||||
return ch?.display_name || channelId
|
||||
}
|
||||
|
||||
function buildCompareDailyTrendChart(baseData) {
|
||||
const allDates = new Set()
|
||||
const seriesMap = {}
|
||||
|
||||
const addSeries = (channelId, data, colorIndex) => {
|
||||
const name = getChannelDisplayName(channelId)
|
||||
const dailyTrend = data?.daily_trend || []
|
||||
const dataMap = {}
|
||||
for (const d of dailyTrend) {
|
||||
allDates.add(d.date)
|
||||
dataMap[d.date] = d.count
|
||||
}
|
||||
seriesMap[channelId] = { name, dataMap, color: compareColorPalette[colorIndex % compareColorPalette.length] }
|
||||
}
|
||||
|
||||
addSeries(props.channelId, baseData, 0)
|
||||
let idx = 1
|
||||
for (const chId of compareChannelIds.value) {
|
||||
if (compareData.value[chId]) {
|
||||
addSeries(chId, compareData.value[chId], idx)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const dates = [...allDates].sort()
|
||||
const series = Object.entries(seriesMap).map(([, s]) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: dates.map((d) => s.dataMap[d] ?? null),
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
lineStyle: { color: s.color, width: 2 },
|
||||
itemStyle: { color: s.color }
|
||||
}))
|
||||
|
||||
initChart('dailyTrend', {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0, textStyle: { fontSize: 11, color: '#979999' } },
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 32 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11, color: '#979999' } },
|
||||
yAxis: { type: 'value', axisLabel: { fontSize: 11, color: '#979999' } },
|
||||
series
|
||||
})
|
||||
}
|
||||
|
||||
function buildCompareSuccessRateChart(baseData) {
|
||||
const allDates = new Set()
|
||||
const seriesMap = {}
|
||||
|
||||
const addSeries = (channelId, data, colorIndex) => {
|
||||
const name = getChannelDisplayName(channelId)
|
||||
const dailySR = data?.daily_success_rate || []
|
||||
const dataMap = {}
|
||||
for (const d of dailySR) {
|
||||
allDates.add(d.date)
|
||||
dataMap[d.date] = d.success_rate
|
||||
}
|
||||
seriesMap[channelId] = { name, dataMap, color: compareColorPalette[colorIndex % compareColorPalette.length] }
|
||||
}
|
||||
|
||||
addSeries(props.channelId, baseData, 0)
|
||||
let idx = 1
|
||||
for (const chId of compareChannelIds.value) {
|
||||
if (compareData.value[chId]) {
|
||||
addSeries(chId, compareData.value[chId], idx)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const dates = [...allDates].sort()
|
||||
const series = Object.entries(seriesMap).map(([, s]) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: dates.map((d) => s.dataMap[d] ?? null),
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
yAxisIndex: 0,
|
||||
lineStyle: { color: s.color, width: 2 },
|
||||
itemStyle: { color: s.color }
|
||||
}))
|
||||
|
||||
initChart('successRate', {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0, textStyle: { fontSize: 11, color: '#979999' } },
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 32 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11, color: '#979999' } },
|
||||
yAxis: { type: 'value', name: '%', min: 0, max: 100, axisLabel: { fontSize: 11, color: '#979999' } },
|
||||
series
|
||||
})
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -208,11 +324,19 @@ async function loadStats() {
|
||||
summary.value = data.summary
|
||||
}
|
||||
await nextTick()
|
||||
buildDailyTrendChart(data)
|
||||
buildLatencyChart(data)
|
||||
buildChatTypeChart(data)
|
||||
buildMessageTypeChart(data)
|
||||
buildSuccessRateChart(data)
|
||||
if (compareMode.value && compareChannelIds.value.length > 0) {
|
||||
buildCompareDailyTrendChart(data)
|
||||
buildLatencyChart(data)
|
||||
buildChatTypeChart(data)
|
||||
buildMessageTypeChart(data)
|
||||
buildCompareSuccessRateChart(data)
|
||||
} else {
|
||||
buildDailyTrendChart(data)
|
||||
buildLatencyChart(data)
|
||||
buildChatTypeChart(data)
|
||||
buildMessageTypeChart(data)
|
||||
buildSuccessRateChart(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载统计数据失败:', e)
|
||||
} finally {
|
||||
@ -220,6 +344,55 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompareData() {
|
||||
if (!compareChannelIds.value.length) return
|
||||
compareLoading.value = true
|
||||
const results = await Promise.allSettled(
|
||||
compareChannelIds.value.map((chId) =>
|
||||
channelApi.getStats(chId, { period: period.value }).then((r) => ({ chId, data: r.data }))
|
||||
)
|
||||
)
|
||||
const newData = { ...compareData.value }
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
newData[r.value.chId] = r.value.data || {}
|
||||
}
|
||||
}
|
||||
compareData.value = newData
|
||||
compareLoading.value = false
|
||||
}
|
||||
|
||||
function toggleCompareMode() {
|
||||
compareMode.value = !compareMode.value
|
||||
if (!compareMode.value) {
|
||||
compareChannelIds.value = []
|
||||
compareData.value = {}
|
||||
loadStats()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCompareChannel(channelId) {
|
||||
const idx = compareChannelIds.value.indexOf(channelId)
|
||||
if (idx >= 0) {
|
||||
compareChannelIds.value.splice(idx, 1)
|
||||
} else if (compareChannelIds.value.length < compareChannelLimit - 1) {
|
||||
compareChannelIds.value.push(channelId)
|
||||
}
|
||||
}
|
||||
|
||||
watch(compareChannelIds, async (newVal, oldVal) => {
|
||||
if (!compareMode.value) return
|
||||
if (newVal.length === 0 && oldVal.length > 0) {
|
||||
compareData.value = {}
|
||||
loadStats()
|
||||
return
|
||||
}
|
||||
await loadCompareData()
|
||||
if (compareData.value) {
|
||||
loadStats()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
function handleResize() {
|
||||
Object.values(charts).forEach((c) => c?.resize())
|
||||
}
|
||||
@ -264,19 +437,46 @@ onUnmounted(() => {
|
||||
|
||||
<div class="stats-period">
|
||||
<h4 class="section-title">统计图表</h4>
|
||||
<div class="period-selector">
|
||||
<div class="period-actions">
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
class="period-btn"
|
||||
:class="{ active: period === opt.value }"
|
||||
@click="period = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
class="period-btn"
|
||||
:class="{ active: period === opt.value }"
|
||||
@click="period = opt.value"
|
||||
v-if="availableChannels.length > 0"
|
||||
class="compare-toggle-btn"
|
||||
:class="{ active: compareMode }"
|
||||
@click="toggleCompareMode"
|
||||
>
|
||||
{{ opt.label }}
|
||||
对比模式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="compareMode" class="compare-selector">
|
||||
<span class="compare-label">选择对比渠道 (最多 {{ compareChannelLimit - 1 }} 个):</span>
|
||||
<div class="compare-channel-list">
|
||||
<button
|
||||
v-for="ch in availableChannels"
|
||||
:key="ch.channel_id"
|
||||
class="compare-channel-btn"
|
||||
:class="{ selected: compareChannelIds.includes(ch.channel_id) }"
|
||||
:disabled="!compareChannelIds.includes(ch.channel_id) && compareChannelIds.length >= compareChannelLimit - 1"
|
||||
@click="toggleCompareChannel(ch.channel_id)"
|
||||
>
|
||||
{{ ch.display_name }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="compareLoading" class="compare-loading-hint">加载对比数据中...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="stats-loading">
|
||||
<div class="skeleton-summary">
|
||||
<div v-for="n in 4" :key="n" class="skeleton-summary-card"></div>
|
||||
@ -348,6 +548,13 @@ onUnmounted(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.period-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@ -374,6 +581,65 @@ onUnmounted(() => {
|
||||
&.active { background: var(--main-color); color: #fff; border-color: var(--main-color); }
|
||||
}
|
||||
|
||||
.compare-toggle-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover { border-color: var(--color-accent-500); color: var(--color-accent-700); }
|
||||
&.active { background: var(--color-accent-700); color: #fff; border-color: var(--color-accent-700); }
|
||||
}
|
||||
|
||||
.compare-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--gray-25);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.compare-label {
|
||||
font-size: 13px;
|
||||
color: var(--gray-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compare-channel-list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.compare-channel-btn {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 4px;
|
||||
background: var(--gray-0);
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) { border-color: var(--color-accent-500); color: var(--color-accent-700); }
|
||||
&.selected { background: var(--color-accent-700); color: #fff; border-color: var(--color-accent-700); }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.compare-loading-hint {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stats-loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
|
||||
@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { RotateCcw, Plug, MessageCircle, Shield, RefreshCw } from 'lucide-vue-next'
|
||||
import ChannelStatusBadge from './ChannelStatusBadge.vue'
|
||||
import { channelIcons } from '@/constants/channelIcons'
|
||||
import { useCredentialStatus } from '@/composables/useCredentialStatus'
|
||||
|
||||
const props = defineProps({
|
||||
channel: { type: Object, required: true },
|
||||
@ -44,39 +45,9 @@ const healthLabel = computed(() => {
|
||||
return map[healthClass.value]
|
||||
})
|
||||
|
||||
const credentialClass = computed(() => {
|
||||
const cs = props.credentialStatus
|
||||
if (!cs || !cs.has_credential) return 'cred-none'
|
||||
if (cs.is_expired) return 'cred-expired'
|
||||
if (cs.expires_at && cs.source === 'db') {
|
||||
const remaining = new Date(cs.expires_at).getTime() - Date.now()
|
||||
if (remaining <= 3600000) return 'cred-soon'
|
||||
return 'cred-ok'
|
||||
}
|
||||
if (cs.source === 'memory') return 'cred-memory'
|
||||
if (cs.source === 'config' || cs.source === 'env') return 'cred-static'
|
||||
if (cs.source === 'file') return 'cred-file'
|
||||
return 'cred-ok'
|
||||
})
|
||||
|
||||
const credentialLabel = computed(() => {
|
||||
const map = {
|
||||
'cred-ok': '凭证正常',
|
||||
'cred-soon': '即将过期',
|
||||
'cred-expired': '已过期',
|
||||
'cred-none': '无凭证',
|
||||
'cred-memory': '内存存储',
|
||||
'cred-static': '静态凭证',
|
||||
'cred-file': '文件存储'
|
||||
}
|
||||
return map[credentialClass.value] || '凭证正常'
|
||||
})
|
||||
|
||||
const credentialSourceLabel = computed(() => {
|
||||
const map = { db: '数据库', config: '配置文件', env: '环境变量', file: '文件', memory: '内存', none: '无' }
|
||||
const cs = props.credentialStatus
|
||||
return cs ? (map[cs.source] || cs.source) : ''
|
||||
})
|
||||
const { credentialClass, credentialLabel, credentialSourceLabel } = useCredentialStatus(
|
||||
() => props.credentialStatus
|
||||
)
|
||||
|
||||
const showRefreshBtn = computed(() => {
|
||||
const cs = props.credentialStatus
|
||||
@ -84,7 +55,6 @@ const showRefreshBtn = computed(() => {
|
||||
return cs.source === 'db' || cs.source === 'memory'
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channel-card" :class="cardClass">
|
||||
@ -112,11 +82,11 @@ const showRefreshBtn = computed(() => {
|
||||
<div class="card-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">今日消息</span>
|
||||
<strong class="stat-value">{{ channel.today_count ?? '-' }}</strong>
|
||||
<strong class="stat-value">{{ channel.today_messages ?? '-' }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总消息</span>
|
||||
<strong class="stat-value">{{ channel.total_count ?? '-' }}</strong>
|
||||
<strong class="stat-value">{{ channel.total_messages ?? '-' }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">活跃连接</span>
|
||||
|
||||
57
web/src/composables/useCredentialStatus.js
Normal file
57
web/src/composables/useCredentialStatus.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const sourceLabelMap = {
|
||||
db: '数据库',
|
||||
config: '配置文件',
|
||||
env: '环境变量',
|
||||
file: '文件',
|
||||
memory: '内存',
|
||||
none: '无'
|
||||
}
|
||||
|
||||
const labelMap = {
|
||||
'cred-ok': '凭证正常',
|
||||
'cred-soon': '即将过期',
|
||||
'cred-expired': '已过期',
|
||||
'cred-none': '无凭证',
|
||||
'cred-memory': '内存存储',
|
||||
'cred-static': '静态凭证',
|
||||
'cred-file': '文件存储'
|
||||
}
|
||||
|
||||
function resolveCS(source) {
|
||||
if (typeof source === 'function') return source()
|
||||
return source?.value ?? source
|
||||
}
|
||||
|
||||
export function useCredentialStatus(credentialStatus) {
|
||||
const credentialClass = computed(() => {
|
||||
const cs = resolveCS(credentialStatus)
|
||||
if (!cs || !cs.has_credential) return 'cred-none'
|
||||
if (cs.is_expired) return 'cred-expired'
|
||||
if (cs.expires_at && cs.source === 'db') {
|
||||
const remaining = new Date(cs.expires_at).getTime() - Date.now()
|
||||
if (remaining <= 3600000) return 'cred-soon'
|
||||
return 'cred-ok'
|
||||
}
|
||||
if (cs.source === 'memory') return 'cred-memory'
|
||||
if (cs.source === 'config' || cs.source === 'env') return 'cred-static'
|
||||
if (cs.source === 'file') return 'cred-file'
|
||||
return 'cred-ok'
|
||||
})
|
||||
|
||||
const credentialLabel = computed(() => {
|
||||
return labelMap[credentialClass.value] || '凭证正常'
|
||||
})
|
||||
|
||||
const credentialSourceLabel = computed(() => {
|
||||
const cs = resolveCS(credentialStatus)
|
||||
return cs ? (sourceLabelMap[cs.source] || cs.source) : ''
|
||||
})
|
||||
|
||||
return {
|
||||
credentialClass,
|
||||
credentialLabel,
|
||||
credentialSourceLabel
|
||||
}
|
||||
}
|
||||
@ -7,47 +7,24 @@ export const channelIcons = {
|
||||
whatsapp: `${SIMPLE_ICONS_BASE}/whatsapp`,
|
||||
wechat: `${SIMPLE_ICONS_BASE}/wechat`,
|
||||
line: `${SIMPLE_ICONS_BASE}/line`,
|
||||
messenger: `${SIMPLE_ICONS_BASE}/messenger`,
|
||||
zoom: `${SIMPLE_ICONS_BASE}/zoom`,
|
||||
signal: `${SIMPLE_ICONS_BASE}/signal`,
|
||||
twitch: `${SIMPLE_ICONS_BASE}/twitch`,
|
||||
shopify: `${SIMPLE_ICONS_BASE}/shopify`,
|
||||
dingtalk: `${SIMPLE_ICONS_BASE}/dingtalk`,
|
||||
dingding: `${SIMPLE_ICONS_BASE}/dingtalk`,
|
||||
feishu: `${SIMPLE_ICONS_BASE}/feishu`,
|
||||
qq: `${SIMPLE_ICONS_BASE}/qq`,
|
||||
qq_bot: `${SIMPLE_ICONS_BASE}/qq`,
|
||||
qqbot: `${SIMPLE_ICONS_BASE}/qq`,
|
||||
matrix: `${SIMPLE_ICONS_BASE}/matrix`,
|
||||
mattermost: `${SIMPLE_ICONS_BASE}/mattermost`,
|
||||
email: `${SIMPLE_ICONS_BASE}/gmail`,
|
||||
sms: `${SIMPLE_ICONS_BASE}/simpleicons`,
|
||||
skype: `${SIMPLE_ICONS_BASE}/skype`,
|
||||
rocketchat: `${SIMPLE_ICONS_BASE}/rocketdotchat`,
|
||||
twitter: `${SIMPLE_ICONS_BASE}/x`,
|
||||
threads: `${SIMPLE_ICONS_BASE}/threads`,
|
||||
alibaba: `${SIMPLE_ICONS_BASE}/alibabacloud`,
|
||||
vk: `${SIMPLE_ICONS_BASE}/vk`,
|
||||
imo: `${SIMPLE_ICONS_BASE}/imo`,
|
||||
google_chat: `${SIMPLE_ICONS_BASE}/googlechat`,
|
||||
googlechat: `${SIMPLE_ICONS_BASE}/googlechat`,
|
||||
zalo: `${SIMPLE_ICONS_BASE}/zalo`,
|
||||
zalo_oa: `${SIMPLE_ICONS_BASE}/zalo`,
|
||||
zalo_user: `${SIMPLE_ICONS_BASE}/zalo`,
|
||||
wechat_work: `${SIMPLE_ICONS_BASE}/wecom`,
|
||||
bluebubbles: `${SIMPLE_ICONS_BASE}/simpleicons`,
|
||||
nostr: `${SIMPLE_ICONS_BASE}/nostr`,
|
||||
imessage: `${SIMPLE_ICONS_BASE}/apple`,
|
||||
urbit: `${SIMPLE_ICONS_BASE}/urbit`,
|
||||
synologychat: `${SIMPLE_ICONS_BASE}/synology`,
|
||||
nextcloudtalk: `${SIMPLE_ICONS_BASE}/nextcloud`,
|
||||
'nextcloud-talk': `${SIMPLE_ICONS_BASE}/nextcloud`,
|
||||
nextcloud_talk: `${SIMPLE_ICONS_BASE}/nextcloud`,
|
||||
yuanbao: `${SIMPLE_ICONS_BASE}/qq`,
|
||||
kik: `${SIMPLE_ICONS_BASE}/kik`,
|
||||
ms_teams: `${SIMPLE_ICONS_BASE}/microsoftteams`,
|
||||
msteams: `${SIMPLE_ICONS_BASE}/microsoftteams`,
|
||||
webchat: `${SIMPLE_ICONS_BASE}/simpleicons`,
|
||||
irc: `${SIMPLE_ICONS_BASE}/simpleicons`,
|
||||
default: `${SIMPLE_ICONS_BASE}/simpleicons`
|
||||
}
|
||||
27
web/src/constants/channelTypes.js
Normal file
27
web/src/constants/channelTypes.js
Normal file
@ -0,0 +1,27 @@
|
||||
export const channelTypeLabels = {
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
whatsapp: 'WhatsApp',
|
||||
wechat: '微信',
|
||||
feishu: '飞书',
|
||||
dingding: '钉钉',
|
||||
line: 'LINE',
|
||||
qqbot: 'QQ 机器人',
|
||||
msteams: 'Microsoft Teams',
|
||||
googlechat: 'Google Chat',
|
||||
signal: 'Signal',
|
||||
matrix: 'Matrix',
|
||||
nostr: 'Nostr',
|
||||
irc: 'IRC',
|
||||
mattermost: 'Mattermost',
|
||||
twitch: 'Twitch',
|
||||
imessage: 'iMessage',
|
||||
bluebubbles: 'BlueBubbles',
|
||||
synologychat: 'Synology Chat',
|
||||
'nextcloud-talk': 'Nextcloud Talk',
|
||||
zalo_user: 'Zalo User',
|
||||
zalo_oa: 'Zalo OA',
|
||||
urbit: 'Urbit',
|
||||
yuanbao: '元宝'
|
||||
}
|
||||
@ -8,7 +8,11 @@ export const useChannelStore = defineStore('channel', () => {
|
||||
const wsClient = ref(null)
|
||||
const wsState = ref('disconnected')
|
||||
const pollingTimer = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const pollingInterval = ref(5000)
|
||||
const pollingActive = ref(false)
|
||||
const POLLING_BASE = 5000
|
||||
const POLLING_MAX = 30000
|
||||
const isLoading = ref(false)
|
||||
const loadError = ref(null)
|
||||
|
||||
const selectedChannel = computed(() => channels.value[selectedChannelId.value])
|
||||
@ -45,6 +49,8 @@ export const useChannelStore = defineStore('channel', () => {
|
||||
...channels.value[result.value.id],
|
||||
credential_status: result.value.data
|
||||
}
|
||||
} else if (result.status === 'rejected') {
|
||||
console.warn(`获取渠道凭证状态失败: ${result.reason?.message || result.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,45 +103,56 @@ export const useChannelStore = defineStore('channel', () => {
|
||||
}
|
||||
|
||||
async function startChannel(channelId) {
|
||||
const original = { ...channels.value[channelId] }
|
||||
channels.value[channelId] = {
|
||||
...channels.value[channelId],
|
||||
...original,
|
||||
enabled: true,
|
||||
status: 'connecting'
|
||||
}
|
||||
const res = await channelApi.startChannel(channelId)
|
||||
if (res?.code !== 0 && res?.code !== undefined) {
|
||||
channels.value[channelId] = {
|
||||
...channels.value[channelId],
|
||||
enabled: false,
|
||||
status: 'disconnected'
|
||||
try {
|
||||
const res = await channelApi.startChannel(channelId)
|
||||
if (res?.code !== 0 && res?.code !== undefined) {
|
||||
channels.value[channelId] = original
|
||||
}
|
||||
return res
|
||||
} catch (e) {
|
||||
channels.value[channelId] = original
|
||||
throw e
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function stopChannel(channelId) {
|
||||
const original = { ...channels.value[channelId] }
|
||||
channels.value[channelId] = {
|
||||
...channels.value[channelId],
|
||||
...original,
|
||||
enabled: false,
|
||||
status: 'disconnected'
|
||||
}
|
||||
const res = await channelApi.stopChannel(channelId)
|
||||
if (res?.code !== 0 && res?.code !== undefined) {
|
||||
channels.value[channelId] = {
|
||||
...channels.value[channelId],
|
||||
enabled: true
|
||||
try {
|
||||
const res = await channelApi.stopChannel(channelId)
|
||||
if (res?.code !== 0 && res?.code !== undefined) {
|
||||
channels.value[channelId] = original
|
||||
}
|
||||
return res
|
||||
} catch (e) {
|
||||
channels.value[channelId] = original
|
||||
throw e
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function restartChannel(channelId) {
|
||||
const original = { ...channels.value[channelId] }
|
||||
channels.value[channelId] = {
|
||||
...channels.value[channelId],
|
||||
...original,
|
||||
status: 'connecting'
|
||||
}
|
||||
const res = await channelApi.restartChannel(channelId)
|
||||
return res
|
||||
try {
|
||||
const res = await channelApi.restartChannel(channelId)
|
||||
return res
|
||||
} catch (e) {
|
||||
channels.value[channelId] = original
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel(channelId) {
|
||||
@ -262,18 +279,47 @@ export const useChannelStore = defineStore('channel', () => {
|
||||
wsState.value = 'disconnected'
|
||||
}
|
||||
|
||||
function _scheduleNextPoll() {
|
||||
if (!pollingActive.value) return
|
||||
pollingTimer.value = setTimeout(async () => {
|
||||
if (!pollingActive.value) return
|
||||
if (document.hidden) {
|
||||
_scheduleNextPoll()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetchAllStatus()
|
||||
pollingInterval.value = POLLING_BASE
|
||||
} catch {
|
||||
pollingInterval.value = Math.min(pollingInterval.value * 2, POLLING_MAX)
|
||||
}
|
||||
_scheduleNextPoll()
|
||||
}, pollingInterval.value)
|
||||
}
|
||||
|
||||
function _handleVisibilityChange() {
|
||||
if (!document.hidden && pollingActive.value) {
|
||||
clearTimeout(pollingTimer.value)
|
||||
pollingInterval.value = POLLING_BASE
|
||||
_scheduleNextPoll()
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(intervalMs = 5000) {
|
||||
stopPolling()
|
||||
pollingTimer.value = setInterval(() => {
|
||||
fetchAllStatus()
|
||||
}, intervalMs)
|
||||
pollingInterval.value = intervalMs
|
||||
pollingActive.value = true
|
||||
document.addEventListener('visibilitychange', _handleVisibilityChange)
|
||||
_scheduleNextPoll()
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
pollingActive.value = false
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
clearTimeout(pollingTimer.value)
|
||||
pollingTimer.value = null
|
||||
}
|
||||
document.removeEventListener('visibilitychange', _handleVisibilityChange)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -4,11 +4,13 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Play, Square, RotateCcw, Plug, CheckSquare, X,
|
||||
Plus, MessageCircle, Settings, Shield
|
||||
Plus, MessageCircle, Settings, Shield, Trash2, Clipboard, Stethoscope
|
||||
} from 'lucide-vue-next'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { channelIcons } from '@/constants/channelIcons'
|
||||
import { channelTypeLabels } from '@/constants/channelTypes'
|
||||
import { useCredentialStatus } from '@/composables/useCredentialStatus'
|
||||
import PageHeader from '@/components/shared/PageHeader.vue'
|
||||
import PageShoulder from '@/components/shared/PageShoulder.vue'
|
||||
import InfoCard from '@/components/shared/InfoCard.vue'
|
||||
@ -21,6 +23,7 @@ import ChannelMessageLog from '@/components/channels/ChannelMessageLog.vue'
|
||||
import ChannelAbilityPanel from '@/components/channels/ChannelAbilityPanel.vue'
|
||||
import ChannelMappingBrowser from '@/components/channels/ChannelMappingBrowser.vue'
|
||||
import ChannelTestDialog from '@/components/channels/ChannelTestDialog.vue'
|
||||
import ChannelDebugLog from '@/components/channels/ChannelDebugLog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -44,6 +47,8 @@ const actionLoading = reactive({})
|
||||
const credRefreshLoading = ref(false)
|
||||
const policySaving = ref(false)
|
||||
|
||||
const restartHint = ref(false)
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
const channelDetail = computed(() =>
|
||||
@ -76,7 +81,9 @@ const tabs = [
|
||||
{ key: 'policy', label: '策略配置' },
|
||||
{ key: 'stats', label: '统计图表' },
|
||||
{ key: 'messages', label: '消息日志' },
|
||||
{ key: 'mapping', label: '映射管理' }
|
||||
{ key: 'mapping', label: '映射管理' },
|
||||
{ key: 'diagnostic', label: '诊断报告' },
|
||||
{ key: 'debug', label: '运行日志' }
|
||||
]
|
||||
|
||||
const channelList = computed(() => Object.values(channelStore.channels))
|
||||
@ -138,6 +145,34 @@ function handleSelectAll() {
|
||||
async function handleBatchAction(action) {
|
||||
const ids = [...selectedIds.value]
|
||||
if (!ids.length) return
|
||||
|
||||
if (action === 'unregister') {
|
||||
Modal.confirm({
|
||||
title: '确认批量注销',
|
||||
content: `确定要注销选中的 ${ids.length} 个渠道吗?注销后渠道将停止运行,此操作不可撤销。`,
|
||||
okText: '确认注销',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
batchActionLoading.value = true
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => channelStore.unregisterChannel(id).then(() => ({ id, ok: true })).catch((e) => ({ id, ok: false, error: e })))
|
||||
)
|
||||
const succeeded = results.filter((r) => r.value?.ok).length
|
||||
const failed = results.length - succeeded
|
||||
if (failed === 0) {
|
||||
message.success(`批量注销完成:${succeeded} 个成功`)
|
||||
} else {
|
||||
message.warning(`批量注销完成:${succeeded} 个成功,${failed} 个失败`)
|
||||
}
|
||||
exitBatchMode()
|
||||
batchActionLoading.value = false
|
||||
await channelStore.fetchAllStatus()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
batchActionLoading.value = true
|
||||
const actionFn = {
|
||||
start: (id) => channelStore.startChannel(id),
|
||||
@ -279,8 +314,13 @@ async function loadChannelDetail(channelId) {
|
||||
|
||||
async function handleConfigSave() {
|
||||
try {
|
||||
await channelStore.updateConfig(detailChannelId.value, editableConfig.value)
|
||||
message.success('配置已保存')
|
||||
const res = await channelStore.updateConfig(detailChannelId.value, editableConfig.value)
|
||||
restartHint.value = !!(res?.data?.needs_restart)
|
||||
if (restartHint.value) {
|
||||
message.warning('配置已保存。建议重启渠道以确保所有内部状态同步。')
|
||||
} else {
|
||||
message.success(res?.message || '配置已保存')
|
||||
}
|
||||
await loadChannelDetail(detailChannelId.value)
|
||||
} catch (e) {
|
||||
message.error(e.message || '保存失败')
|
||||
@ -351,37 +391,6 @@ const showRegisterDialog = ref(false)
|
||||
const registerChannelType = ref('')
|
||||
const registerLoading = ref(false)
|
||||
|
||||
const channelTypeLabels = {
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
whatsapp: 'WhatsApp',
|
||||
webchat: '网页聊天',
|
||||
wechat: '微信',
|
||||
feishu: '飞书',
|
||||
dingding: '钉钉',
|
||||
line: 'LINE',
|
||||
qqbot: 'QQ 机器人',
|
||||
msteams: 'Microsoft Teams',
|
||||
googlechat: 'Google Chat',
|
||||
signal: 'Signal',
|
||||
matrix: 'Matrix',
|
||||
nostr: 'Nostr',
|
||||
irc: 'IRC',
|
||||
mattermost: 'Mattermost',
|
||||
twitch: 'Twitch',
|
||||
imessage: 'iMessage',
|
||||
bluebubbles: 'BlueBubbles',
|
||||
synologychat: 'Synology Chat',
|
||||
'nextcloud-talk': 'Nextcloud Talk',
|
||||
zalo_user: 'Zalo User',
|
||||
zalo_oa: 'Zalo OA',
|
||||
sms: 'SMS',
|
||||
email: 'Email',
|
||||
urbit: 'Urbit',
|
||||
yuanbao: '元宝'
|
||||
}
|
||||
|
||||
const availableChannelTypes = Object.keys(channelTypeLabels).sort()
|
||||
|
||||
async function handleRegisterChannel() {
|
||||
@ -450,7 +459,7 @@ function getChannelStatus(channel) {
|
||||
function getChannelInfo(channel) {
|
||||
return [
|
||||
{ label: '类型', value: channel.channel_type || '-' },
|
||||
{ label: '今日消息', value: channel.today_count ?? '-' },
|
||||
{ label: '今日消息', value: channel.today_messages ?? '-' },
|
||||
{ label: '活跃连接', value: channel.active_connections ?? '-' }
|
||||
]
|
||||
}
|
||||
@ -468,33 +477,9 @@ function handleCardTest(channelId) {
|
||||
router.push({ name: 'ChannelDetailComp', params: { channelId }, query: { tab: 'test' } })
|
||||
}
|
||||
|
||||
function credentialSourceLabel(source) {
|
||||
const map = { db: '数据库', config: '配置文件', env: '环境变量', file: '文件', memory: '内存', none: '无' }
|
||||
return map[source] || source || '-'
|
||||
}
|
||||
|
||||
function credentialStatusClass(cs) {
|
||||
if (!cs || !cs.has_credential) return 'cred-none'
|
||||
if (cs.is_expired) return 'cred-expired'
|
||||
if (cs.expires_at && cs.source === 'db') {
|
||||
const remaining = new Date(cs.expires_at).getTime() - Date.now()
|
||||
if (remaining <= 3600000) return 'cred-soon'
|
||||
return 'cred-ok'
|
||||
}
|
||||
if (cs.source === 'memory') return 'cred-memory'
|
||||
return 'cred-ok'
|
||||
}
|
||||
|
||||
function credentialStatusLabel(cs) {
|
||||
const map = {
|
||||
'cred-ok': '凭证正常',
|
||||
'cred-soon': '即将过期',
|
||||
'cred-expired': '已过期',
|
||||
'cred-none': '无凭证',
|
||||
'cred-memory': '内存存储'
|
||||
}
|
||||
return map[credentialStatusClass(cs)] || '凭证正常'
|
||||
}
|
||||
const { credentialClass, credentialLabel, credentialSourceLabel } = useCredentialStatus(
|
||||
() => channelDetail.value?.credential_status
|
||||
)
|
||||
|
||||
async function handleRefreshCredential(channelId) {
|
||||
if (credRefreshLoading.value) return
|
||||
@ -510,6 +495,98 @@ async function handleRefreshCredential(channelId) {
|
||||
}
|
||||
}
|
||||
|
||||
const diagnosticCopying = ref(false)
|
||||
|
||||
const diagnosticReport = computed(() => {
|
||||
const ch = channelDetail.value
|
||||
if (!ch) return null
|
||||
|
||||
const configKeys = ch.config ? Object.keys(ch.config).filter((k) => k !== 'enabled') : []
|
||||
const credStatus = ch.credential_status
|
||||
const credentialOk = credStatus?.status === 'ok' || credStatus?.status === 'valid'
|
||||
|
||||
const checks = []
|
||||
checks.push({
|
||||
name: '渠道注册',
|
||||
status: ch.channel_id ? 'pass' : 'fail',
|
||||
detail: ch.channel_id ? `渠道已注册: ${ch.channel_id}` : '渠道未注册'
|
||||
})
|
||||
checks.push({
|
||||
name: '启用状态',
|
||||
status: ch.enabled ? 'pass' : 'warn',
|
||||
detail: ch.enabled ? '渠道已启用' : '渠道已禁用'
|
||||
})
|
||||
checks.push({
|
||||
name: '连接状态',
|
||||
status: ch.status === 'connected' ? 'pass' : ch.status === 'connecting' || ch.status === 'reconnecting' ? 'warn' : 'fail',
|
||||
detail: `当前状态: ${ch.status || '未知'}`
|
||||
})
|
||||
checks.push({
|
||||
name: '配置完整性',
|
||||
status: configKeys.length > 0 ? 'pass' : 'warn',
|
||||
detail: configKeys.length > 0 ? `已配置 ${configKeys.length} 项: ${configKeys.join(', ')}` : '暂无配置项'
|
||||
})
|
||||
checks.push({
|
||||
name: '凭证状态',
|
||||
status: credentialOk ? 'pass' : credStatus ? 'warn' : 'info',
|
||||
detail: credStatus
|
||||
? `来源: ${credStatus.source || '-'}, 类型: ${credStatus.credential_type || '-'}, 状态: ${credStatus.status || '-'}`
|
||||
: '未获取凭证信息'
|
||||
})
|
||||
if (credStatus?.expires_at) {
|
||||
checks.push({
|
||||
name: '凭证有效期',
|
||||
status: credStatus.status === 'expired' ? 'fail' : 'pass',
|
||||
detail: `过期时间: ${credStatus.expires_at}`
|
||||
})
|
||||
}
|
||||
checks.push({
|
||||
name: 'Agent 路由',
|
||||
status: (ch.routing && ch.routing.length > 0) ? 'pass' : 'info',
|
||||
detail: (ch.routing && ch.routing.length > 0) ? `已配置 ${ch.routing.length} 条路由规则` : '未配置路由规则'
|
||||
})
|
||||
|
||||
return {
|
||||
channel_name: ch.display_name || ch.channel_id,
|
||||
channel_id: ch.channel_id,
|
||||
channel_type: ch.channel_type,
|
||||
generated_at: new Date().toLocaleString(),
|
||||
checks
|
||||
}
|
||||
})
|
||||
|
||||
function buildDiagnosticText(report) {
|
||||
const statusMap = { pass: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' }
|
||||
let text = '='.repeat(50) + '\n'
|
||||
text += ` 渠道诊断报告\n`
|
||||
text += '='.repeat(50) + '\n'
|
||||
text += `渠道名称: ${report.channel_name}\n`
|
||||
text += `渠道 ID: ${report.channel_id}\n`
|
||||
text += `渠道类型: ${report.channel_type}\n`
|
||||
text += `生成时间: ${report.generated_at}\n`
|
||||
text += '-'.repeat(50) + '\n'
|
||||
for (const check of report.checks) {
|
||||
text += ` ${statusMap[check.status] || ' '} ${check.name}: ${check.detail}\n`
|
||||
}
|
||||
text += '='.repeat(50) + '\n'
|
||||
return text
|
||||
}
|
||||
|
||||
async function copyDiagnosticReport() {
|
||||
const report = diagnosticReport.value
|
||||
if (!report) return
|
||||
diagnosticCopying.value = true
|
||||
try {
|
||||
const text = buildDiagnosticText(report)
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('诊断报告已复制到剪贴板')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制')
|
||||
} finally {
|
||||
diagnosticCopying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.channelId) {
|
||||
await channelStore.fetchAllStatus()
|
||||
@ -572,6 +649,9 @@ onUnmounted(() => {
|
||||
<a-button size="small" :loading="batchActionLoading" @click="handleBatchAction('restart')">
|
||||
<RotateCcw :size="12" /> 批量重启
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="batchActionLoading" @click="handleBatchAction('unregister')">
|
||||
<Trash2 :size="12" /> 批量注销
|
||||
</a-button>
|
||||
<button class="cancel-batch-btn" @click="exitBatchMode">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
@ -763,7 +843,7 @@ onUnmounted(() => {
|
||||
<span class="info-label">凭证来源</span>
|
||||
<span class="info-value">
|
||||
<span class="cred-source-badge" :class="`cred-source-${channelDetail.credential_status.source}`">
|
||||
{{ credentialSourceLabel(channelDetail.credential_status.source) }}
|
||||
{{ credentialSourceLabel }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@ -774,8 +854,8 @@ onUnmounted(() => {
|
||||
<div class="info-item">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">
|
||||
<span class="cred-status-badge" :class="credentialStatusClass(channelDetail.credential_status)">
|
||||
{{ credentialStatusLabel(channelDetail.credential_status) }}
|
||||
<span class="cred-status-badge" :class="credentialClass">
|
||||
{{ credentialLabel }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@ -844,6 +924,18 @@ onUnmounted(() => {
|
||||
保存配置
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="restartHint" class="restart-hint">
|
||||
<span>配置已更新,建议重启渠道以使变更生效。</span>
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="!!actionLoading[`restart_${detailChannelId}`]"
|
||||
@click="handleRestartChannel"
|
||||
>
|
||||
<RotateCcw :size="12" />
|
||||
立即重启
|
||||
</a-button>
|
||||
</div>
|
||||
<ChannelConfigForm
|
||||
:channel-type="channelDetail.channel_type"
|
||||
:channel-id="detailChannelId"
|
||||
@ -899,6 +991,71 @@ onUnmounted(() => {
|
||||
<h3 class="section-title">映射管理</h3>
|
||||
<ChannelMappingBrowser :channel-id="detailChannelId" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'diagnostic'" class="section-card">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<Stethoscope :size="16" class="diagnostic-title-icon" />
|
||||
诊断报告
|
||||
</h3>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="diagnosticCopying"
|
||||
@click="copyDiagnosticReport"
|
||||
>
|
||||
<Clipboard :size="14" />
|
||||
一键复制报告
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="diagnosticReport" class="diagnostic-content">
|
||||
<div class="diagnostic-meta">
|
||||
<div class="diagnostic-meta-item">
|
||||
<span class="diagnostic-meta-label">渠道名称</span>
|
||||
<span class="diagnostic-meta-value">{{ diagnosticReport.channel_name }}</span>
|
||||
</div>
|
||||
<div class="diagnostic-meta-item">
|
||||
<span class="diagnostic-meta-label">渠道 ID</span>
|
||||
<span class="diagnostic-meta-value mono">{{ diagnosticReport.channel_id }}</span>
|
||||
</div>
|
||||
<div class="diagnostic-meta-item">
|
||||
<span class="diagnostic-meta-label">渠道类型</span>
|
||||
<span class="diagnostic-meta-value">{{ diagnosticReport.channel_type }}</span>
|
||||
</div>
|
||||
<div class="diagnostic-meta-item">
|
||||
<span class="diagnostic-meta-label">报告时间</span>
|
||||
<span class="diagnostic-meta-value">{{ diagnosticReport.generated_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diagnostic-checks">
|
||||
<div
|
||||
v-for="check in diagnosticReport.checks"
|
||||
:key="check.name"
|
||||
class="diagnostic-check-item"
|
||||
:class="`diagnostic-check--${check.status}`"
|
||||
>
|
||||
<span class="diagnostic-check-icon">
|
||||
<span v-if="check.status === 'pass'">✅</span>
|
||||
<span v-else-if="check.status === 'warn'">⚠️</span>
|
||||
<span v-else-if="check.status === 'fail'">❌</span>
|
||||
<span v-else>ℹ️</span>
|
||||
</span>
|
||||
<div class="diagnostic-check-body">
|
||||
<span class="diagnostic-check-name">{{ check.name }}</span>
|
||||
<span class="diagnostic-check-detail">{{ check.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="diagnostic-empty">
|
||||
暂无诊断数据,请先加载渠道详情
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'debug'" class="section-card">
|
||||
<h3 class="section-title">运行日志</h3>
|
||||
<ChannelDebugLog :channel-id="detailChannelId" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1618,4 +1775,127 @@ onUnmounted(() => {
|
||||
&.cred-none { background: var(--gray-100); color: var(--gray-500); }
|
||||
&.cred-memory { background: var(--color-warning-50); color: var(--color-warning-700); }
|
||||
}
|
||||
|
||||
.restart-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-warning-50);
|
||||
border: 1px solid var(--color-warning-200);
|
||||
font-size: 13px;
|
||||
color: var(--color-warning-700);
|
||||
}
|
||||
|
||||
.diagnostic-title-icon {
|
||||
vertical-align: -3px;
|
||||
margin-right: 4px;
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.diagnostic-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.diagnostic-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.diagnostic-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.diagnostic-meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.diagnostic-meta-value {
|
||||
font-size: 14px;
|
||||
color: var(--gray-1000);
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.diagnostic-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--gray-150);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.diagnostic-check-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--gray-25);
|
||||
|
||||
&.diagnostic-check--pass {
|
||||
border-left: 3px solid var(--color-success-500);
|
||||
}
|
||||
|
||||
&.diagnostic-check--warn {
|
||||
border-left: 3px solid var(--color-warning-500);
|
||||
background: var(--color-warning-50);
|
||||
}
|
||||
|
||||
&.diagnostic-check--fail {
|
||||
border-left: 3px solid var(--color-error-500);
|
||||
background: var(--color-error-50);
|
||||
}
|
||||
|
||||
&.diagnostic-check--info {
|
||||
border-left: 3px solid var(--color-info-500);
|
||||
}
|
||||
}
|
||||
|
||||
.diagnostic-check-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diagnostic-check-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.diagnostic-check-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-1000);
|
||||
}
|
||||
|
||||
.diagnostic-check-detail {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.diagnostic-empty {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@ -177,9 +177,9 @@ async function loadChannelStats() {
|
||||
const total = channels.length
|
||||
const connected = channels.filter((c) => c.status === 'connected').length
|
||||
const errorCount = channels.filter((c) => c.status === 'error').length
|
||||
const todayMessages = channels.reduce((sum, c) => sum + (c.today_count || 0), 0)
|
||||
const todayMessages = channels.reduce((sum, c) => sum + (c.today_messages || 0), 0)
|
||||
const totalSuccess = channels.reduce((sum, c) => sum + (c.success_count || 0), 0)
|
||||
const totalAll = channels.reduce((sum, c) => sum + (c.total_count || 0), 0)
|
||||
const totalAll = channels.reduce((sum, c) => sum + (c.total_messages || 0), 0)
|
||||
const successRate = totalAll > 0 ? Math.round((totalSuccess / totalAll) * 100) : 0
|
||||
channelStats.value = { total, connected, error: errorCount, todayMessages, successRate }
|
||||
} catch (e) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user