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