ForcePilot/web/src/views/ChannelManageView.vue
Kris cb1b9ba889 feat: 新增渠道管理全量功能优化与日志诊断模块
1.  重构api请求统一处理接口返回错误,标准化错误抛出格式
2.  新增渠道类型常量与图标映射,清理冗余图标配置
3.  重构凭证状态逻辑,抽离为可复用composable工具
4.  优化WebSocket连接管理,新增断开状态标记防止重连异常
5.  新增渠道运行日志组件,支持实时查看与刷新日志
6.  新增渠道诊断报告功能,一键生成并复制渠道状态报告
7.  优化消息日志面板,支持正则搜索、多格式导出与消息重发
8.  新增统计图表对比功能,支持多渠道数据横向对比
9.  优化仪表盘统计字段映射,修正today_count与total_count字段名
10. 重构渠道启停逻辑,添加异常回滚与错误捕获
11. 新增批量注销渠道功能,优化配置保存后的重启提示
12. 优化轮询机制,新增页面可见性监听与指数退避重试策略
2026-05-14 09:47:00 +08:00

1901 lines
52 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
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, 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'
import ExtensionCardGrid from '@/components/extensions/ExtensionCardGrid.vue'
import ChannelConfigForm from '@/components/channels/ChannelConfigForm.vue'
import AgentRoutingTable from '@/components/channels/AgentRoutingTable.vue'
import ChannelPolicyForm from '@/components/channels/ChannelPolicyForm.vue'
import ChannelStatsPanel from '@/components/channels/ChannelStatsPanel.vue'
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()
const channelStore = useChannelStore()
const agentStore = useAgentStore()
const props = defineProps({
channelId: { type: String, default: null }
})
const viewMode = ref('overview')
const detailChannelId = ref(null)
const detailLoading = ref(false)
const activeTab = ref('info')
const showTestDialog = ref(false)
const editableConfig = ref({})
const editablePolicy = ref({})
const actionLoading = reactive({})
const credRefreshLoading = ref(false)
const policySaving = ref(false)
const restartHint = ref(false)
const refreshing = ref(false)
const channelDetail = computed(() =>
detailChannelId.value ? channelStore.channels[detailChannelId.value] : null
)
const agentList = computed(() => agentStore.agentsList)
watch(channelDetail, (detail) => {
if (detail) {
editableConfig.value = { ...(detail.config || {}) }
editablePolicy.value = { ...(detail.policy || {}) }
}
})
watch(activeTab, (tab) => {
if (tab === 'routing' && detailChannelId.value) {
channelStore.fetchRouting(detailChannelId.value)
}
if (tab === 'policy' && detailChannelId.value) {
channelStore.fetchChannelPolicy(detailChannelId.value)
}
})
const tabs = [
{ key: 'info', label: '基本信息' },
{ key: 'config', label: '连接配置' },
{ key: 'ability', label: '渠道能力' },
{ key: 'routing', label: 'Agent 路由' },
{ key: 'policy', label: '策略配置' },
{ key: 'stats', label: '统计图表' },
{ key: 'messages', label: '消息日志' },
{ key: 'mapping', label: '映射管理' },
{ key: 'diagnostic', label: '诊断报告' },
{ key: 'debug', label: '运行日志' }
]
const channelList = computed(() => Object.values(channelStore.channels))
const searchQuery = ref('')
const filteredChannelList = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) return channelList.value
return channelList.value.filter(
(ch) =>
(ch.display_name || '').toLowerCase().includes(keyword) ||
(ch.channel_id || '').toLowerCase().includes(keyword) ||
(ch.channel_type || '').toLowerCase().includes(keyword)
)
})
const channelStats = computed(() => {
let total = 0, enabled = 0, running = 0
for (const ch of channelList.value) {
total++
if (ch.enabled) enabled++
if (ch.status === 'connected') running++
}
return { total, enabled, running }
})
const skeletonCount = 6
const batchMode = ref(false)
const selectedIds = ref(new Set())
const batchActionLoading = ref(false)
const allSelected = computed(() =>
filteredChannelList.value.length > 0 &&
filteredChannelList.value.every((ch) => selectedIds.value.has(ch.channel_id))
)
const selectedCount = computed(() => selectedIds.value.size)
function enterBatchMode() {
batchMode.value = true
selectedIds.value = new Set()
}
function exitBatchMode() {
batchMode.value = false
selectedIds.value = new Set()
}
function handleSelectAll() {
if (allSelected.value) {
selectedIds.value = new Set()
} else {
selectedIds.value = new Set(filteredChannelList.value.map((ch) => ch.channel_id))
}
}
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),
stop: (id) => channelStore.stopChannel(id),
restart: (id) => channelStore.restartChannel(id)
}[action]
const results = await Promise.allSettled(ids.map((id) => actionFn(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(`批量${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}完成:${succeeded} 个成功`)
} else {
message.warning(`批量操作完成:${succeeded} 个成功,${failed} 个失败`)
}
exitBatchMode()
batchActionLoading.value = false
await channelStore.fetchAllStatus()
}
function handleManage(channelId) {
router.push({ name: 'ChannelDetailComp', params: { channelId } })
}
async function handleToggle(channelId) {
if (actionLoading[`toggle_${channelId}`]) return
actionLoading[`toggle_${channelId}`] = true
try {
const channel = channelStore.channels[channelId]
if (channel?.enabled) {
const res = await channelStore.stopChannel(channelId)
if (res?.code === 0) {
message.success('渠道已停止')
} else {
message.warning(res?.message || '停止失败')
}
} else {
const res = await channelStore.startChannel(channelId)
if (res?.code === 0) {
message.success('渠道已启动')
} else {
message.warning(res?.message || '启动失败')
}
}
} catch (e) {
message.error(e.message || '操作失败')
} finally {
delete actionLoading[`toggle_${channelId}`]
}
}
async function handleCardRestart(channelId) {
if (actionLoading[`restart_${channelId}`]) return
actionLoading[`restart_${channelId}`] = true
try {
const res = await channelStore.restartChannel(channelId)
if (res?.code === 0) {
message.success('渠道重启指令已发送')
} else {
message.warning(res?.message || '重启失败')
}
} catch (e) {
message.error(e.message || '重启失败')
} finally {
delete actionLoading[`restart_${channelId}`]
}
}
async function handleStartChannel() {
const id = detailChannelId.value
if (actionLoading[`start_${id}`]) return
actionLoading[`start_${id}`] = true
try {
await channelStore.startChannel(id)
message.success('渠道启动指令已发送')
await loadChannelDetail(id)
} catch (e) {
message.error(e.message || '启动失败')
} finally {
delete actionLoading[`start_${id}`]
}
}
async function handleStopChannel() {
const id = detailChannelId.value
if (actionLoading[`stop_${id}`]) return
actionLoading[`stop_${id}`] = true
try {
await channelStore.stopChannel(id)
message.success('渠道已停止')
await loadChannelDetail(id)
} catch (e) {
message.error(e.message || '停止失败')
} finally {
delete actionLoading[`stop_${id}`]
}
}
async function handleRestartChannel() {
const id = detailChannelId.value
if (actionLoading[`restart_${id}`]) return
actionLoading[`restart_${id}`] = true
try {
await channelStore.restartChannel(id)
message.success('渠道重启指令已发送')
await loadChannelDetail(id)
} catch (e) {
message.error(e.message || '重启失败')
} finally {
delete actionLoading[`restart_${id}`]
}
}
function handleTestChannel() {
showTestDialog.value = true
}
function goBack() {
editableConfig.value = {}
editablePolicy.value = {}
router.push({ name: 'ChannelManageComp' })
}
async function loadChannelDetail(channelId) {
detailLoading.value = true
try {
await Promise.all([
channelStore.fetchChannelDetail(channelId),
channelStore.fetchChannelPolicy(channelId),
channelStore.fetchRouting(channelId),
channelStore.fetchCredentialStatus(channelId)
])
} finally {
detailLoading.value = false
}
}
async function handleConfigSave() {
try {
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 || '保存失败')
}
}
function handleRoutingSave(action) {
const channel = channelDetail.value
if (!channel) return
const routes = [...(channel.routing || [])]
if (action.index >= 0) {
routes[action.index] = action.route
} else {
routes.push(action.route)
}
channelStore.updateRouting(detailChannelId.value, routes).then(() => {
message.success('路由已更新')
}).catch((e) => {
message.error(e?.response?.data?.message || e.message || '保存失败')
})
}
function handleRoutingDelete(index) {
const channel = channelDetail.value
if (!channel) return
const routes = (channel.routing || []).filter((_, i) => i !== index)
channelStore.updateRouting(detailChannelId.value, routes).then(() => {
message.success('路由已删除')
}).catch((e) => {
message.error(e?.response?.data?.message || e.message || '删除失败')
})
}
async function handlePolicySave() {
if (policySaving.value) return
policySaving.value = true
try {
await channelStore.updatePolicy(detailChannelId.value, editablePolicy.value)
message.success('策略配置已保存')
await channelStore.fetchChannelPolicy(detailChannelId.value)
} finally {
policySaving.value = false
}
}
async function handleDeleteChannel() {
const id = detailChannelId.value
const name = channelDetail.value?.display_name || id
Modal.confirm({
title: '确认注销渠道',
content: `确定要注销渠道 "${name}" 吗?注销后渠道将停止运行,此操作不可撤销。`,
okText: '确认注销',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await channelStore.unregisterChannel(id)
message.success(`渠道 "${name}" 已注销`)
router.push({ name: 'ChannelManageComp' })
} catch (e) {
message.error(e.message || '注销渠道失败')
}
},
})
}
const showRegisterDialog = ref(false)
const registerChannelType = ref('')
const registerLoading = ref(false)
const availableChannelTypes = Object.keys(channelTypeLabels).sort()
async function handleRegisterChannel() {
if (!registerChannelType.value) return
registerLoading.value = true
try {
const res = await channelStore.registerChannel(registerChannelType.value)
if (res?.code === 0) {
message.success('渠道注册成功')
showRegisterDialog.value = false
registerChannelType.value = ''
await channelStore.fetchAllStatus()
} else {
message.warning(res?.message || '注册失败')
}
} catch (e) {
message.error(e.message || '注册失败')
} finally {
registerLoading.value = false
}
}
watch(() => props.channelId, async (newId, oldId) => {
if (newId) {
viewMode.value = 'detail'
detailChannelId.value = newId
channelStore.selectedChannelId = newId
channelStore.stopPolling()
activeTab.value = 'info'
await loadChannelDetail(newId)
if (route.query.tab === 'test') {
showTestDialog.value = true
}
} else if (oldId) {
viewMode.value = 'overview'
detailChannelId.value = null
channelStore.selectedChannelId = null
editableConfig.value = {}
editablePolicy.value = {}
await channelStore.fetchAllStatus()
if (channelStore.wsState !== 'connected') {
channelStore.startPolling(5000)
}
}
})
// ============ Card Helpers (overview) ============
const imgErrors = ref({})
function getChannelIcon(channel) {
const type = channel?.channel_type || ''
return channelIcons[type] || channelIcons.default
}
function getChannelStatus(channel) {
if (!channel.enabled) return { label: '未启用', level: 'off' }
const map = {
connected: { label: '运行中', level: 'success' },
connecting: { label: '连接中', level: 'warning' },
reconnecting: { label: '重连中', level: 'warning' },
error: { label: '异常', level: 'error' }
}
return map[channel.status] || { label: channel.status || '未知', level: 'info' }
}
function getChannelInfo(channel) {
return [
{ label: '类型', value: channel.channel_type || '-' },
{ label: '今日消息', value: channel.today_messages ?? '-' },
{ label: '活跃连接', value: channel.active_connections ?? '-' }
]
}
async function handleRefresh() {
refreshing.value = true
try {
await channelStore.fetchAllStatus()
} finally {
refreshing.value = false
}
}
function handleCardTest(channelId) {
router.push({ name: 'ChannelDetailComp', params: { channelId }, query: { tab: 'test' } })
}
const { credentialClass, credentialLabel, credentialSourceLabel } = useCredentialStatus(
() => channelDetail.value?.credential_status
)
async function handleRefreshCredential(channelId) {
if (credRefreshLoading.value) return
credRefreshLoading.value = true
try {
await channelStore.refreshCredential(channelId)
message.success('凭证刷新成功')
await loadChannelDetail(channelId)
} catch (e) {
message.error(e.message || '刷新凭证失败')
} finally {
credRefreshLoading.value = false
}
}
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()
const token = localStorage.getItem('token') || ''
channelStore.initWS(token).then(() => {
}).catch(() => {
channelStore.startPolling(5000)
})
}
})
onUnmounted(() => {
channelStore.stopPolling()
channelStore.disconnectWS()
channelStore.selectedChannelId = null
})
</script>
<template>
<div class="channel-manage-view">
<template v-if="viewMode === 'overview'">
<!-- Page Header -->
<PageHeader
title="渠道管理"
description="管理和监控所有消息渠道的连接状态与配置"
:show-border="true"
>
<template #info>
<div class="summary-strip">
<span class="stat-pill">
渠道总数 <strong>{{ channelStats.total }}</strong>
</span>
<span class="stat-pill">
已启用 <strong>{{ channelStats.enabled }}</strong>
</span>
<span class="stat-pill stat-pill--running">
运行中 <strong>{{ channelStats.running }}</strong>
</span>
</div>
</template>
</PageHeader>
<!-- Batch Bar -->
<div v-if="batchMode" class="batch-bar">
<div class="batch-bar-left">
<label class="select-all-label">
<input type="checkbox" :checked="allSelected" @change="handleSelectAll" />
全选
</label>
<span class="batch-count">已选 {{ selectedCount }} 项</span>
</div>
<div class="batch-bar-right">
<a-button size="small" :loading="batchActionLoading" @click="handleBatchAction('start')">
<Play :size="12" /> 批量启动
</a-button>
<a-button size="small" :loading="batchActionLoading" @click="handleBatchAction('stop')">
<Square :size="12" /> 批量停止
</a-button>
<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>
</div>
</div>
<!-- Toolbar -->
<PageShoulder v-model:search="searchQuery" search-placeholder="搜索渠道名称、ID 或类型...">
<template #actions>
<a-button type="primary" class="lucide-icon-btn register-btn" @click="showRegisterDialog = true">
<Plus :size="14" />
注册渠道
</a-button>
<a-button class="lucide-icon-btn" @click="handleRefresh" :loading="refreshing">
<RefreshCw :size="14" :class="{ spinning: refreshing }" />
</a-button>
<button v-if="!batchMode" class="batch-mode-btn" @click="enterBatchMode">
<CheckSquare :size="14" />
批量操作
</button>
</template>
</PageShoulder>
<!-- Channel Cards -->
<div class="channel-content">
<div
v-if="channelStore.isLoading && !Object.keys(channelStore.channels).length"
>
<ExtensionCardGrid :min-width="280">
<div v-for="n in skeletonCount" :key="n" class="skeleton-card"></div>
</ExtensionCardGrid>
</div>
<div v-else-if="channelStore.loadError && !Object.keys(channelStore.channels).length" class="empty-state">
<div class="empty-icon">⚠</div>
<div class="empty-title">加载失败</div>
<div class="empty-desc">{{ channelStore.loadError }}</div>
<a-button @click="channelStore.fetchAllStatus()">重试</a-button>
</div>
<div v-else-if="!channelList.length" class="empty-state">
<div class="empty-icon">📡</div>
<div class="empty-title">暂无已注册渠道</div>
<div class="empty-desc">系统尚未注册任何消息渠道,请联系管理员进行配置</div>
</div>
<div v-else-if="!filteredChannelList.length" class="empty-state">
<div class="empty-icon">🔍</div>
<div class="empty-title">未找到匹配渠道</div>
<div class="empty-desc">尝试调整搜索关键词</div>
</div>
<ExtensionCardGrid v-else :min-width="280">
<InfoCard
v-for="ch in filteredChannelList"
:key="ch.channel_id"
:title="ch.display_name || ch.channel_id"
:subtitle="ch.channel_id"
:disabled="!ch.enabled"
:status="getChannelStatus(ch)"
:info="getChannelInfo(ch)"
:default-icon="MessageCircle"
@click="handleManage(ch.channel_id)"
>
<template #icon>
<img
v-if="!imgErrors[ch.channel_id]"
:src="getChannelIcon(ch)"
:alt="ch.display_name || ch.channel_id"
@error="imgErrors[ch.channel_id] = true"
/>
</template>
<template #status>
<span class="channel-status-pill" :class="`channel-status-pill--${getChannelStatus(ch).level}`">
<span class="status-dot"></span>
{{ getChannelStatus(ch).label }}
</span>
</template>
<template #footer>
<button
class="footer-link"
type="button"
@click.stop="handleManage(ch.channel_id)"
>
<Settings :size="13" />
管理
</button>
<div class="footer-icon-actions">
<a-tooltip title="测试连接">
<button
class="footer-icon-btn"
type="button"
@click.stop="handleCardTest(ch.channel_id)"
>
<Plug :size="14" />
</button>
</a-tooltip>
<a-tooltip title="重启">
<button
class="footer-icon-btn"
type="button"
:disabled="!!actionLoading[`restart_${ch.channel_id}`]"
@click.stop="handleCardRestart(ch.channel_id)"
>
<RotateCcw :size="14" />
</button>
</a-tooltip>
<a-switch
:checked="ch.enabled"
:loading="!!actionLoading[`toggle_${ch.channel_id}`]"
size="small"
@click.stop
@change="handleToggle(ch.channel_id)"
/>
</div>
</template>
</InfoCard>
</ExtensionCardGrid>
</div>
</template>
<template v-else>
<div class="detail-toolbar">
<button class="back-btn" @click="goBack">
<ArrowLeft :size="18" />
返回
</button>
<div class="detail-title" v-if="channelDetail">
<h2>{{ channelDetail.display_name || channelDetail.channel_id }}</h2>
<span class="detail-id">{{ channelDetail.channel_id }}</span>
</div>
<div class="toolbar-spacer"></div>
<button v-if="channelDetail" class="btn-delete" @click="handleDeleteChannel">注销渠道</button>
</div>
<div class="detail-content">
<div class="detail-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<div v-if="detailLoading" class="detail-loading">加载中...</div>
<div v-else-if="channelDetail" class="detail-container">
<div v-show="activeTab === 'info'" class="section-card">
<h3 class="section-title">基本信息</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">渠道 ID</span>
<span class="info-value">{{ channelDetail.channel_id }}</span>
</div>
<div class="info-item">
<span class="info-label">渠道类型</span>
<span class="info-value">{{ channelDetail.channel_type }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">{{ channelDetail.status }}</span>
</div>
<div class="info-item">
<span class="info-label">运行状态</span>
<span class="info-value">{{ channelDetail.enabled ? '已启用' : '已禁用' }}</span>
</div>
</div>
<div v-if="channelDetail.credential_status" class="credential-info-section">
<h4 class="subsection-title">
<Shield :size="14" />
凭证状态
<a-button
size="small"
type="link"
:loading="credRefreshLoading"
@click="handleRefreshCredential(detailChannelId)"
>
<RefreshCw :size="12" />
刷新凭证
</a-button>
</h4>
<div class="credential-info-grid">
<div class="info-item">
<span class="info-label">凭证来源</span>
<span class="info-value">
<span class="cred-source-badge" :class="`cred-source-${channelDetail.credential_status.source}`">
{{ credentialSourceLabel }}
</span>
</span>
</div>
<div class="info-item">
<span class="info-label">凭证类型</span>
<span class="info-value">{{ channelDetail.credential_status.credential_type || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<span class="cred-status-badge" :class="credentialClass">
{{ credentialLabel }}
</span>
</span>
</div>
<div class="info-item">
<span class="info-label">最后更新</span>
<span class="info-value">{{ channelDetail.credential_status.last_updated || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">过期时间</span>
<span class="info-value">
{{ channelDetail.credential_status.expires_at || '无过期时间' }}
</span>
</div>
</div>
</div>
<div class="channel-actions">
<a-button
type="primary"
size="small"
class="lucide-icon-btn"
:loading="!!actionLoading[`start_${detailChannelId}`]"
@click="handleStartChannel"
>
<Play :size="14" />
启动
</a-button>
<a-button
danger
size="small"
class="lucide-icon-btn"
:disabled="!!actionLoading[`stop_${detailChannelId}`]"
@click="handleStopChannel"
>
<Square :size="14" />
停止
</a-button>
<a-button
size="small"
class="lucide-icon-btn"
:disabled="!!actionLoading[`restart_${detailChannelId}`]"
@click="handleRestartChannel"
>
<RotateCcw :size="14" />
重启
</a-button>
<a-button
size="small"
class="lucide-icon-btn"
@click="handleTestChannel"
>
<Plug :size="14" />
测试连接
</a-button>
</div>
</div>
<div v-show="activeTab === 'config'" class="section-card">
<div class="section-header">
<h3 class="section-title">连接配置</h3>
<a-button
type="primary"
size="small"
@click="handleConfigSave()"
>
保存配置
</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"
v-model="editableConfig"
:agent-list="agentList"
/>
</div>
<div v-show="activeTab === 'ability'" class="section-card">
<h3 class="section-title">渠道能力</h3>
<ChannelAbilityPanel :channel-id="detailChannelId" />
</div>
<div v-show="activeTab === 'routing'" class="section-card">
<h3 class="section-title">Agent 路由配置</h3>
<AgentRoutingTable
:routes="channelDetail.routing || []"
:agent-list="agentList"
@add="handleRoutingSave"
@edit="handleRoutingSave"
@delete="handleRoutingDelete"
/>
</div>
<div v-show="activeTab === 'policy'" class="section-card">
<div class="section-header">
<h3 class="section-title">策略配置</h3>
<a-button
type="primary"
size="small"
:loading="policySaving"
@click="handlePolicySave()"
>
保存策略
</a-button>
</div>
<ChannelPolicyForm
v-model="editablePolicy"
/>
</div>
<div v-show="activeTab === 'stats'" class="section-card">
<h3 class="section-title">消息统计</h3>
<ChannelStatsPanel :channel-id="detailChannelId" />
</div>
<div v-show="activeTab === 'messages'" class="section-card">
<h3 class="section-title">消息日志</h3>
<ChannelMessageLog :channel-id="detailChannelId" />
</div>
<div v-show="activeTab === 'mapping'" class="section-card">
<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>
<ChannelTestDialog
v-model:visible="showTestDialog"
:channel-id="detailChannelId"
@tested="loadChannelDetail(detailChannelId)"
/>
<Teleport to="body">
<div v-if="showRegisterDialog" class="dialog-overlay" @click.self="showRegisterDialog = false">
<div class="dialog-container">
<div class="dialog-header">
<h3>注册渠道</h3>
<button class="dialog-close" @click="showRegisterDialog = false">
<X :size="18" />
</button>
</div>
<div class="dialog-body">
<div class="dialog-section">
<label class="dialog-label">选择渠道类型</label>
<select v-model="registerChannelType" class="field-input">
<option value="">-- 请选择 --</option>
<option v-for="type in availableChannelTypes" :key="type" :value="type">
{{ channelTypeLabels[type] || type }}
</option>
</select>
</div>
</div>
<div class="dialog-footer">
<button class="btn-cancel" @click="showRegisterDialog = false">取消</button>
<button
class="btn-submit"
:disabled="!registerChannelType || registerLoading"
@click="handleRegisterChannel"
>
{{ registerLoading ? '注册中...' : '注册' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style lang="less" scoped>
.channel-manage-view {
display: flex;
flex-direction: column;
min-height: 100%;
background: var(--gray-0);
color: var(--gray-1000);
}
// ============ Summary Strip (Pill Tags) ============
.summary-strip {
display: flex;
gap: 8px;
}
.stat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
background: var(--gray-25);
border: 1px solid var(--gray-100);
color: var(--gray-600);
font-size: 12px;
font-weight: 500;
line-height: 20px;
strong {
color: var(--gray-900);
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
&--running {
background: var(--color-success-50);
border-color: var(--color-success-100);
color: var(--color-success-700);
strong {
color: var(--color-success-700);
}
}
}
// ============ Batch Bar ============
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px var(--page-padding);
background: var(--gray-10);
border-bottom: 1px solid var(--gray-100);
&-left {
display: flex;
align-items: center;
gap: 12px;
}
&-right {
display: flex;
align-items: center;
gap: 8px;
}
}
.select-all-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--gray-700);
cursor: pointer;
input[type="checkbox"] {
accent-color: var(--main-color);
}
}
.batch-count {
font-size: 13px;
color: var(--gray-600);
padding: 2px 8px;
background: var(--gray-50);
border-radius: 4px;
}
.cancel-batch-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--gray-150);
border-radius: 4px;
background: transparent;
color: var(--gray-500);
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--gray-300);
color: var(--gray-700);
}
}
// ============ Register button (teal solid) ============
.register-btn {
:deep(&.ant-btn-primary) {
background: var(--color-accent-700);
border-color: var(--color-accent-700);
&:hover {
background: var(--color-accent-500);
border-color: var(--color-accent-500);
}
}
}
// ============ Batch mode entry button ============
.batch-mode-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid var(--gray-200);
border-radius: 6px;
background: var(--gray-0);
color: var(--gray-700);
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
&:hover {
background: var(--main-color);
border-color: var(--main-color);
color: #fff;
}
}
// ============ Content Area ============
.channel-content {
display: flex;
flex-direction: column;
}
// ============ Channel Status Pill (dot + text) ============
.channel-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 20px;
background: var(--gray-50);
font-size: 12px;
font-weight: 500;
color: var(--gray-600);
&--success {
background: var(--color-success-50);
color: var(--color-success-700);
}
&--warning {
background: var(--color-warning-50);
color: var(--color-warning-700);
}
&--error {
background: var(--color-error-50);
color: var(--color-error-700);
}
&--off {
background: var(--gray-100);
color: var(--gray-500);
}
&--info {
background: var(--color-info-50);
color: var(--color-info-700);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
}
// ============ Card Footer ============
.footer-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--main-700);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
&:hover {
background: var(--main-30);
}
}
.footer-icon-actions {
display: flex;
align-items: center;
gap: 6px;
}
.footer-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--gray-150);
border-radius: 4px;
background: transparent;
color: var(--gray-500);
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--main-200);
color: var(--main-color);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
border-color: var(--gray-150);
color: var(--gray-400);
&:hover {
border-color: var(--gray-150);
color: var(--gray-400);
}
}
}
// ============ Skeleton ============
.skeleton-card {
height: 180px;
background: linear-gradient(90deg, var(--gray-25) 25%, var(--gray-10) 50%, var(--gray-25) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--gray-600);
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.4;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--gray-1000);
margin-bottom: 4px;
}
.empty-desc {
font-size: 14px;
margin-bottom: 16px;
}
}
// ============ Spin animation ============
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
// ============ Detail View ============
.detail-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px var(--page-padding);
border-bottom: 1px solid var(--gray-100);
background: var(--gray-0);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--gray-150);
border-radius: 6px;
background: var(--gray-0);
color: var(--gray-700);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
&:hover {
background: var(--gray-25);
border-color: var(--main-color);
color: var(--main-color);
}
}
.toolbar-spacer {
flex: 1;
}
.btn-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid var(--color-error-500);
border-radius: 6px;
background: transparent;
color: var(--color-error-500);
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
&:hover {
background: var(--color-error-500);
color: #fff;
}
}
.detail-title {
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--gray-1000);
line-height: 1.3;
}
.detail-id {
font-size: 12px;
color: var(--gray-500);
font-family: monospace;
}
}
.detail-content {
padding: 0 var(--page-padding) 24px;
}
.detail-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--gray-150);
overflow-x: auto;
.tab-btn {
padding: 8px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
font-size: 14px;
color: var(--gray-600);
cursor: pointer;
white-space: nowrap;
transition: color 0.2s ease, border-color 0.2s ease;
&:hover {
color: var(--main-color);
}
&.active {
color: var(--main-color);
border-bottom-color: var(--main-color);
font-weight: 500;
}
}
}
.detail-loading {
text-align: center;
padding: 48px;
color: var(--gray-500);
font-size: 14px;
}
.detail-container {
max-width: 960px;
margin: 16px auto 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-card {
background: var(--gray-0);
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 20px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--gray-1000);
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--gray-150);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
.section-title {
margin-bottom: 16px;
padding-bottom: 8px;
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
.info-label {
font-size: 12px;
color: var(--gray-600);
font-weight: 500;
}
.info-value {
font-size: 14px;
color: var(--gray-1000);
}
}
.channel-actions {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--gray-150);
flex-wrap: wrap;
}
// ============ Register Dialog ============
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.dialog-container {
width: 420px;
max-width: 90vw;
background: var(--gray-0);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--gray-150);
h3 {
font-size: 16px;
font-weight: 600;
color: var(--gray-1000);
margin: 0;
}
}
.dialog-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gray-600);
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--gray-25);
}
}
.dialog-body {
padding: 24px 20px;
}
.dialog-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.dialog-label {
font-size: 14px;
font-weight: 500;
color: var(--gray-1000);
}
.field-input {
height: 36px;
padding: 0 8px;
border: 1px solid var(--gray-150);
border-radius: 6px;
font-size: 14px;
color: var(--gray-1000);
background: var(--gray-0);
transition: border-color 0.25s ease;
&:focus {
border-color: var(--main-color);
outline: none;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--gray-150);
}
.btn-cancel {
padding: 6px 16px;
border: 1px solid var(--gray-150);
border-radius: 6px;
background: var(--gray-0);
color: var(--gray-600);
font-size: 14px;
cursor: pointer;
&:hover {
border-color: var(--gray-300);
}
}
.btn-submit {
padding: 6px 20px;
border: none;
border-radius: 6px;
background: var(--main-color);
color: #fff;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.credential-info-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--gray-150);
.subsection-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--gray-900);
margin: 0 0 10px;
}
}
.credential-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.cred-source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.cred-source-db { background: var(--color-success-50); color: var(--color-success-700); }
&.cred-source-config { background: var(--gray-100); color: var(--gray-600); }
&.cred-source-env { background: var(--gray-100); color: var(--gray-600); }
&.cred-source-file { background: var(--gray-100); color: var(--gray-600); }
&.cred-source-memory { background: var(--color-warning-50); color: var(--color-warning-700); }
&.cred-source-none { background: var(--gray-100); color: var(--gray-500); }
}
.cred-status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.cred-ok { background: var(--color-success-50); color: var(--color-success-700); }
&.cred-soon { background: var(--color-warning-50); color: var(--color-warning-700); }
&.cred-expired { background: var(--color-error-50); color: var(--color-error-700); }
&.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>