活跃连接
diff --git a/web/src/composables/useCredentialStatus.js b/web/src/composables/useCredentialStatus.js
new file mode 100644
index 00000000..4a9a2f68
--- /dev/null
+++ b/web/src/composables/useCredentialStatus.js
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/web/src/constants/channelIcons.js b/web/src/constants/channelIcons.js
index 3da29be6..e811444e 100644
--- a/web/src/constants/channelIcons.js
+++ b/web/src/constants/channelIcons.js
@@ -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`
}
\ No newline at end of file
diff --git a/web/src/constants/channelTypes.js b/web/src/constants/channelTypes.js
new file mode 100644
index 00000000..c501dc21
--- /dev/null
+++ b/web/src/constants/channelTypes.js
@@ -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: '元宝'
+}
\ No newline at end of file
diff --git a/web/src/stores/channel.js b/web/src/stores/channel.js
index b0d264e5..92c9ffb7 100644
--- a/web/src/stores/channel.js
+++ b/web/src/stores/channel.js
@@ -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 {
diff --git a/web/src/views/ChannelManageView.vue b/web/src/views/ChannelManageView.vue
index fc3ca026..52a1ba4e 100644
--- a/web/src/views/ChannelManageView.vue
+++ b/web/src/views/ChannelManageView.vue
@@ -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(() => {
批量重启
+
+ 批量注销
+
@@ -763,7 +843,7 @@ onUnmounted(() => {
凭证来源
- {{ credentialSourceLabel(channelDetail.credential_status.source) }}
+ {{ credentialSourceLabel }}
@@ -844,6 +924,18 @@ onUnmounted(() => {
保存配置