mateclaw/mateclaw-ui/src/views/Channels.vue

1398 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner channels-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Connect</div>
<h1 class="mc-page-title">{{ t('channels.title') }}</h1>
<p class="mc-page-desc">{{ t('channels.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('channels.newChannel') }}
</button>
</div>
<!-- 渠道卡片 -->
<div class="channel-grid">
<div v-for="channel in channels" :key="channel.id" class="channel-card mc-surface-card">
<div class="channel-header">
<div class="channel-icon-wrap">
<img class="channel-icon-img" :src="getChannelIconPath(channel.channelType)" :alt="channel.channelType" />
</div>
<div class="channel-meta">
<h3 class="channel-name">{{ channel.name }}</h3>
<span class="channel-type">{{ channel.channelType }}</span>
</div>
<div class="channel-status-group">
<div class="channel-status" :class="channel.enabled ? 'status-on' : 'status-off'">
{{ channel.enabled ? t('channels.status.active') : t('channels.status.inactive') }}
</div>
<div
v-if="channel.enabled"
class="connection-indicator"
:class="getConnectionClass(channel)"
:title="getConnectionTooltip(channel)"
>
{{ getConnectionIcon(channel) }} {{ getConnectionLabel(channel) }}
</div>
</div>
</div>
<p class="channel-desc">{{ channel.description }}</p>
<div class="channel-footer">
<button class="card-btn" @click="openEditModal(channel)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ t('channels.configure') }}
</button>
<button class="card-btn" @click="toggleChannel(channel)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line v-if="channel.enabled" x1="8" y1="12" x2="16" y2="12"/>
<polyline v-else points="10 8 16 12 10 16"/>
</svg>
{{ channel.enabled ? t('channels.disable') : t('channels.enable') }}
</button>
<button class="card-btn danger" @click="deleteChannel(channel.id)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
{{ t('common.delete') }}
</button>
</div>
</div>
<!-- 添加渠道卡片 -->
<div class="channel-card add-card mc-surface-card" @click="openCreateModal">
<div class="add-icon">+</div>
<p class="add-label">{{ t('channels.addChannel') }}</p>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2>{{ editingChannel ? t('channels.modal.editTitle') : t('channels.modal.newTitle') }}</h2>
<button class="modal-close" @click="closeModal">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<!-- 基础信息 -->
<div class="form-grid">
<div class="form-group">
<label class="form-label">{{ t('channels.fields.name') }} <span class="required">*</span></label>
<input v-model="form.name" class="form-input" :placeholder="t('channels.placeholders.name')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('channels.fields.type') }}</label>
<select v-model="form.channelType" class="form-input" @change="onChannelTypeChange">
<option value="web">{{ t('channels.types.web') }}</option>
<option value="dingtalk">{{ t('channels.types.dingtalk') }}</option>
<option value="feishu">{{ t('channels.types.feishu') }}</option>
<option value="telegram">{{ t('channels.types.telegram') }}</option>
<option value="discord">{{ t('channels.types.discord') }}</option>
<option value="wecom">{{ t('channels.types.wecom') }}</option>
<option value="weixin">{{ t('channels.types.weixin') }}</option>
<option value="qq">{{ t('channels.types.qq') }}</option>
<option value="slack">{{ t('channels.types.slack') }}</option>
<option value="webchat">{{ t('channels.types.webchat') }}</option>
<option value="webhook">{{ t('channels.types.webhook') }}</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('channels.fields.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('channels.placeholders.description')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('channels.fields.bindAgent') }}</label>
<select v-model="form.agentId" class="form-input">
<option value="">{{ t('channels.placeholders.selectAgent') }}</option>
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
{{ agent.icon || '🤖' }} {{ agent.name }}
</option>
</select>
</div>
</div>
<!-- 配置区域:标签页 -->
<div class="config-section">
<div class="tab-bar">
<button
class="tab-btn" :class="{ active: configTab === 'form' }"
@click="switchTab('form')"
>{{ t('channels.tabs.form') }}</button>
<button
class="tab-btn" :class="{ active: configTab === 'json' }"
@click="switchTab('json')"
>{{ t('channels.tabs.json') }}</button>
</div>
<!-- 表单配置 Tab -->
<div v-if="configTab === 'form'" class="tab-content">
<!-- 接入引导卡片 -->
<div v-if="webhookGuide" class="guide-card">
<div v-if="needsWebhookUrl" class="guide-webhook-row">
<span class="guide-label">Webhook URL</span>
<code class="guide-url">{{ webhookUrl }}</code>
<button type="button" class="copy-btn" @click="copyWebhookUrl" :title="t('common.copy')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{{ copyLabel }}
</button>
</div>
<div v-if="needsWebhookUrl && isLocalhost" class="guide-warn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
{{ t('channels.webhook.localhostWarn') }}
</div>
<ol class="guide-steps">
<li v-for="(step, i) in webhookGuide.steps" :key="i" v-html="step"></li>
</ol>
</div>
<!-- 微信 iLink Bot 扫码登录 -->
<div v-if="form.channelType === 'weixin'" class="weixin-auth-card">
<p class="weixin-auth-hint">
{{ t('channels.weixin.authHint') }}
</p>
<!-- 编辑模式:提示扫码会替换当前账号,引导添加新渠道 -->
<div v-if="editingChannel && channelConfig.bot_token" class="weixin-multi-account-hint">
<p class="hint-warning">⚠️ {{ t('channels.weixin.replaceWarning') }}</p>
<button type="button" class="weixin-add-account-btn" @click="addNewWeixinChannel">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
</svg>
{{ t('channels.weixin.addNewAccount') }}
</button>
</div>
<button type="button" class="weixin-auth-btn" @click="handleWeixinQrcode" :disabled="weixinQrcodeLoading">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/>
<line x1="21" y1="14" x2="21" y2="17"/><line x1="14" y1="21" x2="17" y2="21"/>
<line x1="21" y1="21" x2="21" y2="21"/>
</svg>
{{ editingChannel && channelConfig.bot_token
? t('channels.weixin.rescanButton')
: (weixinQrcodeLoading ? t('channels.weixin.qrcodeLoading') : t('channels.weixin.qrcodeButton'))
}}
</button>
<!-- 二维码展示区 -->
<div v-if="weixinQrcodeImg || weixinPollStatus === 'confirmed'" class="weixin-qrcode-area">
<img v-if="weixinQrcodeImg" :src="weixinQrcodeImg" alt="WeChat QR Code" class="weixin-qrcode-img" />
<p class="weixin-scan-hint" :class="weixinPollStatus">
<template v-if="weixinPollStatus === 'scanned'">{{ t('channels.weixin.scanned') }}</template>
<template v-else-if="weixinPollStatus === 'confirmed'">{{ t('channels.weixin.loginSuccess') }}</template>
<template v-else-if="weixinPollStatus === 'expired'">{{ t('channels.weixin.qrcodeExpired') }}</template>
<template v-else>{{ t('channels.weixin.scanHint') }}</template>
</p>
</div>
</div>
<!-- 企业微信扫码授权 -->
<div v-if="form.channelType === 'wecom'" class="wecom-auth-card">
<p class="wecom-auth-hint">
{{ t('channels.wecom.authHint') }}
</p>
<button type="button" class="wecom-auth-btn" @click="handleWecomAuth" :disabled="wecomAuthLoading">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/>
<line x1="21" y1="14" x2="21" y2="17"/><line x1="14" y1="21" x2="17" y2="21"/>
<line x1="21" y1="21" x2="21" y2="21"/>
</svg>
{{ wecomAuthLoading ? t('channels.wecom.authLoading') : t('channels.wecom.authButton') }}
</button>
</div>
<!-- 渠道专属配置字段 -->
<template v-if="currentFieldDefs.length > 0">
<div class="form-grid">
<div
v-for="field in currentFieldDefs" :key="field.key"
class="form-group"
:class="{ 'full-width': field.type === 'text' && (field.placeholder?.length || 0) > 30 }"
>
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
<span v-if="field.tooltip" class="tooltip-icon" :title="field.tooltip">?</span>
</label>
<!-- 密码字段:带显示/隐藏切换 -->
<div v-if="field.sensitive || field.type === 'password'" class="password-wrap">
<input
v-model="channelConfig[field.key]"
:type="visibleFields[field.key] ? 'text' : 'password'"
class="form-input"
:placeholder="field.placeholder"
:readonly="field.readOnly"
autocomplete="off"
/>
<button
v-if="!field.readOnly"
type="button" class="eye-btn"
@click="visibleFields[field.key] = !visibleFields[field.key]"
:title="visibleFields[field.key] ? t('common.hide') : t('common.show')"
>
<svg v-if="visibleFields[field.key]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
<button
v-else-if="channelConfig[field.key]"
type="button" class="copy-inline-btn"
@click="copyText(String(channelConfig[field.key]))"
:title="t('common.copy')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<!-- 下拉选择 -->
<select
v-else-if="field.type === 'select'"
v-model="channelConfig[field.key]"
class="form-input"
>
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- 开关 -->
<div v-else-if="field.type === 'switch'" class="switch-wrap">
<label class="switch">
<input type="checkbox" v-model="channelConfig[field.key]" />
<span class="switch-slider"></span>
</label>
<span class="switch-label">{{ channelConfig[field.key] ? t('common.on') : t('common.off') }}</span>
</div>
<!-- 数字 -->
<input
v-else-if="field.type === 'number'"
v-model.number="channelConfig[field.key]"
type="number"
class="form-input"
:placeholder="field.placeholder"
:readonly="field.readOnly"
/>
<!-- 普通文本 -->
<input
v-else
v-model="channelConfig[field.key]"
type="text"
class="form-input"
:placeholder="field.placeholder"
:readonly="field.readOnly"
/>
<span v-if="field.readOnly && field.key === 'api_key'" class="form-hint">
{{ editingChannel ? t('channels.webchatApiKeyReadOnly') : t('channels.webchatApiKeyGenerated') }}
</span>
</div>
</div>
</template>
<!-- 飞书所需权限提示(根据开关动态计算) -->
<div v-if="form.channelType === 'feishu'" class="permission-hints">
<div class="permission-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>{{ t('channels.feishuPermissions.title') }}</span>
<a
:href="feishuPermissionUrl"
target="_blank" rel="noopener"
class="permission-link"
>{{ t('channels.feishuPermissions.goToPermissions') }}</a>
</div>
<div class="permission-list">
<div v-for="perm in feishuRequiredPermissions" :key="perm.scope" class="permission-item">
<code class="permission-scope">{{ perm.scope }}</code>
<span class="permission-desc">{{ perm.desc }}</span>
<span class="permission-reason">{{ perm.reason }}</span>
</div>
</div>
</div>
<!-- Web 类型:无额外配置 -->
<div v-else-if="form.channelType === 'web'" class="empty-config">
<p class="empty-text">{{ t('channels.webHint') }}</p>
</div>
<!-- WebChat 类型 -->
<div v-else-if="form.channelType === 'webchat'" class="empty-config">
<p class="empty-text">{{ t('channels.webchatHint') }}</p>
</div>
<!-- Webhook 类型 -->
<div v-else-if="form.channelType === 'webhook'" class="empty-config">
<p class="empty-text">{{ t('channels.webhookHint') }}</p>
</div>
<!-- 高级配置(折叠) -->
<div class="advanced-section">
<button class="advanced-toggle" @click="showAdvanced = !showAdvanced">
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
:style="{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }"
>
<polyline points="9 18 15 12 9 6"/>
</svg>
{{ t('channels.advanced') }}
</button>
<div v-if="showAdvanced" class="advanced-body">
<!-- 访问控制 -->
<div class="form-group full-width section-divider">
<label class="section-label">{{ t('channels.accessControl.title') }}</label>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">
{{ t('channels.accessControl.dmPolicy') }}
<span class="tooltip-icon" :title="t('channels.accessControl.dmPolicyTooltip')">?</span>
</label>
<select v-model="accessControl.dm_policy" class="form-input">
<option value="open">{{ t('channels.accessControl.policyOpen') }}</option>
<option value="closed">{{ t('channels.accessControl.policyClosed') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">
{{ t('channels.accessControl.groupPolicy') }}
<span class="tooltip-icon" :title="t('channels.accessControl.groupPolicyTooltip')">?</span>
</label>
<select v-model="accessControl.group_policy" class="form-input">
<option value="open">{{ t('channels.accessControl.policyOpen') }}</option>
<option value="closed">{{ t('channels.accessControl.policyClosed') }}</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">
{{ t('channels.accessControl.allowFrom') }}
<span class="tooltip-icon" :title="t('channels.accessControl.allowFromTooltip')">?</span>
</label>
<input v-model="accessControl.allow_from" class="form-input" :placeholder="t('channels.accessControl.allowFromPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('channels.accessControl.denyMessage') }}</label>
<input v-model="accessControl.deny_message" class="form-input" :placeholder="t('channels.accessControl.denyMessagePlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">
{{ t('channels.accessControl.requireMention') }}
<span class="tooltip-icon" :title="t('channels.accessControl.requireMentionTooltip')">?</span>
</label>
<select v-model="accessControl.require_mention" class="form-input">
<option :value="true">{{ t('common.yes') }}</option>
<option :value="false">{{ t('common.no') }}</option>
</select>
</div>
</div>
<!-- 消息过滤 -->
<div class="form-group full-width section-divider">
<label class="section-label">{{ t('channels.messageFilter.title') }}</label>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">
{{ t('channels.messageFilter.filterThinking') }}
<span class="tooltip-icon" :title="t('channels.messageFilter.filterThinkingTooltip')">?</span>
</label>
<select v-model="renderConfig.filter_thinking" class="form-input">
<option :value="true">{{ t('common.yes') }}</option>
<option :value="false">{{ t('common.no') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">
{{ t('channels.messageFilter.filterToolMessages') }}
<span class="tooltip-icon" :title="t('channels.messageFilter.filterToolMessagesTooltip')">?</span>
</label>
<select v-model="renderConfig.filter_tool_messages" class="form-input">
<option :value="true">{{ t('common.yes') }}</option>
<option :value="false">{{ t('common.no') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ t('channels.messageFilter.messageFormat') }}</label>
<select v-model="renderConfig.message_format" class="form-input">
<option value="auto">{{ t('channels.messageFilter.formatAuto') }}</option>
<option value="markdown">{{ t('channels.messageFilter.formatMarkdown') }}</option>
<option value="text">{{ t('channels.messageFilter.formatText') }}</option>
<option value="html">{{ t('channels.messageFilter.formatHtml') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- 原始 JSON Tab -->
<div v-if="configTab === 'json'" class="tab-content">
<p class="json-hint">{{ t('channels.jsonHint') }}</p>
<textarea
v-model="rawConfigJson"
class="form-textarea json-editor"
rows="14"
placeholder='{"client_id": "...", "client_secret": "..."}'
spellcheck="false"
></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="saveChannel" :disabled="!form.name">{{ t('common.save') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { channelApi, agentApi } from '@/api/index'
import { CHANNEL_FIELD_DEFS } from '@/types/index'
import type { Channel, Agent, ChannelFieldDef } from '@/types/index'
const { t } = useI18n()
const channels = ref<Channel[]>([])
const agents = ref<Agent[]>([])
const showModal = ref(false)
const editingChannel = ref<Channel | null>(null)
/** 渠道连接状态映射 */
const channelStatusMap = ref<Record<string | number, {
connectionState: string
lastError: string | null
reconnectAttempts: number
}>>({})
let statusPollTimer: ReturnType<typeof setInterval> | null = null
// ==================== 表单状态 ====================
const defaultForm = () => ({
name: '', channelType: 'web' as string, description: '', configJson: '', agentId: null as string | number | null, enabled: true,
})
const form = ref(defaultForm())
/** 渠道专属配置(从 configJson 中提取的字段值) */
const channelConfig = ref<Record<string, any>>({})
/** 敏感字段可见性 */
const visibleFields = ref<Record<string, boolean>>({})
/** 配置标签页 */
const configTab = ref<'form' | 'json'>('form')
/** 原始 JSON 编辑内容 */
const rawConfigJson = ref('')
/** 高级配置折叠状态 */
const showAdvanced = ref(false)
const defaultAccessControl = () => ({
dm_policy: 'open' as string,
group_policy: 'open' as string,
allow_from: '',
deny_message: '',
require_mention: false as boolean,
})
const accessControl = ref(defaultAccessControl())
const defaultRenderConfig = () => ({
filter_thinking: true as boolean,
filter_tool_messages: true as boolean,
message_format: 'auto' as string,
})
const renderConfig = ref(defaultRenderConfig())
/** 当前渠道类型的字段定义(过滤条件显示字段) */
const currentFieldDefs = computed<ChannelFieldDef[]>(() => {
const allFields = CHANNEL_FIELD_DEFS[form.value.channelType] || []
return allFields.filter(field => {
if (!field.showIf) return true
return channelConfig.value[field.showIf.field] === field.showIf.value
})
})
// ==================== Webhook 引导 ====================
const WEBHOOK_TYPES = new Set(['dingtalk', 'feishu', 'telegram', 'discord', 'wecom', 'weixin', 'qq'])
interface WebhookGuideInfo {
steps: string[]
}
const WEBHOOK_GUIDES = computed<Record<string, WebhookGuideInfo>>(() => ({
dingtalk: {
steps: [
t('channels.guide.dingtalk.step1'),
t('channels.guide.dingtalk.step2'),
t('channels.guide.dingtalk.step3'),
],
},
feishu: {
steps: [
t('channels.guide.feishu.step1'),
t('channels.guide.feishu.step2'),
t('channels.guide.feishu.step3'),
t('channels.guide.feishu.step4'),
t('channels.guide.feishu.step5'),
],
},
telegram: {
steps: [
t('channels.guide.telegram.step1'),
t('channels.guide.telegram.step2'),
t('channels.guide.telegram.step3'),
t('channels.guide.telegram.step4'),
],
},
discord: {
steps: [
t('channels.guide.discord.step1'),
t('channels.guide.discord.step2'),
t('channels.guide.discord.step3'),
t('channels.guide.discord.step4'),
t('channels.guide.discord.step5'),
],
},
wecom: {
steps: [
t('channels.guide.wecom.step1'),
t('channels.guide.wecom.step2'),
t('channels.guide.wecom.step3'),
t('channels.guide.wecom.step4'),
],
},
weixin: {
steps: [
t('channels.guide.weixin.step1'),
t('channels.guide.weixin.step2'),
t('channels.guide.weixin.step3'),
t('channels.guide.weixin.step4'),
],
},
qq: {
steps: [
t('channels.guide.qq.step1'),
t('channels.guide.qq.step2'),
t('channels.guide.qq.step3'),
t('channels.guide.qq.step4'),
],
},
}))
/** 当前渠道是否有接入引导 */
const webhookGuide = computed<WebhookGuideInfo | null>(() => {
return WEBHOOK_GUIDES.value[form.value.channelType] || null
})
/** 当前渠道是否需要 Webhook URLWebSocket / Stream / Long-Polling 模式不需要) */
const needsWebhookUrl = computed(() => {
const type = form.value.channelType
// 企业微信、QQ、微信使用 WebSocket 长连接,不需要 Webhook URL
if (type === 'wecom' || type === 'qq' || type === 'weixin') return false
// Discord 使用 Gateway WebSocket不需要 Webhook URL
if (type === 'discord') return false
// 钉钉 Stream 模式不需要 Webhook URL
if (type === 'dingtalk' && channelConfig.value.connection_mode !== 'webhook') return false
// 飞书 WebSocket 模式不需要 Webhook URL
if (type === 'feishu' && channelConfig.value.connection_mode === 'websocket') return false
// Telegram Long-Polling 模式不需要 Webhook URL
if (type === 'telegram' && channelConfig.value.connection_mode !== 'webhook') return false
return true
})
// ==================== 企业微信扫码授权 ====================
const WECOM_SDK_URL = 'https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js'
const WECOM_SOURCE = 'mateclaw'
/** SDK 是否已加载 */
let wecomSdkLoaded = false
/** 扫码授权加载中 */
const wecomAuthLoading = ref(false)
/** 动态加载企业微信 JS SDK */
function loadWecomSDK(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).WecomAIBotSDK || wecomSdkLoaded) {
resolve()
return
}
const script = document.createElement('script')
script.src = WECOM_SDK_URL
script.async = true
script.onload = () => {
wecomSdkLoaded = true
resolve()
}
script.onerror = () => reject(new Error('WeCom SDK 加载失败'))
document.body.appendChild(script)
})
}
/** 处理企业微信扫码授权 */
async function handleWecomAuth() {
wecomAuthLoading.value = true
try {
await loadWecomSDK()
} catch {
ElMessage.error(t('channels.wecom.sdkFailed'))
wecomAuthLoading.value = false
return
}
const sdk = (window as any).WecomAIBotSDK
if (!sdk) {
ElMessage.error(t('channels.wecom.sdkFailed'))
wecomAuthLoading.value = false
return
}
wecomAuthLoading.value = false
const result = sdk.openBotInfoAuthWindow({
source: WECOM_SOURCE,
})
if (result && typeof result.then === 'function') {
result.then(
(bot: { botid: string; secret: string }) => {
if (bot?.botid) {
channelConfig.value.bot_id = bot.botid
channelConfig.value.secret = bot.secret
ElMessage.success(t('channels.wecom.authSuccess'))
}
},
(error: { code: string; message: string }) => {
if (error?.code === 'WINDOW_BLOCKED') {
ElMessage.error(t('channels.wecom.windowBlocked'))
} else if (error?.code === 'CANCELLED') {
ElMessage.info(t('channels.wecom.authCancelled'))
} else {
ElMessage.error(t('channels.wecom.authFailed') + '' + (error?.message || error?.code || ''))
}
},
)
}
}
// ==================== 微信 iLink Bot 扫码登录 ====================
/** 二维码图片base64 或 URL */
const weixinQrcodeImg = ref('')
/** 二维码加载中 */
const weixinQrcodeLoading = ref(false)
/** 轮询状态polling / scanned / confirmed / expired */
const weixinPollStatus = ref<string>('')
/** 轮询定时器 */
let weixinPollTimer: ReturnType<typeof setInterval> | null = null
/** 防止重复确认 */
let weixinConfirmed = false
/** 停止轮询 */
function stopWeixinPoll() {
if (weixinPollTimer) {
clearInterval(weixinPollTimer)
weixinPollTimer = null
}
}
/** 处理获取微信登录二维码 */
async function handleWeixinQrcode() {
weixinQrcodeLoading.value = true
weixinQrcodeImg.value = ''
weixinPollStatus.value = ''
weixinConfirmed = false
stopWeixinPoll()
try {
const data: any = await channelApi.weixinQrcode()
const imgContent = data?.qrcode_img_content || data?.qrcode_img || ''
const qrcodeId = data?.qrcode || ''
if (!imgContent && !qrcodeId) {
ElMessage.error(t('channels.weixin.qrcodeFailed'))
weixinQrcodeLoading.value = false
return
}
// 处理图片:如果是 URL 直接使用,否则包装为 data URI
if (imgContent) {
weixinQrcodeImg.value = imgContent.startsWith('http')
? imgContent
: `data:image/png;base64,${imgContent}`
}
weixinQrcodeLoading.value = false
weixinPollStatus.value = 'polling'
// 开始轮询扫码状态(每 2 秒)
if (qrcodeId) {
weixinPollTimer = setInterval(async () => {
try {
const s: any = await channelApi.weixinQrcodeStatus(qrcodeId)
const status = s?.status || ''
if (status === 'scanned') {
weixinPollStatus.value = 'scanned'
}
if (status === 'confirmed' && s?.bot_token) {
if (weixinConfirmed) return
weixinConfirmed = true
stopWeixinPoll()
// 自动填入 bot_token
channelConfig.value.bot_token = s.bot_token
// 如果返回了 base_url也更新
if (s.base_url) {
channelConfig.value.base_url = s.base_url
}
// 新建模式且名称为默认值时,自动追加 token 后缀便于区分多账号
if (!editingChannel.value) {
const suffix = s.bot_token.slice(-6)
const currentName = form.value.name || ''
if (!currentName || currentName === t('channels.weixin.newAccountName') || currentName === t('channels.types.weixin')) {
form.value.name = t('channels.weixin.newAccountName') + ' (' + suffix + ')'
}
}
weixinQrcodeImg.value = ''
weixinPollStatus.value = 'confirmed'
ElMessage.success(t('channels.weixin.loginSuccess'))
}
if (status === 'expired') {
stopWeixinPoll()
weixinQrcodeImg.value = ''
weixinPollStatus.value = 'expired'
ElMessage.warning(t('channels.weixin.qrcodeExpired'))
}
} catch {
// 轮询错误静默忽略
}
}, 2000)
}
} catch (e: any) {
ElMessage.error(t('channels.weixin.qrcodeFailed'))
weixinQrcodeLoading.value = false
}
}
/** 是否在本地开发环境 */
const isLocalhost = computed(() => {
const host = window.location.hostname
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'
})
/** 生成 Webhook URL */
const webhookUrl = computed(() => {
const proto = window.location.protocol
const host = window.location.hostname
// 生产环境跟随当前页面端口,开发环境提示用户替换
const port = window.location.port
const portSuffix = port ? `:${port}` : ''
return `${proto}//${host}${portSuffix}/api/v1/channels/webhook/${form.value.channelType}`
})
/** 飞书权限管理页面 URL需要 app_id */
const feishuPermissionUrl = computed(() => {
const appId = channelConfig.value?.app_id || ''
const domain = channelConfig.value?.domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn'
if (appId) {
return `https://${domain}/app/${appId}/permission`
}
return `https://${domain}/`
})
/** 根据飞书功能开关动态计算所需权限 */
const feishuRequiredPermissions = computed(() => {
const perms: { scope: string; desc: string; reason: string }[] = []
perms.push(
{ scope: 'im:message', desc: t('channels.feishu.perm.message'), reason: t('channels.feishu.perm.messageReason') },
{ scope: 'im:message.receive_v1', desc: t('channels.feishu.perm.receive'), reason: t('channels.feishu.perm.receiveReason') },
)
if (channelConfig.value?.connection_mode === 'websocket') {
perms.push(
{ scope: 'im:resource', desc: t('channels.feishu.perm.resource'), reason: t('channels.feishu.perm.resourceReason') },
)
}
if (channelConfig.value?.enable_reaction !== false) {
perms.push(
{ scope: 'im:message.reactions', desc: t('channels.feishu.perm.reactions'), reason: t('channels.feishu.perm.reactionsReason') },
)
}
if (channelConfig.value?.enable_nickname_cache !== false) {
perms.push(
{ scope: 'contact:user.base:readonly', desc: t('channels.feishu.perm.contact'), reason: t('channels.feishu.perm.contactReason') },
)
}
if (channelConfig.value?.media_download_enabled) {
perms.push(
{ scope: 'im:message.resource', desc: t('channels.feishu.perm.media'), reason: t('channels.feishu.perm.mediaReason') },
)
}
return perms
})
const copyLabel = ref(t('channels.webhook.copy'))
async function copyWebhookUrl() {
try {
await navigator.clipboard.writeText(webhookUrl.value)
copyLabel.value = t('channels.webhook.copied')
setTimeout(() => { copyLabel.value = t('channels.webhook.copy') }, 2000)
} catch {
ElMessage.warning(t('channels.webhook.copyFailed'))
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success(t('common.copied'))
} catch {
ElMessage.warning(t('channels.webhook.copyFailed'))
}
}
// ==================== 生命周期 ====================
onMounted(async () => {
await loadChannels()
await loadStatus()
const res: any = await agentApi.list()
agents.value = res.data || []
statusPollTimer = setInterval(loadStatus, 10000)
})
onUnmounted(() => {
if (statusPollTimer) {
clearInterval(statusPollTimer)
statusPollTimer = null
}
})
onBeforeUnmount(() => {
stopWeixinPoll()
})
// ==================== 数据加载 ====================
async function loadChannels() {
try {
const res: any = await channelApi.list()
channels.value = (res.data || []).map((c: any) => ({
...c,
}))
} catch (e: any) {
ElMessage.error(t('channels.messages.loadFailed') + ': ' + (e?.message || ''))
channels.value = []
}
}
async function loadStatus() {
try {
const res: any = await channelApi.status()
const statusData = res.data
if (statusData && Array.isArray(statusData.channels)) {
const map: Record<number, any> = {}
for (const ch of statusData.channels) {
map[ch.id] = {
connectionState: ch.connectionState || 'DISCONNECTED',
lastError: ch.lastError || null,
reconnectAttempts: ch.reconnectAttempts || 0,
}
}
channelStatusMap.value = map
}
} catch {
// 静默处理
}
}
// ==================== 连接状态 ====================
function getConnectionState(channel: Channel): string {
return channelStatusMap.value[channel.id]?.connectionState || 'DISCONNECTED'
}
function getConnectionIcon(channel: Channel): string {
const state = getConnectionState(channel)
switch (state) {
case 'CONNECTED': return '🟢'
case 'RECONNECTING': return '🟡'
case 'ERROR': return '🔴'
default: return '⚪'
}
}
function getConnectionLabel(channel: Channel): string {
const state = getConnectionState(channel)
switch (state) {
case 'CONNECTED': return t('channels.connection.connected')
case 'RECONNECTING': return t('channels.connection.reconnecting')
case 'ERROR': return t('channels.connection.error')
case 'DISCONNECTED': return t('channels.connection.disconnected')
default: return state
}
}
function getConnectionClass(channel: Channel): string {
const state = getConnectionState(channel)
switch (state) {
case 'CONNECTED': return 'conn-connected'
case 'RECONNECTING': return 'conn-reconnecting'
case 'ERROR': return 'conn-error'
default: return 'conn-disconnected'
}
}
function getConnectionTooltip(channel: Channel): string {
const status = channelStatusMap.value[channel.id]
if (!status) return ''
let tip = getConnectionLabel(channel)
if (status.reconnectAttempts > 0) {
tip += ` (${t('channels.connection.retryCount', { n: status.reconnectAttempts })})`
}
if (status.lastError) {
tip += `\n${t('channels.connection.errorLabel')}: ${status.lastError}`
}
return tip
}
// ==================== 弹窗操作 ====================
function openCreateModal() {
editingChannel.value = null
form.value = defaultForm()
channelConfig.value = {}
visibleFields.value = {}
accessControl.value = defaultAccessControl()
renderConfig.value = defaultRenderConfig()
rawConfigJson.value = ''
configTab.value = 'form'
showAdvanced.value = false
initDefaultFieldValues()
showModal.value = true
}
function openEditModal(channel: Channel) {
editingChannel.value = channel
form.value = { ...channel } as any
configTab.value = 'form'
showAdvanced.value = false
visibleFields.value = {}
// 从 configJson 反序列化
const cfg = parseConfigJson(channel.configJson)
channelConfig.value = extractChannelFields(cfg, channel.channelType)
accessControl.value = extractAccessControl(cfg)
renderConfig.value = extractRenderConfig(cfg)
rawConfigJson.value = channel.configJson || ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingChannel.value = null
}
/** 绑定新微信账号:关闭当前编辑弹窗,以新建模式打开微信渠道 */
function addNewWeixinChannel() {
closeModal()
// 计算已有微信渠道数量,用于自动递增命名
const existingCount = channels.value.filter(c => c.channelType === 'weixin').length
const newName = t('channels.weixin.newAccountName') + ' ' + (existingCount + 1)
// 延迟打开新建弹窗,确保关闭动画完成
setTimeout(() => {
openCreateModal()
form.value.channelType = 'weixin'
form.value.name = newName
onChannelTypeChange()
}, 200)
}
/** 渠道类型变更时重置配置字段 */
function onChannelTypeChange() {
channelConfig.value = {}
visibleFields.value = {}
// 清理微信扫码状态
stopWeixinPoll()
weixinQrcodeImg.value = ''
weixinPollStatus.value = ''
initDefaultFieldValues()
}
/** 用字段定义中的 defaultValue 初始化 */
function initDefaultFieldValues() {
const fields = CHANNEL_FIELD_DEFS[form.value.channelType] || []
for (const f of fields) {
if (f.defaultValue !== undefined && channelConfig.value[f.key] === undefined) {
channelConfig.value[f.key] = f.defaultValue
}
}
}
/** 切换标签页时同步数据 */
function switchTab(tab: 'form' | 'json') {
if (tab === 'json' && configTab.value === 'form') {
// 表单 -> JSON序列化表单值到 rawConfigJson
rawConfigJson.value = buildConfigJson()
} else if (tab === 'form' && configTab.value === 'json') {
// JSON -> 表单:解析 rawConfigJson 回填到表单
const cfg = parseConfigJson(rawConfigJson.value)
channelConfig.value = extractChannelFields(cfg, form.value.channelType)
accessControl.value = extractAccessControl(cfg)
renderConfig.value = extractRenderConfig(cfg)
}
configTab.value = tab
}
// ==================== 保存 ====================
async function saveChannel() {
try {
const payload = { ...form.value }
if (configTab.value === 'json') {
// 当前在 JSON 标签页,校验 JSON 格式后使用
if (rawConfigJson.value.trim()) {
try {
JSON.parse(rawConfigJson.value)
} catch {
ElMessage.error(t('channels.messages.invalidJson'))
return
}
}
payload.configJson = rawConfigJson.value
} else {
// 表单模式:将所有字段合并为 configJson
payload.configJson = buildConfigJson()
}
if (editingChannel.value) {
await channelApi.update(editingChannel.value.id, payload)
} else {
await channelApi.create(payload)
}
closeModal()
await loadChannels()
} catch (e: any) { ElMessage.error(e?.message || t('channels.messages.saveFailed')) }
}
/** 将表单字段合并为完整的 configJson 字符串 */
function buildConfigJson(): string {
const cfg: Record<string, any> = {}
// 渠道专属字段
const fields = CHANNEL_FIELD_DEFS[form.value.channelType] || []
for (const f of fields) {
const val = channelConfig.value[f.key]
if (val !== undefined && val !== '' && val !== null) {
cfg[f.key] = val
}
}
// 访问控制
cfg.dm_policy = accessControl.value.dm_policy
cfg.group_policy = accessControl.value.group_policy
cfg.allow_from = accessControl.value.allow_from
? accessControl.value.allow_from.split(/[,]/).map((s: string) => s.trim()).filter(Boolean)
: []
cfg.deny_message = accessControl.value.deny_message
cfg.require_mention = accessControl.value.require_mention
// 消息过滤
cfg.filter_thinking = renderConfig.value.filter_thinking
cfg.filter_tool_messages = renderConfig.value.filter_tool_messages
cfg.message_format = renderConfig.value.message_format
return JSON.stringify(cfg, null, 2)
}
// ==================== 配置解析工具 ====================
function parseConfigJson(json?: string): Record<string, any> {
if (!json) return {}
try { return JSON.parse(json) } catch { return {} }
}
/** 从 config 对象提取渠道专属字段 */
function extractChannelFields(cfg: Record<string, any>, channelType: string): Record<string, any> {
const fields = CHANNEL_FIELD_DEFS[channelType] || []
const result: Record<string, any> = {}
// 兼容旧 Telegram 配置:如果有 webhook_url 但没有 connection_mode推断为 webhook
if (channelType === 'telegram' && cfg.webhook_url && !cfg.connection_mode) {
cfg = { ...cfg, connection_mode: 'webhook' }
}
for (const f of fields) {
if (cfg[f.key] !== undefined) {
result[f.key] = cfg[f.key]
} else if (f.defaultValue !== undefined) {
result[f.key] = f.defaultValue
}
}
return result
}
function extractAccessControl(cfg: Record<string, any>) {
const defaults = defaultAccessControl()
return {
dm_policy: cfg.dm_policy || defaults.dm_policy,
group_policy: cfg.group_policy || defaults.group_policy,
allow_from: Array.isArray(cfg.allow_from) ? cfg.allow_from.join(', ') : (cfg.allow_from || ''),
deny_message: cfg.deny_message || defaults.deny_message,
require_mention: cfg.require_mention === true,
}
}
function extractRenderConfig(cfg: Record<string, any>) {
const defaults = defaultRenderConfig()
return {
filter_thinking: cfg.filter_thinking !== false,
filter_tool_messages: cfg.filter_tool_messages !== false,
message_format: cfg.message_format || defaults.message_format,
}
}
// ==================== CRUD ====================
async function deleteChannel(id: string | number) {
try { await ElMessageBox.confirm(t('channels.messages.deleteConfirm'), t('channels.messages.deleteTitle'), { type: 'warning' }) } catch { return }
try {
await channelApi.delete(id)
await loadChannels()
} catch (e: any) { ElMessage.error(e?.message || t('channels.messages.deleteFailed')) }
}
async function toggleChannel(channel: Channel) {
try {
await channelApi.toggle(channel.id, !channel.enabled)
await loadChannels()
await loadStatus()
} catch (e: any) { ElMessage.error(e?.message || t('channels.messages.toggleFailed')) }
}
// ==================== 渠道图标 ====================
const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'slack', 'webchat', 'webhook']
function getChannelIconPath(type: string) {
const name = CHANNEL_ICON_TYPES.includes(type) ? type : 'default'
return `/icons/channels/${name}.svg`
}
</script>
<style scoped>
.channels-page { gap: 18px; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
/* 渠道卡片 */
.channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.channel-card { padding: 20px; transition: all 0.15s; min-height: 238px; display: flex; flex-direction: column; }
.channel-card:hover { border-color: var(--mc-primary-light); box-shadow: var(--mc-shadow-medium); transform: translateY(-2px); }
.channel-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.channel-icon-wrap { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; background: linear-gradient(135deg, rgba(217,109,87,0.12), rgba(24,74,69,0.08)); }
.channel-icon-img { width: 42px; height: 42px; border-radius: 12px; object-fit: cover; }
.channel-meta { flex: 1; }
.channel-name { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 2px; }
.channel-type { font-size: 12px; color: var(--mc-text-tertiary); }
.channel-status { padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.channel-status-group { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.status-on { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-off { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.connection-indicator { font-size: 11px; padding: 2px 8px; border-radius: 12px; white-space: nowrap; cursor: default; }
.conn-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
.conn-reconnecting { color: var(--mc-primary-hover); background: var(--mc-primary-bg); animation: pulse-reconnecting 1.5s ease-in-out infinite; }
.conn-error { color: var(--mc-danger); background: var(--mc-danger-bg); }
.conn-disconnected { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
@keyframes pulse-reconnecting { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.channel-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 14px; line-height: 1.6; min-height: 42px; }
.channel-footer { display: flex; gap: 6px; border-top: 1px solid var(--mc-border-light); padding-top: 12px; margin-top: auto; flex-wrap: wrap; }
.card-btn { display: flex; align-items: center; gap: 4px; padding: 7px 11px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 10px; font-size: 12px; color: var(--mc-text-primary); cursor: pointer; transition: all 0.15s; font-weight: 600; }
.card-btn:hover { background: var(--mc-bg-sunken); }
.card-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
.add-card { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 238px; border: 2px dashed var(--mc-border); cursor: pointer; background: transparent; }
.add-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.add-icon { font-size: 28px; color: var(--mc-text-tertiary); margin-bottom: 8px; }
.add-label { font-size: 14px; color: var(--mc-text-tertiary); }
.add-card:hover .add-icon, .add-card:hover .add-label { color: var(--mc-primary); }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal { background: var(--mc-bg-elevated); border-radius: 16px; width: 100%; max-width: 620px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 18px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
.modal-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); display: flex; align-items: center; gap: 4px; }
.required { color: var(--mc-danger, #ef4444); font-size: 13px; }
.form-input, .form-textarea { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-elevated); width: 100%; box-sizing: border-box; }
.form-input:focus, .form-textarea:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-textarea { resize: vertical; font-family: monospace; }
/* Tooltip */
.tooltip-icon { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border-radius: 50%; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); font-size: 10px; font-weight: 700; cursor: help; flex-shrink: 0; }
/* 配置区域 */
.config-section { margin-top: 20px; }
/* 标签页 */
.tab-bar { display: flex; gap: 0; border-bottom: 1px solid var(--mc-border-light); margin-bottom: 16px; }
.tab-btn { padding: 8px 16px; background: none; border: none; border-bottom: 2px solid transparent; font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
.tab-btn:hover { color: var(--mc-text-primary); }
.tab-btn.active { color: var(--mc-primary); border-bottom-color: var(--mc-primary); }
.tab-content { min-height: 60px; }
/* Webhook 引导卡片 */
.guide-card { background: var(--mc-primary-bg, rgba(217,119,87,0.06)); border: 1px solid var(--mc-primary-light, rgba(217,119,87,0.2)); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.guide-webhook-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.guide-label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0; }
.guide-url { font-size: 12px; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 5px; padding: 3px 8px; color: var(--mc-text-primary); word-break: break-all; flex: 1; min-width: 0; }
.copy-btn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 5px; font-size: 11px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; white-space: nowrap; flex-shrink: 0; }
.copy-btn:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.guide-warn { display: flex; align-items: flex-start; gap: 6px; padding: 8px 10px; background: rgba(234, 179, 8, 0.08); border: 1px solid rgba(234, 179, 8, 0.2); border-radius: 6px; font-size: 12px; color: var(--mc-text-secondary); margin-bottom: 8px; line-height: 1.5; }
.guide-warn svg { flex-shrink: 0; color: #eab308; margin-top: 1px; }
.guide-warn a { color: var(--mc-primary); text-decoration: none; }
.guide-warn a:hover { text-decoration: underline; }
.guide-steps { margin: 0; padding-left: 20px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.7; }
.guide-steps li { margin-bottom: 2px; }
.guide-steps :deep(a) { color: var(--mc-primary); text-decoration: none; }
.guide-steps :deep(a:hover) { text-decoration: underline; }
.guide-steps :deep(code) { font-size: 12px; background: var(--mc-bg-sunken); padding: 1px 5px; border-radius: 3px; }
/* 企业微信扫码授权卡片 */
.wecom-auth-card { background: var(--mc-primary-bg, rgba(217,119,87,0.06)); border: 1px solid var(--mc-primary-light, rgba(217,119,87,0.2)); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.wecom-auth-hint { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; }
.wecom-auth-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 10px 16px; background: var(--mc-primary, #D97757); color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.wecom-auth-btn:hover:not(:disabled) { background: var(--mc-primary-hover, #C1572B); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(217,119,87,0.3); }
.wecom-auth-btn:active:not(:disabled) { transform: translateY(0); }
.wecom-auth-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 微信 iLink Bot 扫码登录 */
.weixin-auth-card { background: var(--mc-primary-bg, rgba(217,119,87,0.06)); border: 1px solid var(--mc-primary-light, rgba(217,119,87,0.2)); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.weixin-auth-hint { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; }
.weixin-auth-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 10px 16px; background: #07C160; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.weixin-auth-btn:hover:not(:disabled) { background: #06AD56; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(7,193,96,0.3); }
.weixin-auth-btn:active:not(:disabled) { transform: translateY(0); }
.weixin-auth-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.weixin-multi-account-hint { margin-bottom: 12px; padding: 12px; background: #FFF7E6; border: 1px solid #FFD666; border-radius: 8px; }
.weixin-multi-account-hint .hint-warning { font-size: 13px; color: #D48806; margin: 0 0 10px 0; line-height: 1.5; }
.weixin-add-account-btn { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; padding: 8px 14px; background: var(--mc-primary); color: #fff; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.weixin-add-account-btn:hover { opacity: 0.9; transform: translateY(-1px); }
.weixin-qrcode-area { display: flex; flex-direction: column; align-items: center; margin-top: 16px; padding: 16px; background: #fff; border-radius: 8px; border: 1px solid var(--mc-border); }
.weixin-qrcode-img { width: 200px; height: 200px; border-radius: 4px; }
.weixin-scan-hint { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; }
.weixin-scan-hint.scanned { color: #E6A23C; }
.weixin-scan-hint.confirmed { color: #07C160; font-weight: 500; }
.weixin-scan-hint.expired { color: #F56C6C; }
.guide-steps :deep(b) { color: var(--mc-text-primary); font-weight: 600; }
/* 飞书权限提示 */
.permission-hints { background: var(--mc-primary-bg, rgba(217,119,87,0.06)); border: 1px solid var(--mc-primary-light, rgba(217,119,87,0.15)); border-radius: 10px; padding: 12px 16px; margin-top: 12px; margin-bottom: 8px; }
.permission-header { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 10px; }
.permission-header svg { color: var(--mc-primary); flex-shrink: 0; }
.permission-link { margin-left: auto; font-size: 12px; font-weight: 500; color: var(--mc-primary); text-decoration: none; white-space: nowrap; }
.permission-link:hover { text-decoration: underline; }
.permission-list { display: flex; flex-direction: column; gap: 6px; }
.permission-item { display: flex; align-items: baseline; gap: 8px; font-size: 12px; line-height: 1.5; }
.permission-scope { font-size: 11px; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 4px; padding: 1px 6px; color: var(--mc-primary); white-space: nowrap; flex-shrink: 0; }
.permission-desc { color: var(--mc-text-primary); flex-shrink: 0; }
.permission-reason { color: var(--mc-text-tertiary, var(--mc-text-secondary)); font-size: 11px; opacity: 0.7; }
/* 密码字段 */
.password-wrap { position: relative; display: flex; align-items: center; }
.password-wrap .form-input { padding-right: 36px; }
.eye-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; }
.eye-btn:hover { color: var(--mc-text-primary); }
.copy-inline-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; }
.copy-inline-btn:hover { color: var(--mc-text-primary); }
.form-hint { font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
/* 开关 */
.switch-wrap { display: flex; align-items: center; gap: 8px; height: 36px; }
.switch { position: relative; display: inline-block; width: 36px; height: 20px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-slider { position: absolute; cursor: pointer; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
.switch-slider::before { content: ""; position: absolute; height: 14px; width: 14px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s; }
.switch input:checked + .switch-slider { background: var(--mc-primary); }
.switch input:checked + .switch-slider::before { transform: translateX(16px); }
.switch-label { font-size: 13px; color: var(--mc-text-secondary); }
/* 空配置 */
.empty-config { padding: 24px 16px; text-align: center; }
.empty-text { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; }
/* 高级配置 */
.advanced-section { margin-top: 20px; border-top: 1px solid var(--mc-border-light); padding-top: 12px; }
.advanced-toggle { display: flex; align-items: center; gap: 6px; background: none; border: none; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); padding: 4px 0; }
.advanced-toggle:hover { color: var(--mc-text-primary); }
.advanced-body { margin-top: 12px; }
.section-divider { margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--mc-border-light); }
.section-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
/* JSON 编辑器 */
.json-hint { font-size: 12px; color: var(--mc-text-tertiary); margin: 0 0 8px; }
.json-editor { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 13px; line-height: 1.5; tab-size: 2; }
</style>