refactor(channel): 合并渠道列表和详情页到ChannelManageView
1. 将原ChannelOverviewView和ChannelDetailView的功能整合到单个组件 2. 移除冗余的两个页面文件,统一路由指向ChannelManageView 3. 通过props和路由参数区分列表页和详情页状态 4. 保留并优化原有所有功能包括批量操作、渠道注册、启停重启等
This commit is contained in:
parent
1242999888
commit
29cf49ec92
@ -130,13 +130,13 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'ChannelManageComp',
|
name: 'ChannelManageComp',
|
||||||
component: () => import('../views/channels/ChannelOverviewView.vue'),
|
component: () => import('../views/ChannelManageView.vue'),
|
||||||
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
|
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':channelId',
|
path: ':channelId',
|
||||||
name: 'ChannelDetailComp',
|
name: 'ChannelDetailComp',
|
||||||
component: () => import('../views/channels/ChannelDetailView.vue'),
|
component: () => import('../views/ChannelManageView.vue'),
|
||||||
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true },
|
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true },
|
||||||
props: true
|
props: true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, RefreshCw, Play, Square, RotateCcw, Plug, CheckSquare, X,
|
ArrowLeft, RefreshCw, Play, Square, RotateCcw, Plug, CheckSquare, X,
|
||||||
Plus, MessageCircle, Settings
|
Plus, MessageCircle, Settings
|
||||||
@ -21,9 +22,15 @@ import ChannelAbilityPanel from '@/components/channels/ChannelAbilityPanel.vue'
|
|||||||
import ChannelMappingBrowser from '@/components/channels/ChannelMappingBrowser.vue'
|
import ChannelMappingBrowser from '@/components/channels/ChannelMappingBrowser.vue'
|
||||||
import ChannelTestDialog from '@/components/channels/ChannelTestDialog.vue'
|
import ChannelTestDialog from '@/components/channels/ChannelTestDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const channelStore = useChannelStore()
|
const channelStore = useChannelStore()
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
channelId: { type: String, default: null }
|
||||||
|
})
|
||||||
|
|
||||||
const viewMode = ref('overview')
|
const viewMode = ref('overview')
|
||||||
const detailChannelId = ref(null)
|
const detailChannelId = ref(null)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
@ -153,12 +160,7 @@ async function handleBatchAction(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleManage(channelId) {
|
function handleManage(channelId) {
|
||||||
detailChannelId.value = channelId
|
router.push({ name: 'ChannelDetailComp', params: { channelId } })
|
||||||
channelStore.selectedChannelId = channelId
|
|
||||||
viewMode.value = 'detail'
|
|
||||||
activeTab.value = 'info'
|
|
||||||
channelStore.stopPolling()
|
|
||||||
loadChannelDetail(channelId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle(channelId) {
|
async function handleToggle(channelId) {
|
||||||
@ -255,14 +257,9 @@ function handleTestChannel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
viewMode.value = 'overview'
|
|
||||||
detailChannelId.value = null
|
|
||||||
channelStore.selectedChannelId = null
|
|
||||||
editableConfig.value = {}
|
editableConfig.value = {}
|
||||||
editablePolicy.value = {}
|
editablePolicy.value = {}
|
||||||
if (channelStore.wsState !== 'connected') {
|
router.push({ name: 'ChannelManageComp' })
|
||||||
channelStore.startPolling(5000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChannelDetail(channelId) {
|
async function loadChannelDetail(channelId) {
|
||||||
@ -327,6 +324,108 @@ async function handlePolicySave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) ============
|
// ============ Card Helpers (overview) ============
|
||||||
const imgErrors = ref({})
|
const imgErrors = ref({})
|
||||||
|
|
||||||
@ -364,18 +463,19 @@ async function handleRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCardTest(channelId) {
|
function handleCardTest(channelId) {
|
||||||
detailChannelId.value = channelId
|
router.push({ name: 'ChannelDetailComp', params: { channelId }, query: { tab: 'test' } })
|
||||||
showTestDialog.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await channelStore.fetchAllStatus()
|
if (!props.channelId) {
|
||||||
|
await channelStore.fetchAllStatus()
|
||||||
|
|
||||||
const token = localStorage.getItem('token') || ''
|
const token = localStorage.getItem('token') || ''
|
||||||
channelStore.initWS(token).then(() => {
|
channelStore.initWS(token).then(() => {
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
channelStore.startPolling(5000)
|
channelStore.startPolling(5000)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -437,7 +537,7 @@ onUnmounted(() => {
|
|||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<PageShoulder v-model:search="searchQuery" search-placeholder="搜索渠道名称、ID 或类型...">
|
<PageShoulder v-model:search="searchQuery" search-placeholder="搜索渠道名称、ID 或类型...">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<a-button type="primary" class="lucide-icon-btn register-btn" @click="handleRefresh">
|
<a-button type="primary" class="lucide-icon-btn register-btn" @click="showRegisterDialog = true">
|
||||||
<Plus :size="14" />
|
<Plus :size="14" />
|
||||||
注册渠道
|
注册渠道
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -559,6 +659,8 @@ onUnmounted(() => {
|
|||||||
<h2>{{ channelDetail.display_name || channelDetail.channel_id }}</h2>
|
<h2>{{ channelDetail.display_name || channelDetail.channel_id }}</h2>
|
||||||
<span class="detail-id">{{ channelDetail.channel_id }}</span>
|
<span class="detail-id">{{ channelDetail.channel_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar-spacer"></div>
|
||||||
|
<button v-if="channelDetail" class="btn-delete" @click="handleDeleteChannel">注销渠道</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
@ -714,6 +816,40 @@ onUnmounted(() => {
|
|||||||
:channel-id="detailChannelId"
|
:channel-id="detailChannelId"
|
||||||
@tested="loadChannelDetail(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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -1029,7 +1165,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Detail View (unchanged) ============
|
// ============ Detail View ============
|
||||||
.detail-toolbar {
|
.detail-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1059,6 +1195,29 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.detail-title {
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -1185,4 +1344,131 @@ onUnmounted(() => {
|
|||||||
border-top: 1px solid var(--gray-150);
|
border-top: 1px solid var(--gray-150);
|
||||||
flex-wrap: wrap;
|
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>
|
</style>
|
||||||
@ -1,569 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import { ArrowLeft, Play, Square, RotateCcw, Plug } from 'lucide-vue-next'
|
|
||||||
import { useChannelStore } from '@/stores/channel'
|
|
||||||
import { useAgentStore } from '@/stores/agent'
|
|
||||||
import { useChannelAction } from '@/composables/useChannelAction'
|
|
||||||
import ChannelConfigForm from '@/components/channels/ChannelConfigForm.vue'
|
|
||||||
import AgentRoutingTable from '@/components/channels/AgentRoutingTable.vue'
|
|
||||||
import ChannelPolicyForm from '@/components/channels/ChannelPolicyForm.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 ChannelStatsPanel = defineAsyncComponent({
|
|
||||||
loader: () => import('@/components/channels/ChannelStatsPanel.vue'),
|
|
||||||
loadingComponent: { template: '<div class="detail-loading">统计图表加载中...</div>' },
|
|
||||||
errorComponent: { template: '<div class="detail-loading">统计图表加载失败,请切换 Tab 后重试</div>' },
|
|
||||||
delay: 200,
|
|
||||||
timeout: 10000
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const channelStore = useChannelStore()
|
|
||||||
const agentStore = useAgentStore()
|
|
||||||
const { loadingMap, execute } = useChannelAction()
|
|
||||||
|
|
||||||
const channelId = computed(() => route.params.channelId)
|
|
||||||
const detailLoading = ref(false)
|
|
||||||
const activeTab = ref('overview')
|
|
||||||
const showTestDialog = ref(false)
|
|
||||||
|
|
||||||
const editableConfig = ref({})
|
|
||||||
const editablePolicy = ref({})
|
|
||||||
const policySaving = ref(false)
|
|
||||||
|
|
||||||
const channelDetail = computed(() =>
|
|
||||||
channelId.value ? channelStore.channels[channelId.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' && channelId.value) {
|
|
||||||
channelStore.fetchRouting(channelId.value)
|
|
||||||
}
|
|
||||||
if (tab === 'policy' && channelId.value) {
|
|
||||||
channelStore.fetchChannelPolicy(channelId.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabGroups = {
|
|
||||||
core: [
|
|
||||||
{ key: 'overview', label: '总览' },
|
|
||||||
{ key: 'config', label: '连接配置' },
|
|
||||||
{ key: 'routing', label: 'Agent 路由' },
|
|
||||||
{ key: 'policy', label: '策略配置' }
|
|
||||||
],
|
|
||||||
monitor: [
|
|
||||||
{ key: 'stats', label: '统计图表' },
|
|
||||||
{ key: 'messages', label: '消息日志' },
|
|
||||||
{ key: 'mapping', label: '映射管理' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTabs = computed(() => [...tabGroups.core, ...tabGroups.monitor])
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
channelStore.stopPolling()
|
|
||||||
router.push({ name: 'ChannelManageComp' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadChannelDetail(id) {
|
|
||||||
detailLoading.value = true
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
channelStore.fetchChannelDetail(id),
|
|
||||||
channelStore.fetchChannelPolicy(id),
|
|
||||||
channelStore.fetchRouting(id)
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
detailLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStartChannel() {
|
|
||||||
const id = channelId.value
|
|
||||||
await execute(
|
|
||||||
() => channelStore.startChannel(id),
|
|
||||||
`start_${id}`,
|
|
||||||
{ successMsg: '渠道启动指令已发送', onSuccess: () => loadChannelDetail(id) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStopChannel() {
|
|
||||||
const id = channelId.value
|
|
||||||
await execute(
|
|
||||||
() => channelStore.stopChannel(id),
|
|
||||||
`stop_${id}`,
|
|
||||||
{ successMsg: '渠道已停止', onSuccess: () => loadChannelDetail(id) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRestartChannel() {
|
|
||||||
const id = channelId.value
|
|
||||||
await execute(
|
|
||||||
() => channelStore.restartChannel(id),
|
|
||||||
`restart_${id}`,
|
|
||||||
{ successMsg: '渠道重启指令已发送', onSuccess: () => loadChannelDetail(id) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTestChannel() {
|
|
||||||
showTestDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfigSave() {
|
|
||||||
try {
|
|
||||||
await channelStore.updateConfig(channelId.value, editableConfig.value)
|
|
||||||
message.success('配置已保存')
|
|
||||||
await loadChannelDetail(channelId.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(channelId.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(channelId.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(channelId.value, editablePolicy.value)
|
|
||||||
message.success('策略配置已保存')
|
|
||||||
await channelStore.fetchChannelPolicy(channelId.value)
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
policySaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteChannel() {
|
|
||||||
const id = channelId.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: 'ChannelList' })
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '注销渠道失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
channelStore.selectedChannelId = channelId.value
|
|
||||||
channelStore.stopPolling()
|
|
||||||
await loadChannelDetail(channelId.value)
|
|
||||||
|
|
||||||
if (route.query.tab === 'test') {
|
|
||||||
showTestDialog.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
channelStore.selectedChannelId = null
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="channel-detail-view">
|
|
||||||
<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 class="btn-delete" @click="handleDeleteChannel">注销渠道</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-content">
|
|
||||||
<div class="detail-tabs">
|
|
||||||
<template v-for="group in ['core', 'monitor']" :key="group">
|
|
||||||
<span v-if="group === 'monitor'" class="tab-separator">|</span>
|
|
||||||
<button
|
|
||||||
v-for="tab in tabGroups[group]"
|
|
||||||
:key="tab.key"
|
|
||||||
class="tab-btn"
|
|
||||||
:class="{ active: activeTab === tab.key }"
|
|
||||||
@click="activeTab = tab.key"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="detailLoading" class="detail-loading">加载中...</div>
|
|
||||||
|
|
||||||
<div v-else-if="channelDetail" class="detail-container">
|
|
||||||
<div v-show="activeTab === 'overview'" 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="!!loadingMap[`start_${channelId}`]"
|
|
||||||
@click="handleStartChannel"
|
|
||||||
>
|
|
||||||
<Play :size="14" />
|
|
||||||
启动
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
class="lucide-icon-btn"
|
|
||||||
:disabled="!!loadingMap[`stop_${channelId}`]"
|
|
||||||
@click="handleStopChannel"
|
|
||||||
>
|
|
||||||
<Square :size="14" />
|
|
||||||
停止
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
size="small"
|
|
||||||
class="lucide-icon-btn"
|
|
||||||
:disabled="!!loadingMap[`restart_${channelId}`]"
|
|
||||||
@click="handleRestartChannel"
|
|
||||||
>
|
|
||||||
<RotateCcw :size="14" />
|
|
||||||
重启
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
size="small"
|
|
||||||
class="lucide-icon-btn"
|
|
||||||
@click="handleTestChannel"
|
|
||||||
>
|
|
||||||
<Plug :size="14" />
|
|
||||||
测试连接
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ability-inline">
|
|
||||||
<ChannelAbilityPanel :channel-id="channelId" />
|
|
||||||
</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="channelId"
|
|
||||||
v-model="editableConfig"
|
|
||||||
:agent-list="agentList"
|
|
||||||
/>
|
|
||||||
</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="channelId" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-show="activeTab === 'messages'" class="section-card">
|
|
||||||
<h3 class="section-title">消息日志</h3>
|
|
||||||
<ChannelMessageLog :channel-id="channelId" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-show="activeTab === 'mapping'" class="section-card">
|
|
||||||
<h3 class="section-title">映射管理</h3>
|
|
||||||
<ChannelMappingBrowser :channel-id="channelId" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChannelTestDialog
|
|
||||||
v-model:visible="showTestDialog"
|
|
||||||
:channel-id="channelId"
|
|
||||||
@tested="loadChannelDetail(channelId)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.channel-detail-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
background: var(--gray-0);
|
|
||||||
color: var(--gray-1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
border-bottom: 1px solid var(--gray-150);
|
|
||||||
overflow-x: auto;
|
|
||||||
|
|
||||||
.tab-separator {
|
|
||||||
width: 1px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--gray-150);
|
|
||||||
margin: 0 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ability-inline {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--gray-150);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,639 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import {
|
|
||||||
RefreshCw, Play, Square, RotateCcw, Search, CheckSquare, X, Plus
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { useChannelStore } from '@/stores/channel'
|
|
||||||
import { useChannelAction } from '@/composables/useChannelAction'
|
|
||||||
import PageHeader from '@/components/shared/PageHeader.vue'
|
|
||||||
import AppEmptyState from '@/components/shared/AppEmptyState.vue'
|
|
||||||
import ChannelStatusCard from '@/components/channels/ChannelStatusCard.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const channelStore = useChannelStore()
|
|
||||||
const { loadingMap, execute } = useChannelAction()
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const skeletonCount = 6
|
|
||||||
|
|
||||||
const batchMode = ref(false)
|
|
||||||
const selectedIds = ref(new Set())
|
|
||||||
const batchActionLoading = ref(false)
|
|
||||||
|
|
||||||
const channelList = computed(() => Object.values(channelStore.channels))
|
|
||||||
|
|
||||||
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 allSelected = computed(() =>
|
|
||||||
filteredChannelList.value.length > 0 &&
|
|
||||||
filteredChannelList.value.every((ch) => selectedIds.value.has(ch.channel_id))
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedCount = computed(() => selectedIds.value.size)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enterBatchMode() {
|
|
||||||
batchMode.value = true
|
|
||||||
selectedIds.value = new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitBatchMode() {
|
|
||||||
batchMode.value = false
|
|
||||||
selectedIds.value = new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelect(channelId) {
|
|
||||||
const next = new Set(selectedIds.value)
|
|
||||||
if (next.has(channelId)) {
|
|
||||||
next.delete(channelId)
|
|
||||||
} else {
|
|
||||||
next.add(channelId)
|
|
||||||
}
|
|
||||||
selectedIds.value = next
|
|
||||||
}
|
|
||||||
|
|
||||||
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 goToDetail(channelId) {
|
|
||||||
router.push({ name: 'ChannelDetailComp', params: { channelId } })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggle(channelId) {
|
|
||||||
const channel = channelStore.channels[channelId]
|
|
||||||
if (channel?.enabled) {
|
|
||||||
await execute(
|
|
||||||
() => channelStore.stopChannel(channelId),
|
|
||||||
`toggle_${channelId}`,
|
|
||||||
{ successMsg: '渠道已停止' }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await execute(
|
|
||||||
() => channelStore.startChannel(channelId),
|
|
||||||
`toggle_${channelId}`,
|
|
||||||
{ successMsg: '渠道已启动' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCardRestart(channelId) {
|
|
||||||
await execute(
|
|
||||||
() => channelStore.restartChannel(channelId),
|
|
||||||
`restart_${channelId}`,
|
|
||||||
{ successMsg: '渠道重启指令已发送', warningMsg: '重启失败' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTest(channelId) {
|
|
||||||
channelStore.selectedChannelId = channelId
|
|
||||||
router.push({ name: 'ChannelDetailComp', params: { channelId }, query: { tab: 'test' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
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-overview-view">
|
|
||||||
<PageHeader
|
|
||||||
title="渠道管理"
|
|
||||||
description="管理和监控所有消息渠道的连接状态与配置"
|
|
||||||
:show-border="true"
|
|
||||||
>
|
|
||||||
<template #info>
|
|
||||||
<div class="summary-strip">
|
|
||||||
<span>{{ channelStats.total }} 个渠道</span>
|
|
||||||
<span>{{ channelStats.enabled }} 个启用</span>
|
|
||||||
<span>{{ channelStats.running }} 个运行中</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<a-button class="lucide-icon-btn" @click="channelStore.fetchAllStatus()">
|
|
||||||
<RefreshCw :size="14" />
|
|
||||||
刷新
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div class="channel-toolbar">
|
|
||||||
<a-input
|
|
||||||
v-model:value="searchQuery"
|
|
||||||
class="search-input"
|
|
||||||
placeholder="搜索渠道名称、ID 或类型..."
|
|
||||||
allow-clear
|
|
||||||
>
|
|
||||||
<template #prefix><Search :size="14" /></template>
|
|
||||||
</a-input>
|
|
||||||
<button v-if="!batchMode" class="batch-btn" @click="enterBatchMode">
|
|
||||||
<CheckSquare :size="14" />
|
|
||||||
批量操作
|
|
||||||
</button>
|
|
||||||
<button v-if="!batchMode" class="batch-btn register-btn" @click="showRegisterDialog = true">
|
|
||||||
<Plus :size="14" />
|
|
||||||
注册渠道
|
|
||||||
</button>
|
|
||||||
<template v-else>
|
|
||||||
<div class="batch-actions">
|
|
||||||
<label class="select-all-label">
|
|
||||||
<input type="checkbox" :checked="allSelected" @change="handleSelectAll" />
|
|
||||||
全选
|
|
||||||
</label>
|
|
||||||
<span class="batch-count">已选 {{ selectedCount }} 项</span>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="channel-content">
|
|
||||||
<div
|
|
||||||
v-if="channelStore.isLoading && !Object.keys(channelStore.channels).length"
|
|
||||||
class="channel-grid"
|
|
||||||
>
|
|
||||||
<div v-for="n in skeletonCount" :key="n" class="skeleton-card"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppEmptyState
|
|
||||||
v-else-if="channelStore.loadError && !Object.keys(channelStore.channels).length"
|
|
||||||
type="error"
|
|
||||||
:description="channelStore.loadError"
|
|
||||||
action-label="重试"
|
|
||||||
@action="channelStore.fetchAllStatus()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AppEmptyState
|
|
||||||
v-else-if="!channelList.length"
|
|
||||||
type="empty"
|
|
||||||
title="暂无已注册渠道"
|
|
||||||
description="系统尚未注册任何消息渠道,请联系管理员进行配置"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AppEmptyState
|
|
||||||
v-else-if="!filteredChannelList.length"
|
|
||||||
type="no-results"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="channel-grid">
|
|
||||||
<ChannelStatusCard
|
|
||||||
v-for="ch in filteredChannelList"
|
|
||||||
:key="ch.channel_id"
|
|
||||||
:channel="ch"
|
|
||||||
:toggling="!!loadingMap[`toggle_${ch.channel_id}`]"
|
|
||||||
:batch-mode="batchMode"
|
|
||||||
:selected="selectedIds.has(ch.channel_id)"
|
|
||||||
@manage="goToDetail"
|
|
||||||
@toggle="handleToggle"
|
|
||||||
@restart="handleCardRestart"
|
|
||||||
@test="handleTest"
|
|
||||||
@select="handleSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-overview-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
background: var(--gray-0);
|
|
||||||
color: var(--gray-1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-strip {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--gray-100);
|
|
||||||
border-radius: 7px;
|
|
||||||
background: var(--gray-10);
|
|
||||||
color: var(--gray-700);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px var(--page-padding) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 280px;
|
|
||||||
|
|
||||||
:deep(.ant-input-affix-wrapper) {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid var(--gray-150);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: var(--gray-0);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&.ant-input-affix-wrapper-focused {
|
|
||||||
border-color: var(--gray-200);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-input-prefix) {
|
|
||||||
margin-right: 8px;
|
|
||||||
color: var(--gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-input) {
|
|
||||||
height: 100%;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-all-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
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-25);
|
|
||||||
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); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-content {
|
|
||||||
padding: 16px var(--page-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 360px));
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton-shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-btn {
|
|
||||||
border-color: var(--main-color);
|
|
||||||
color: var(--main-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--main-color);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
Loading…
Reference in New Issue
Block a user