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:
Kris 2026-05-14 09:47:00 +08:00
parent 6ca611fead
commit cb1b9ba889
13 changed files with 1364 additions and 200 deletions

View File

@ -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') {

View File

@ -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')

View File

@ -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>

View 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>

View File

@ -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;

View File

@ -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;

View File

@ -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>

View 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
}
}

View File

@ -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`
}

View 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: '元宝'
}

View File

@ -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 {

View File

@ -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>

View File

@ -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) {