ForcePilot/web/src/views/ChannelManageView.vue

1474 lines
38 KiB
Vue
Raw Normal View History

<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
} from 'lucide-vue-next'
import { useChannelStore } from '@/stores/channel'
import { useAgentStore } from '@/stores/agent'
import { channelIcons } from '@/constants/channelIcons'
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'
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 policySaving = 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: '映射管理' }
]
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
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)
])
} finally {
detailLoading.value = false
}
}
async function handleConfigSave() {
try {
await channelStore.updateConfig(detailChannelId.value, editableConfig.value)
message.success('配置已保存')
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 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() {
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_count ?? '-' },
{ 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' } })
}
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>
<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 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>
<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>
</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;
}
}
</style>