16e24cfc95
Replace the placeholder QQBot logo with the official documentation favicon and enlarge channel card badges for clearer platform recognition.
1386 lines
48 KiB
Vue
1386 lines
48 KiB
Vue
<template>
|
|
<div class="im-panel">
|
|
<div class="channels-section">
|
|
<div class="channels-header">
|
|
<span class="channels-title">{{ $t('agentEditor.im.channelsTitle') }}</span>
|
|
<IntegrationsAgentFilter v-model="filterAgentId" :agents="agents" />
|
|
<span class="channels-count">{{ channels.length }}</span>
|
|
</div>
|
|
|
|
<t-loading :loading="loading" size="small" class="channels-loading-wrap">
|
|
<div v-if="!loading && channels.length === 0 && !authStore.hasRole('admin')" class="channels-empty">
|
|
<t-empty :description="$t('agentEditor.im.empty')" />
|
|
</div>
|
|
|
|
<div v-else-if="!loading" class="channel-grid">
|
|
<button v-for="channel in channels" :key="channel.id" type="button"
|
|
class="channel-card channel-card--clickable" @click="openDrawer(channel)">
|
|
<div class="channel-card__badge" :class="`channel-card__badge--${channel.platform}`">
|
|
<img v-if="platformLogo(channel.platform)" :src="platformLogo(channel.platform)"
|
|
:alt="platformLabel(channel.platform)" class="channel-card__logo" />
|
|
<t-icon v-else name="chat-message" size="22px" />
|
|
</div>
|
|
<div class="channel-card__body">
|
|
<div class="channel-card__header">
|
|
<h3 class="channel-card__title">{{ channel.name || $t('agentEditor.im.unnamed') }}</h3>
|
|
<t-tag v-if="!channel.enabled" size="small" variant="light" theme="warning">
|
|
{{ $t('agentEditor.im.disabled') }}
|
|
</t-tag>
|
|
</div>
|
|
<span v-if="agentDisplayName(channel)" class="channel-card__agent-name">
|
|
{{ agentDisplayName(channel) }}
|
|
</span>
|
|
</div>
|
|
<div v-if="authStore.hasRole('admin')" class="channel-card__actions" @click.stop>
|
|
<t-dropdown trigger="click" placement="bottom-right" attach="body" :options="channelMenuOptions(channel)"
|
|
@click="(data) => handleChannelMenuClick(data, channel)">
|
|
<t-button variant="text" shape="square" size="small" class="channel-card__action-btn channel-card__more"
|
|
@click.stop>
|
|
<template #icon><t-icon name="ellipsis" /></template>
|
|
</t-button>
|
|
</t-dropdown>
|
|
<t-popconfirm :content="$t('agentEditor.im.deleteConfirm')"
|
|
:confirm-btn="{ content: $t('common.delete'), theme: 'danger' }"
|
|
:cancel-btn="{ content: $t('common.cancel') }" placement="bottom-right"
|
|
@confirm="() => handleDelete(channel.id)">
|
|
<t-tooltip :content="$t('common.delete')" placement="top">
|
|
<t-button theme="danger" shape="square" variant="text" size="small"
|
|
class="channel-card__action-btn channel-card__delete" @click.stop>
|
|
<template #icon><t-icon name="delete" /></template>
|
|
</t-button>
|
|
</t-tooltip>
|
|
</t-popconfirm>
|
|
</div>
|
|
</button>
|
|
|
|
<button v-if="authStore.hasRole('admin')" type="button" class="channel-card channel-card--add"
|
|
@click="openCreate">
|
|
<span class="channel-card__badge" aria-hidden="true">
|
|
<t-icon name="add" />
|
|
</span>
|
|
<div class="channel-card__body">
|
|
<div class="channel-card__header">
|
|
<span class="channel-card__title">{{ $t('agentEditor.im.addChannel') }}</span>
|
|
</div>
|
|
</div>
|
|
<span class="channel-card__actions channel-card__actions--spacer" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</t-loading>
|
|
</div>
|
|
|
|
<SettingDrawer v-model:visible="showCreateDialog" class="im-channel-drawer" :title="drawerTitle"
|
|
:description="drawerStepDescription" storage-key="setting-drawer:im-channel" width="560px"
|
|
:confirm-loading="saving" :confirm-text="drawerConfirmText" :hide-footer="!authStore.hasRole('admin')"
|
|
@confirm="handleDrawerConfirm" @cancel="resetForm">
|
|
<template #headerIcon>
|
|
<img v-if="platformLogo(formData.platform)" :src="platformLogo(formData.platform)"
|
|
:alt="platformLabel(formData.platform)" class="drawer-platform-icon" />
|
|
<t-icon v-else name="chat-message" />
|
|
</template>
|
|
|
|
<template v-if="wizardStep > 0" #footer-left>
|
|
<t-button variant="outline" @click="prevWizardStep">
|
|
{{ $t('datasource.back') }}
|
|
</t-button>
|
|
</template>
|
|
|
|
<div class="im-steps">
|
|
<div v-for="(title, i) in stepTitles" :key="i"
|
|
:class="['im-step', { active: wizardStep === i, done: wizardStep > i }]">
|
|
<span class="im-step-num">
|
|
<t-icon v-if="wizardStep > i" name="check" class="im-step-check" />
|
|
<template v-else>{{ i + 1 }}</template>
|
|
</span>
|
|
<span class="im-step-title">{{ title }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 1: Basic -->
|
|
<div v-if="wizardStep === 0" class="im-step-body">
|
|
<section class="setting-drawer__section im-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionChannel') }}</h4>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('integrations.boundAgent') }}</label>
|
|
<div class="agent-field-row">
|
|
<t-select v-model="formData.target_agent_id" :options="agentOptions" filterable
|
|
:placeholder="$t('integrations.selectAgentPlaceholder')" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('agentEditor.im.platform') }}</label>
|
|
<t-select v-model="formData.platform" :disabled="!!editingChannel" class="im-platform-select"
|
|
@change="onPlatformChange">
|
|
<template v-if="platformLogo(formData.platform)" #prefixIcon>
|
|
<img :src="platformLogo(formData.platform)" :alt="platformLabel(formData.platform)"
|
|
class="im-platform-select-prefix" />
|
|
</template>
|
|
<t-option v-for="item in platformOptions" :key="item.value" :value="item.value" :label="item.label">
|
|
<div class="im-platform-select-option">
|
|
<img :src="item.logo" :alt="item.label" class="im-platform-select-option__icon" />
|
|
<span>{{ item.label }}</span>
|
|
</div>
|
|
</t-option>
|
|
</t-select>
|
|
</div>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label">{{ $t('agentEditor.im.channelName') }}</label>
|
|
<t-input v-model="formData.name" :placeholder="$t('agentEditor.im.channelNamePlaceholder')"
|
|
@focus="channelNameTouched = true" />
|
|
<p v-if="!editingChannel" class="form-desc">{{ $t('agentEditor.im.channelNameDefaultHint') }}</p>
|
|
</div>
|
|
|
|
<div v-if="editingChannel && authStore.hasRole('admin')" class="setting-row setting-row--last">
|
|
<div class="setting-info">
|
|
<label>{{ $t('agentEditor.im.enabled') }}</label>
|
|
</div>
|
|
<div class="setting-control">
|
|
<t-switch v-model="editingEnabled" size="small" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Step 2: Connection -->
|
|
<div v-else-if="wizardStep === 1" class="im-step-body">
|
|
<section v-if="formData.platform !== 'wechat'" class="setting-drawer__section im-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionAccess') }}</h4>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('agentEditor.im.mode') }}</label>
|
|
<div class="option-chips">
|
|
<button type="button" class="option-chip"
|
|
:class="{ 'option-chip--active': formData.mode === 'websocket' }"
|
|
:disabled="formData.platform === 'mattermost'" @click="formData.mode = 'websocket'">
|
|
WebSocket
|
|
</button>
|
|
<button type="button" class="option-chip" :class="{ 'option-chip--active': formData.mode === 'webhook' }"
|
|
@click="formData.mode = 'webhook'">
|
|
Webhook
|
|
</button>
|
|
</div>
|
|
<p class="form-desc">
|
|
{{ formData.platform === 'mattermost' ? $t('agentEditor.im.mattermostModeHint') :
|
|
$t('agentEditor.im.modeHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('agentEditor.im.outputMode') }}</label>
|
|
<div class="option-chips">
|
|
<button type="button" class="option-chip"
|
|
:class="{ 'option-chip--active': formData.output_mode === 'stream' }"
|
|
@click="formData.output_mode = 'stream'">
|
|
{{ $t('agentEditor.im.outputStream') }}
|
|
</button>
|
|
<button type="button" class="option-chip"
|
|
:class="{ 'option-chip--active': formData.output_mode === 'full' }"
|
|
@click="formData.output_mode = 'full'">
|
|
{{ $t('agentEditor.im.outputFull') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="setting-drawer__section im-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionSession') }}</h4>
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('agentEditor.im.sessionMode') }}</label>
|
|
<div class="option-chips">
|
|
<button type="button" class="option-chip"
|
|
:class="{ 'option-chip--active': formData.session_mode === 'user' }"
|
|
@click="formData.session_mode = 'user'">
|
|
{{ $t('agentEditor.im.sessionModeUser') }}
|
|
</button>
|
|
<button type="button" class="option-chip"
|
|
:class="{ 'option-chip--active': formData.session_mode === 'thread' }"
|
|
:disabled="!platformSupportsThread(formData.platform)" @click="formData.session_mode = 'thread'">
|
|
{{ $t('agentEditor.im.sessionModeThread') }}
|
|
</button>
|
|
</div>
|
|
<p class="form-desc">{{ $t('agentEditor.im.sessionModeHint') }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="editingChannel && formData.mode === 'webhook'"
|
|
class="setting-drawer__section im-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionCallback') }}</h4>
|
|
<div class="form-item">
|
|
<label class="form-label">{{ $t('agentEditor.im.callbackUrl') }}</label>
|
|
<div class="callback-url-control">
|
|
<t-input :model-value="getCallbackUrl(editingChannel)" readonly
|
|
class="mono-text-input callback-url-input" />
|
|
<t-button size="small" variant="text" :title="$t('common.copy')" @click="copyUrl(editingChannel)">
|
|
<t-icon name="file-copy" />
|
|
</t-button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Step 3: File knowledge base -->
|
|
<div v-else-if="wizardStep === 2" class="im-step-body">
|
|
<section class="setting-drawer__section im-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionKnowledge') }}</h4>
|
|
<div class="form-item">
|
|
<label class="form-label">{{ $t('agentEditor.im.fileKnowledgeBase') }}</label>
|
|
<t-select v-model="formData.knowledge_base_id"
|
|
:placeholder="$t('agentEditor.im.fileKnowledgeBasePlaceholder')" clearable filterable>
|
|
<t-option v-for="kb in knowledgeBases" :key="kb.id" :value="kb.id" :label="kb.name" />
|
|
</t-select>
|
|
<p class="form-desc">{{ $t('agentEditor.im.fileKnowledgeBaseHint') }}</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Step 4: Credentials -->
|
|
<div v-else class="im-step-body">
|
|
<section class="setting-drawer__section im-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionCredentials') }}</h4>
|
|
<div class="drawer-form">
|
|
<!-- WeCom credentials -->
|
|
<template v-if="formData.platform === 'wecom'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://work.weixin.qq.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.wecomConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<template v-if="formData.mode === 'websocket'">
|
|
<div class="form-item">
|
|
<label class="form-label">Bot ID</label>
|
|
<t-input v-model="formData.credentials.bot_id" placeholder="Bot ID" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Bot Secret</label>
|
|
<t-input v-model="formData.credentials.bot_secret" type="password" placeholder="Bot Secret" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">WebSocket Endpoint</label>
|
|
<t-input v-model="formData.credentials.ws_endpoint" placeholder="wss://openws.work.weixin.qq.com" />
|
|
<p class="form-desc">{{ $t('agentEditor.im.wecomWSEndpointHint') }}</p>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="form-item">
|
|
<label class="form-label">Corp ID</label>
|
|
<t-input v-model="formData.credentials.corp_id" placeholder="Corp ID" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Agent Secret</label>
|
|
<t-input v-model="formData.credentials.agent_secret" type="password" placeholder="Agent Secret" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Token</label>
|
|
<t-input v-model="formData.credentials.token" placeholder="Token" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">EncodingAESKey</label>
|
|
<t-input v-model="formData.credentials.encoding_aes_key" placeholder="EncodingAESKey" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Corp Agent ID</label>
|
|
<t-input-number v-model="formData.credentials.corp_agent_id" placeholder="Corp Agent ID"
|
|
style="width: 100%;" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">API Base URL</label>
|
|
<t-input v-model="formData.credentials.api_base_url" placeholder="https://qyapi.weixin.qq.com" />
|
|
<p class="form-desc">{{ $t('agentEditor.im.wecomAPIBaseURLHint') }}</p>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Feishu credentials -->
|
|
<template v-if="formData.platform === 'feishu'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://open.feishu.cn/" target="_blank" rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.feishuConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">App ID</label>
|
|
<t-input v-model="formData.credentials.app_id" placeholder="App ID" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">App Secret</label>
|
|
<t-input v-model="formData.credentials.app_secret" type="password" placeholder="App Secret" />
|
|
</div>
|
|
<template v-if="formData.mode === 'webhook'">
|
|
<div class="form-item">
|
|
<label class="form-label">Verification Token</label>
|
|
<t-input v-model="formData.credentials.verification_token" placeholder="Verification Token" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Encrypt Key</label>
|
|
<t-input v-model="formData.credentials.encrypt_key" type="password" placeholder="Encrypt Key" />
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Slack credentials -->
|
|
<template v-if="formData.platform === 'slack'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.slackConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<template v-if="formData.mode === 'websocket'">
|
|
<div class="form-item">
|
|
<label class="form-label">App Token</label>
|
|
<t-input v-model="formData.credentials.app_token" type="password" placeholder="xapp-..." />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Bot Token</label>
|
|
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="xoxb-..." />
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="form-item">
|
|
<label class="form-label">Bot Token</label>
|
|
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="xoxb-..." />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Signing Secret</label>
|
|
<t-input v-model="formData.credentials.signing_secret" type="password" placeholder="Signing Secret" />
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Telegram credentials -->
|
|
<template v-if="formData.platform === 'telegram'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.telegramConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Bot Token</label>
|
|
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="123456789:AABBccdd..." />
|
|
</div>
|
|
<template v-if="formData.mode === 'webhook'">
|
|
<div class="form-item">
|
|
<label class="form-label">Secret Token</label>
|
|
<t-input v-model="formData.credentials.secret_token" type="password"
|
|
placeholder="Secret Token (optional)" />
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- DingTalk credentials -->
|
|
<template v-if="formData.platform === 'dingtalk'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://open.dingtalk.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.dingtalkConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Client ID (AppKey)</label>
|
|
<t-input v-model="formData.credentials.client_id" placeholder="Client ID / AppKey" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Client Secret (AppSecret)</label>
|
|
<t-input v-model="formData.credentials.client_secret" type="password"
|
|
placeholder="Client Secret / AppSecret" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">{{ $t('agentEditor.im.dingtalkCardTemplateId') }}</label>
|
|
<t-input v-model="formData.credentials.card_template_id"
|
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.schema" />
|
|
<p class="form-desc">{{ $t('agentEditor.im.dingtalkCardTemplateIdHint') }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- QQBot credentials -->
|
|
<template v-if="formData.platform === 'qqbot'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://q.qq.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.qqbotConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">App ID</label>
|
|
<t-input v-model="formData.credentials.app_id" placeholder="QQBot App ID" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">App Secret</label>
|
|
<t-input v-model="formData.credentials.client_secret" type="password" placeholder="QQBot App Secret" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">API Base URL</label>
|
|
<t-input v-model="formData.credentials.api_base_url" placeholder="https://api.sgroup.qq.com" />
|
|
<p class="form-desc">{{ $t('agentEditor.im.qqbotAPIBaseURLHint') }}</p>
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Gateway URL</label>
|
|
<t-input v-model="formData.credentials.gateway_url" placeholder="wss://api.sgroup.qq.com/websocket/" />
|
|
<p class="form-desc">{{ $t('agentEditor.im.qqbotGatewayURLHint') }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Mattermost credentials -->
|
|
<template v-if="formData.platform === 'mattermost'">
|
|
<div class="platform-link-hint">
|
|
<a href="https://developers.mattermost.com/integrate/webhooks/outgoing/" target="_blank"
|
|
rel="noopener noreferrer" class="doc-link">
|
|
{{ $t('agentEditor.im.mattermostConsole') }}
|
|
<t-icon name="link" class="link-icon" />
|
|
</a>
|
|
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Site URL</label>
|
|
<t-input v-model="formData.credentials.site_url" placeholder="https://mattermost.example.com" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Bot Token</label>
|
|
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="Bot Token" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Outgoing Webhook Token</label>
|
|
<t-input v-model="formData.credentials.outgoing_token" type="password"
|
|
placeholder="Token from Outgoing Webhook" />
|
|
</div>
|
|
<div class="form-item">
|
|
<label class="form-label">Bot User ID</label>
|
|
<t-input v-model="formData.credentials.bot_user_id" placeholder="Optional — filter bot self-messages" />
|
|
</div>
|
|
<div class="settings-group">
|
|
<div class="setting-row setting-row--last">
|
|
<div class="setting-info">
|
|
<label>{{ $t('agentEditor.im.mattermostPostToMain') }}</label>
|
|
<p class="desc">{{ $t('agentEditor.im.mattermostPostToMainHint') }}</p>
|
|
</div>
|
|
<div class="setting-control">
|
|
<t-switch :value="!!formData.credentials.post_to_main" size="small"
|
|
@change="(v: boolean) => { formData.credentials.post_to_main = v }" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- WeChat credentials (QR code binding) -->
|
|
<template v-if="formData.platform === 'wechat'">
|
|
<p class="form-desc">{{ $t('agentEditor.im.wechatHint') }}</p>
|
|
|
|
<!-- Already bound state -->
|
|
<div v-if="wechatBound" class="wechat-bound-status">
|
|
<t-icon name="check-circle-filled" class="bound-icon" />
|
|
<span>{{ $t('agentEditor.im.wechatBindSuccess') }}</span>
|
|
<t-button size="small" variant="outline" theme="default" @click="startWeChatBinding">
|
|
{{ $t('agentEditor.im.wechatRebind') }}
|
|
</t-button>
|
|
</div>
|
|
|
|
<!-- QR code binding flow -->
|
|
<div v-else class="wechat-qr-section">
|
|
<!-- Initial state: show bind button -->
|
|
<div v-if="!wechatQRImgUrl" class="wechat-bind-action">
|
|
<t-button theme="default" variant="outline" :loading="wechatLoading" @click="startWeChatBinding">
|
|
<template #icon><t-icon name="scan" /></template>
|
|
{{ $t('agentEditor.im.wechatScanBind') }}
|
|
</t-button>
|
|
</div>
|
|
|
|
<!-- QR code displayed -->
|
|
<div v-else class="wechat-qr-display">
|
|
<div class="qr-container">
|
|
<img :src="wechatQRImgUrl" alt="WeChat QR Code" class="qr-image" />
|
|
<div v-if="wechatQRStatus === 'expired'" class="qr-expired-overlay" @click="startWeChatBinding">
|
|
<t-icon name="refresh" class="refresh-icon" />
|
|
<span>{{ $t('agentEditor.im.wechatQRExpired') }}</span>
|
|
</div>
|
|
</div>
|
|
<p class="qr-hint">
|
|
<template v-if="wechatQRStatus === 'scaned'">
|
|
{{ $t('agentEditor.im.wechatBinding') }}
|
|
</template>
|
|
<template v-else>
|
|
{{ $t('agentEditor.im.wechatScanning') }}
|
|
</template>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</SettingDrawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { MessagePlugin } from 'tdesign-vue-next';
|
|
import {
|
|
listIMChannels, createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel,
|
|
getWeChatQRCode, pollWeChatQRCodeStatus, listAllIMChannels, listAgents,
|
|
type IMChannelOverview, type CustomAgent,
|
|
} from '@/api/agent';
|
|
import { useChatResourcesStore } from '@/stores/chatResources';
|
|
import type { IMChannel } from '@/api/agent';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import SettingDrawer from '@/components/settings/SettingDrawer.vue';
|
|
import IntegrationsAgentFilter from '@/components/IntegrationsAgentFilter.vue';
|
|
import wecomLogo from '@/assets/img/im/wecom.svg';
|
|
import feishuLogo from '@/assets/img/im/feishu.svg';
|
|
import slackLogo from '@/assets/img/im/slack.svg';
|
|
import telegramLogo from '@/assets/img/im/telegram.svg';
|
|
import dingtalkLogo from '@/assets/img/im/dingtalk.svg';
|
|
import mattermostLogo from '@/assets/img/im/mattermost.svg';
|
|
import wechatLogo from '@/assets/img/im/wechat.svg';
|
|
import qqbotLogo from '@/assets/img/im/qqbot.png';
|
|
|
|
type IMPlatform = IMChannel['platform'];
|
|
|
|
const PLATFORM_LOGO: Record<string, string> = {
|
|
wecom: wecomLogo,
|
|
feishu: feishuLogo,
|
|
slack: slackLogo,
|
|
telegram: telegramLogo,
|
|
dingtalk: dingtalkLogo,
|
|
mattermost: mattermostLogo,
|
|
wechat: wechatLogo,
|
|
qqbot: qqbotLogo,
|
|
};
|
|
|
|
const platformLogo = (platform: string): string => (platform ? PLATFORM_LOGO[platform] || '' : '');
|
|
|
|
const { t } = useI18n();
|
|
const authStore = useAuthStore();
|
|
|
|
const filterAgentId = defineModel<string>('filterAgentId', { default: '' });
|
|
|
|
const agents = ref<CustomAgent[]>([]);
|
|
const agentOptions = computed(() =>
|
|
agents.value.map((agent) => ({ label: agent.name, value: agent.id })),
|
|
);
|
|
|
|
const allChannels = ref<Array<IMChannel | IMChannelOverview>>([]);
|
|
const channels = computed(() => {
|
|
const filter = filterAgentId.value?.trim();
|
|
if (!filter) return allChannels.value;
|
|
return allChannels.value.filter((channel) => channel.agent_id === filter);
|
|
});
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const showCreateDialog = ref(false);
|
|
const editingChannel = ref<IMChannel | null>(null);
|
|
const editingEnabled = ref(true);
|
|
const wizardStep = ref(0);
|
|
const channelNameTouched = ref(false);
|
|
|
|
const stepTitles = computed(() => [
|
|
t('agentEditor.im.stepBasic'),
|
|
t('agentEditor.im.stepConnection'),
|
|
t('agentEditor.im.stepKnowledge'),
|
|
t('agentEditor.im.stepCredentials'),
|
|
]);
|
|
|
|
const drawerStepDescription = computed(() => stepTitles.value[wizardStep.value] ?? '');
|
|
|
|
const drawerConfirmText = computed(() =>
|
|
wizardStep.value < stepTitles.value.length - 1 ? t('common.next') : t('common.save'),
|
|
);
|
|
|
|
const platformOptions = computed(() => ([
|
|
{ value: 'wecom' as IMPlatform, label: t('agentEditor.im.wecom'), logo: wecomLogo },
|
|
{ value: 'feishu' as IMPlatform, label: t('agentEditor.im.feishu'), logo: feishuLogo },
|
|
{ value: 'slack' as IMPlatform, label: t('agentEditor.im.slack'), logo: slackLogo },
|
|
{ value: 'telegram' as IMPlatform, label: t('agentEditor.im.telegram'), logo: telegramLogo },
|
|
{ value: 'dingtalk' as IMPlatform, label: t('agentEditor.im.dingtalk'), logo: dingtalkLogo },
|
|
{ value: 'mattermost' as IMPlatform, label: t('agentEditor.im.mattermost'), logo: mattermostLogo },
|
|
{ value: 'wechat' as IMPlatform, label: t('agentEditor.im.wechat'), logo: wechatLogo },
|
|
{ value: 'qqbot' as IMPlatform, label: t('agentEditor.im.qqbot'), logo: qqbotLogo },
|
|
]));
|
|
|
|
const drawerTitle = computed(() => {
|
|
if (editingChannel.value) {
|
|
return formData.value.name?.trim() || t('agentEditor.im.unnamed');
|
|
}
|
|
return t('agentEditor.im.addChannel');
|
|
});
|
|
|
|
function validateWizardStep(step: number): boolean {
|
|
if (step === 0 && !formData.value.target_agent_id) {
|
|
MessagePlugin.warning(t('integrations.selectAgentHint'));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function prevWizardStep() {
|
|
if (wizardStep.value > 0) wizardStep.value -= 1;
|
|
}
|
|
|
|
async function handleDrawerConfirm() {
|
|
if (wizardStep.value < stepTitles.value.length - 1) {
|
|
if (!validateWizardStep(wizardStep.value)) return;
|
|
wizardStep.value += 1;
|
|
return;
|
|
}
|
|
await handleSave();
|
|
}
|
|
|
|
// Knowledge base options for file-to-KB feature
|
|
const knowledgeBases = ref<{ id: string; name: string }[]>([]);
|
|
|
|
// WeChat QR code binding state
|
|
const wechatQRContent = ref(''); // raw text to encode as QR code
|
|
const wechatQRImgUrl = ref(''); // generated QR image URL
|
|
const wechatQRCode = ref(''); // opaque token for polling status
|
|
const wechatQRStatus = ref<string>('');
|
|
const wechatLoading = ref(false);
|
|
let wechatPollActive = false;
|
|
let wechatPollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const defaultCredentials = (): Record<string, any> => ({});
|
|
|
|
const formData = ref({
|
|
target_agent_id: '',
|
|
platform: 'wecom' as IMPlatform,
|
|
name: '',
|
|
mode: 'websocket' as 'webhook' | 'websocket' | 'longpoll',
|
|
output_mode: 'stream' as 'stream' | 'full',
|
|
session_mode: 'user' as 'user' | 'thread',
|
|
knowledge_base_id: '',
|
|
credentials: defaultCredentials(),
|
|
});
|
|
|
|
const channelMenuOptions = (channel: IMChannel | IMChannelOverview) => ([
|
|
{
|
|
content: channel.enabled ? t('common.off') : t('common.on'),
|
|
value: 'toggle',
|
|
},
|
|
]);
|
|
|
|
function handleChannelMenuClick(
|
|
data: { value?: string },
|
|
channel: IMChannel | IMChannelOverview,
|
|
) {
|
|
if (data.value === 'toggle') {
|
|
void handleToggle(channel);
|
|
}
|
|
}
|
|
|
|
function agentDisplayName(channel: IMChannel | IMChannelOverview): string {
|
|
return agentForChannel(channel)?.name || '';
|
|
}
|
|
|
|
function agentForChannel(channel: IMChannel | IMChannelOverview): CustomAgent | undefined {
|
|
const found = agents.value.find((agent) => agent.id === channel.agent_id);
|
|
if (found) return found;
|
|
const overviewName = (channel as IMChannelOverview).agent_name;
|
|
if (!overviewName) return undefined;
|
|
return {
|
|
id: channel.agent_id,
|
|
name: overviewName,
|
|
is_builtin: false,
|
|
config: {},
|
|
};
|
|
}
|
|
|
|
function platformLabel(platform: string): string {
|
|
const key = `agentEditor.im.${platform}`;
|
|
return t(key);
|
|
}
|
|
|
|
function defaultChannelName(platform: string = formData.value.platform): string {
|
|
return platformLabel(platform);
|
|
}
|
|
|
|
function resolvedChannelName(): string {
|
|
return formData.value.name.trim() || defaultChannelName();
|
|
}
|
|
|
|
function platformSupportsThread(platform: string): boolean {
|
|
return ['slack', 'mattermost', 'feishu', 'telegram'].includes(platform);
|
|
}
|
|
|
|
watch(
|
|
() => formData.value.platform,
|
|
(p) => {
|
|
if (p === 'mattermost') {
|
|
formData.value.mode = 'webhook';
|
|
if (typeof formData.value.credentials.post_to_main !== 'boolean') {
|
|
formData.value.credentials.post_to_main = false;
|
|
}
|
|
}
|
|
if (!platformSupportsThread(p)) {
|
|
formData.value.session_mode = 'user';
|
|
}
|
|
},
|
|
);
|
|
// Whether WeChat credentials are already bound
|
|
const wechatBound = computed(() => {
|
|
return formData.value.platform === 'wechat' &&
|
|
formData.value.credentials.bot_token &&
|
|
formData.value.credentials.ilink_bot_id;
|
|
});
|
|
|
|
|
|
function onPlatformChange(val: string | number | boolean) {
|
|
if (editingChannel.value) return;
|
|
formData.value.credentials = defaultCredentials();
|
|
stopWeChatPolling();
|
|
wechatQRContent.value = '';
|
|
wechatQRImgUrl.value = '';
|
|
wechatQRCode.value = '';
|
|
wechatQRStatus.value = '';
|
|
// WeChat uses fixed mode/output
|
|
if (val === 'wechat') {
|
|
formData.value.mode = 'longpoll';
|
|
formData.value.output_mode = 'full';
|
|
} else {
|
|
formData.value.mode = 'websocket';
|
|
formData.value.output_mode = 'stream';
|
|
}
|
|
if (!channelNameTouched.value) {
|
|
formData.value.name = defaultChannelName(String(val));
|
|
}
|
|
}
|
|
|
|
async function startWeChatBinding() {
|
|
stopWeChatPolling();
|
|
wechatLoading.value = true;
|
|
wechatQRContent.value = '';
|
|
wechatQRImgUrl.value = '';
|
|
wechatQRStatus.value = '';
|
|
|
|
try {
|
|
const res = await getWeChatQRCode();
|
|
// qrcode_url is the text content to encode as QR code (e.g. a weixin:// URL)
|
|
wechatQRContent.value = res.data.qrcode_url;
|
|
wechatQRCode.value = res.data.qrcode;
|
|
wechatQRStatus.value = 'wait';
|
|
|
|
// Generate QR code image via public API (no extra npm dependency needed)
|
|
wechatQRImgUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(res.data.qrcode_url)}`;
|
|
|
|
// Start long-polling for scan status
|
|
startStatusPolling();
|
|
} catch (e: any) {
|
|
MessagePlugin.error(e?.message || 'Failed to generate QR code');
|
|
} finally {
|
|
wechatLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function startStatusPolling() {
|
|
wechatPollActive = true;
|
|
pollOnce();
|
|
}
|
|
|
|
async function pollOnce() {
|
|
if (!wechatPollActive) return;
|
|
try {
|
|
const statusRes = await pollWeChatQRCodeStatus(wechatQRCode.value);
|
|
if (!wechatPollActive) return;
|
|
wechatQRStatus.value = statusRes.data.status;
|
|
|
|
if (statusRes.data.status === 'confirmed' && statusRes.data.credentials) {
|
|
formData.value.credentials = {
|
|
bot_token: statusRes.data.credentials.bot_token,
|
|
ilink_bot_id: statusRes.data.credentials.ilink_bot_id,
|
|
ilink_user_id: statusRes.data.credentials.ilink_user_id,
|
|
};
|
|
stopWeChatPolling();
|
|
wechatQRContent.value = '';
|
|
wechatQRImgUrl.value = '';
|
|
MessagePlugin.success(t('agentEditor.im.wechatBindSuccess'));
|
|
return;
|
|
}
|
|
if (statusRes.data.status === 'expired') {
|
|
stopWeChatPolling();
|
|
return;
|
|
}
|
|
} catch {
|
|
// transient error
|
|
}
|
|
// Schedule next poll with a short delay (the backend already long-polled ~35s)
|
|
if (wechatPollActive) {
|
|
wechatPollTimer = setTimeout(pollOnce, 500);
|
|
}
|
|
}
|
|
|
|
function stopWeChatPolling() {
|
|
wechatPollActive = false;
|
|
if (wechatPollTimer) {
|
|
clearTimeout(wechatPollTimer);
|
|
wechatPollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function loadChannels() {
|
|
loading.value = true;
|
|
try {
|
|
const chatResources = useChatResourcesStore();
|
|
const [channelRes, agentRes] = await Promise.all([
|
|
listAllIMChannels(),
|
|
listAgents(),
|
|
chatResources.ensureKnowledgeBases(),
|
|
]);
|
|
allChannels.value = channelRes.data || [];
|
|
agents.value = agentRes?.data || [];
|
|
knowledgeBases.value = chatResources.rawKnowledgeBases.map((kb: any) => ({ id: kb.id, name: kb.name }));
|
|
} catch {
|
|
allChannels.value = [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function getCallbackUrl(channel: IMChannel): string {
|
|
const base = window.location.origin;
|
|
return `${base}/api/v1/im/callback/${channel.id}`;
|
|
}
|
|
|
|
async function copyUrl(channel: IMChannel) {
|
|
const text = getCallbackUrl(channel);
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
MessagePlugin.success(t('common.copySuccess'));
|
|
} catch {
|
|
const el = document.createElement('textarea');
|
|
el.value = text;
|
|
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
|
|
document.body.appendChild(el);
|
|
el.focus();
|
|
el.select();
|
|
const ok = document.execCommand('copy');
|
|
document.body.removeChild(el);
|
|
if (ok) {
|
|
MessagePlugin.success(t('common.copySuccess'));
|
|
} else {
|
|
MessagePlugin.error(t('common.copyFailed'));
|
|
}
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
resetForm();
|
|
if (filterAgentId.value) {
|
|
formData.value.target_agent_id = filterAgentId.value;
|
|
}
|
|
showCreateDialog.value = true;
|
|
}
|
|
|
|
function openDrawer(channel: IMChannel | IMChannelOverview) {
|
|
void editChannel(channel);
|
|
}
|
|
|
|
async function editChannel(channel: IMChannel | IMChannelOverview) {
|
|
wizardStep.value = 0;
|
|
let fullChannel: IMChannel | null = null;
|
|
if (!('credentials' in channel)) {
|
|
try {
|
|
const res = await listIMChannels(channel.agent_id);
|
|
fullChannel = (res.data || []).find((item) => item.id === channel.id) || null;
|
|
} catch {
|
|
fullChannel = null;
|
|
}
|
|
} else {
|
|
fullChannel = channel as IMChannel;
|
|
}
|
|
if (!fullChannel) {
|
|
MessagePlugin.error(t('common.operationFailed'));
|
|
return;
|
|
}
|
|
editingChannel.value = fullChannel;
|
|
editingEnabled.value = fullChannel.enabled;
|
|
channelNameTouched.value = true;
|
|
formData.value = {
|
|
target_agent_id: fullChannel.agent_id,
|
|
platform: fullChannel.platform,
|
|
name: fullChannel.name,
|
|
mode: fullChannel.mode,
|
|
output_mode: fullChannel.output_mode,
|
|
session_mode: fullChannel.session_mode || 'user',
|
|
knowledge_base_id: fullChannel.knowledge_base_id || '',
|
|
credentials: { ...fullChannel.credentials },
|
|
};
|
|
showCreateDialog.value = true;
|
|
}
|
|
|
|
function resetForm() {
|
|
editingChannel.value = null;
|
|
editingEnabled.value = true;
|
|
wizardStep.value = 0;
|
|
channelNameTouched.value = false;
|
|
stopWeChatPolling();
|
|
wechatQRContent.value = '';
|
|
wechatQRImgUrl.value = '';
|
|
wechatQRCode.value = '';
|
|
wechatQRStatus.value = '';
|
|
formData.value = {
|
|
target_agent_id: filterAgentId.value || '',
|
|
platform: 'wecom',
|
|
name: defaultChannelName('wecom'),
|
|
mode: 'websocket',
|
|
output_mode: 'stream',
|
|
session_mode: 'user',
|
|
knowledge_base_id: '',
|
|
credentials: defaultCredentials(),
|
|
};
|
|
}
|
|
|
|
async function handleSave() {
|
|
saving.value = true;
|
|
try {
|
|
// For WeChat, validate that credentials are bound
|
|
if (formData.value.platform === 'wechat' && !formData.value.credentials.bot_token) {
|
|
MessagePlugin.warning(t('agentEditor.im.wechatScanBind'));
|
|
return;
|
|
}
|
|
|
|
if (editingChannel.value) {
|
|
await updateIMChannel(editingChannel.value.id, {
|
|
name: resolvedChannelName(),
|
|
mode: formData.value.mode,
|
|
output_mode: formData.value.output_mode,
|
|
session_mode: formData.value.session_mode,
|
|
knowledge_base_id: formData.value.knowledge_base_id,
|
|
credentials: formData.value.credentials,
|
|
enabled: editingEnabled.value,
|
|
...(formData.value.target_agent_id ? { agent_id: formData.value.target_agent_id } : {}),
|
|
});
|
|
MessagePlugin.success(t('common.updateSuccess'));
|
|
} else {
|
|
const targetAgentId = formData.value.target_agent_id;
|
|
if (!targetAgentId) {
|
|
MessagePlugin.warning(t('integrations.selectAgentHint'));
|
|
return;
|
|
}
|
|
await createIMChannel(targetAgentId, {
|
|
platform: formData.value.platform,
|
|
name: resolvedChannelName(),
|
|
mode: formData.value.mode,
|
|
output_mode: formData.value.output_mode,
|
|
session_mode: formData.value.session_mode,
|
|
knowledge_base_id: formData.value.knowledge_base_id,
|
|
credentials: formData.value.credentials,
|
|
});
|
|
MessagePlugin.success(t('common.createSuccess'));
|
|
}
|
|
showCreateDialog.value = false;
|
|
resetForm();
|
|
await loadChannels();
|
|
} catch (e: any) {
|
|
const msg = e?.message || (typeof e?.error === 'string' ? e.error : null) || t('common.operationFailed');
|
|
MessagePlugin.error(msg);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function handleToggle(channel: IMChannel | IMChannelOverview) {
|
|
try {
|
|
await toggleIMChannel(channel.id);
|
|
await loadChannels();
|
|
} catch (e: any) {
|
|
MessagePlugin.error(e?.message || t('common.operationFailed'));
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
try {
|
|
await deleteIMChannel(id);
|
|
MessagePlugin.success(t('common.deleteSuccess'));
|
|
await loadChannels();
|
|
} catch (e: any) {
|
|
MessagePlugin.error(e?.message || t('common.operationFailed'));
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadChannels();
|
|
});
|
|
|
|
watch(filterAgentId, (id) => {
|
|
if (!showCreateDialog.value && !editingChannel.value && id) {
|
|
formData.value.target_agent_id = id;
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopWeChatPolling();
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
@import './css/channel-panel-list.less';
|
|
|
|
.im-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.drawer-platform-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.im-steps {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
border-bottom: 1px solid var(--td-component-stroke);
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
.im-step {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 12px;
|
|
color: var(--td-text-color-placeholder);
|
|
}
|
|
|
|
.im-step-title {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.im-step.active {
|
|
color: var(--td-brand-color);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.im-step.done {
|
|
color: var(--td-text-color-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.im-step-num {
|
|
flex-shrink: 0;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
border: 1px solid var(--td-component-stroke);
|
|
color: var(--td-text-color-placeholder);
|
|
background: transparent;
|
|
}
|
|
|
|
.im-step.active .im-step-num {
|
|
background: var(--td-brand-color);
|
|
color: #fff;
|
|
border-color: var(--td-brand-color);
|
|
}
|
|
|
|
.im-step.done .im-step-num {
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
color: var(--td-brand-color);
|
|
border-color: var(--td-component-stroke);
|
|
}
|
|
|
|
.im-step-check {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.im-step-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
:deep(.im-drawer__section.setting-drawer__section) {
|
|
gap: 10px;
|
|
padding: 10px 0 14px;
|
|
}
|
|
|
|
.callback-url-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.callback-url-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.mono-text-input :deep(input) {
|
|
font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.drawer-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.form-item {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--td-text-color-primary);
|
|
line-height: 1.4;
|
|
|
|
&--inline {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
&.required::after {
|
|
content: '*';
|
|
color: var(--td-error-color);
|
|
margin-left: 4px;
|
|
}
|
|
}
|
|
|
|
.im-platform-select-prefix {
|
|
width: 16px;
|
|
height: 16px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.im-platform-select-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
|
|
&__icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
object-fit: contain;
|
|
flex-shrink: 0;
|
|
}
|
|
}
|
|
|
|
.form-desc {
|
|
margin: 4px 0 0;
|
|
font-size: 12px;
|
|
line-height: 1.45;
|
|
color: var(--td-text-color-placeholder);
|
|
}
|
|
|
|
.option-chips {
|
|
display: inline-flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
padding: 3px;
|
|
border-radius: 8px;
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
}
|
|
|
|
.option-chip {
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--td-text-color-secondary);
|
|
font: inherit;
|
|
font-size: 12px;
|
|
line-height: 1.3;
|
|
padding: 5px 10px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
|
white-space: nowrap;
|
|
|
|
&:hover:not(:disabled) {
|
|
color: var(--td-text-color-primary);
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
&--active {
|
|
background: var(--td-bg-color-container);
|
|
color: var(--td-brand-color);
|
|
font-weight: 500;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
|
}
|
|
}
|
|
|
|
.settings-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.setting-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 12px 0;
|
|
border-bottom: 1px solid var(--td-component-stroke);
|
|
|
|
&--last {
|
|
border-bottom: none;
|
|
padding-bottom: 0;
|
|
}
|
|
}
|
|
|
|
.setting-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
max-width: 72%;
|
|
padding-right: 8px;
|
|
|
|
label {
|
|
display: block;
|
|
margin: 0 0 4px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--td-text-color-primary);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.desc {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
line-height: 1.45;
|
|
color: var(--td-text-color-placeholder);
|
|
}
|
|
}
|
|
|
|
.setting-control {
|
|
flex-shrink: 0;
|
|
padding-top: 2px;
|
|
}
|
|
|
|
.platform-link-hint {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
color: var(--td-text-color-placeholder);
|
|
|
|
.doc-link {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.hint-text {
|
|
color: var(--td-text-color-placeholder);
|
|
}
|
|
}
|
|
|
|
// --- WeChat QR code binding ---
|
|
.wechat-bound-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
background: rgba(7, 193, 96, 0.06);
|
|
border: 1px solid rgba(7, 193, 96, 0.2);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: var(--td-text-color-primary);
|
|
|
|
.bound-icon {
|
|
font-size: 18px;
|
|
color: #07c160;
|
|
}
|
|
}
|
|
|
|
.wechat-qr-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.wechat-bind-action {
|
|
padding: 24px 0;
|
|
}
|
|
|
|
.wechat-qr-display {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 16px 0;
|
|
}
|
|
|
|
.qr-container {
|
|
position: relative;
|
|
width: 200px;
|
|
height: 200px;
|
|
border: 1px solid var(--td-component-stroke);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
// QR code images are always black-on-white; force white background
|
|
// so the code remains scannable in dark mode.
|
|
background: #fff;
|
|
|
|
.qr-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
}
|
|
|
|
.qr-expired-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
|
|
.refresh-icon {
|
|
font-size: 24px;
|
|
}
|
|
}
|
|
|
|
.qr-hint {
|
|
font-size: 13px;
|
|
color: var(--td-text-color-secondary);
|
|
text-align: center;
|
|
}
|
|
</style>
|
|
|
|
<style lang="less">
|
|
.im-channel-drawer .setting-drawer__header-icon:has(.drawer-platform-icon) {
|
|
background: var(--td-bg-color-container, #fff);
|
|
box-shadow: inset 0 0 0 1px var(--td-component-stroke);
|
|
}
|
|
|
|
.im-channel-drawer .drawer-platform-icon {
|
|
display: block;
|
|
width: 20px;
|
|
height: 20px;
|
|
object-fit: contain;
|
|
}
|
|
</style>
|