perf(channels): split Channels.vue, lazy-load modal, async locales, keep-alive route

- Extract create/edit modal into ChannelEditModal.vue (defineAsyncComponent),
  shrinking Channels.vue from 1438 to 370 lines and dropping ~30KB from the
  initial route chunk.
- Move side-effect logic into composables: useWeixinQrcodePoll (QR + 2s status
  poll, auto-cleanup) and useWecomBotAuth (lazy SDK script with module-level
  promise dedupe). Pure config-JSON helpers move to utils/channelConfigJson.ts.
- Switch i18n locales from static imports to dynamic import keyed by current
  locale; applyLocale becomes async to avoid first-render flicker.
- /channels route opts into keep-alive (meta.keepAlive=true). Channels.vue
  pauses status polling in onDeactivated and resumes in onActivated, with an
  isActive guard to prevent late-resolving timers from leaking after navigation.
- Initial load goes from serial 3-RTT to Promise.all + 4-card el-skeleton.
This commit is contained in:
matevip 2026-04-28 11:08:36 +08:00
parent 4e22b85557
commit 22894ac4b1
8 changed files with 1233 additions and 1185 deletions

View File

@ -0,0 +1,748 @@
<template>
<div v-if="modelValue" 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="close">
<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="null">{{ 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>
<!-- 表单配置 -->
<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>
<!-- 微信扫码登录 -->
<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="emit('add-new-weixin')">
<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="weixin.start" :disabled="weixin.loading.value">
<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')
: (weixin.loading.value ? t('channels.weixin.qrcodeLoading') : t('channels.weixin.qrcodeButton'))
}}
</button>
<div v-if="weixin.qrcodeImg.value || weixin.pollStatus.value === 'confirmed'" class="weixin-qrcode-area">
<img v-if="weixin.qrcodeImg.value" :src="weixin.qrcodeImg.value" alt="WeChat QR Code" class="weixin-qrcode-img" />
<p class="weixin-scan-hint" :class="weixin.pollStatus.value">
<template v-if="weixin.pollStatus.value === 'scanned'">{{ t('channels.weixin.scanned') }}</template>
<template v-else-if="weixin.pollStatus.value === 'confirmed'">{{ t('channels.weixin.loginSuccess') }}</template>
<template v-else-if="weixin.pollStatus.value === '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="wecom.start" :disabled="wecom.loading.value">
<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>
{{ wecom.loading.value ? 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>
<div v-else-if="form.channelType === 'web'" class="empty-config">
<p class="empty-text">{{ t('channels.webHint') }}</p>
</div>
<div v-else-if="form.channelType === 'webchat'" class="empty-config">
<p class="empty-text">{{ t('channels.webchatHint') }}</p>
</div>
<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="close">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="save" :disabled="!form.name">{{ t('common.save') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { CHANNEL_FIELD_DEFS } from '@/types'
import type { Agent, Channel, ChannelFieldDef } from '@/types'
import {
buildConfigJson,
defaultAccessControl,
defaultRenderConfig,
extractAccessControl,
extractChannelFields,
extractRenderConfig,
parseConfigJson,
type AccessControlValue,
type RenderConfigValue,
} from '@/utils/channelConfigJson'
import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll'
import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth'
interface Props {
modelValue: boolean
editingChannel: Channel | null
agents: Agent[]
/** When opening in create mode, optionally preset the channel type. */
defaultType?: string
/** When opening in create mode, optionally preset the name. */
defaultName?: string
}
const props = withDefaults(defineProps<Props>(), {
defaultType: 'web',
defaultName: '',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [payload: Partial<Channel>]
/**
* User clicked "Add new WeChat account" while editing an existing one.
* Parent should close this modal and reopen in create mode with weixin
* pre-selected. Parent owns the existing-channel count needed for naming.
*/
'add-new-weixin': []
}>()
const { t } = useI18n()
// ========== Form state (owned by modal; parent only opens/closes) ==========
const defaultForm = (): Partial<Channel> => ({
name: props.defaultName,
channelType: props.defaultType,
description: '',
configJson: '',
agentId: null as any,
enabled: true,
})
const form = ref<Partial<Channel>>(defaultForm())
const channelConfig = ref<Record<string, any>>({})
const visibleFields = ref<Record<string, boolean>>({})
const configTab = ref<'form' | 'json'>('form')
const rawConfigJson = ref('')
const showAdvanced = ref(false)
const accessControl = ref<AccessControlValue>(defaultAccessControl())
const renderConfig = ref<RenderConfigValue>(defaultRenderConfig())
// ========== Auth composables ==========
const weixin = useWeixinQrcodePoll(({ botToken, baseUrl }) => {
channelConfig.value.bot_token = botToken
if (baseUrl) channelConfig.value.base_url = baseUrl
// Auto-suffix the channel name with the last 6 chars of the token in create
// mode, so multiple weixin accounts are visually distinguishable in the list.
if (!props.editingChannel) {
const suffix = botToken.slice(-6)
const cur = form.value.name || ''
if (!cur || cur === t('channels.weixin.newAccountName') || cur === t('channels.types.weixin')) {
form.value.name = t('channels.weixin.newAccountName') + ' (' + suffix + ')'
}
}
})
const wecom = useWecomBotAuth((bot) => {
channelConfig.value.bot_id = bot.botid
channelConfig.value.secret = bot.secret
})
// ========== Field defs (derived) ==========
const currentFieldDefs = computed<ChannelFieldDef[]>(() => {
const all = CHANNEL_FIELD_DEFS[form.value.channelType || ''] || []
return all.filter((field) => {
if (!field.showIf) return true
return channelConfig.value[field.showIf.field] === field.showIf.value
})
})
// ========== Webhook guide ==========
const WEBHOOK_GUIDES = computed<Record<string, { steps: string[] }>>(() => ({
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(() => WEBHOOK_GUIDES.value[form.value.channelType || ''] || null)
const needsWebhookUrl = computed(() => {
const type = form.value.channelType
if (type === 'wecom' || type === 'qq' || type === 'weixin') return false
if (type === 'discord') return false
if (type === 'dingtalk' && channelConfig.value.connection_mode !== 'webhook') return false
if (type === 'feishu' && channelConfig.value.connection_mode === 'websocket') return false
if (type === 'telegram' && channelConfig.value.connection_mode !== 'webhook') return false
return true
})
const isLocalhost = computed(() => {
const host = window.location.hostname
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'
})
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}`
})
const feishuPermissionUrl = computed(() => {
const appId = channelConfig.value?.app_id || ''
const domain = channelConfig.value?.domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn'
return appId ? `https://${domain}/app/${appId}/permission` : `https://${domain}/`
})
const feishuRequiredPermissions = computed(() => {
const perms: { scope: string; desc: string; reason: string }[] = [
{ 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
})
// ========== Copy webhook URL ==========
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'))
}
}
// ========== Initialization (the modal is v-if'd, so each open is a fresh mount) ==========
function initFromEditing(channel: Channel) {
form.value = { ...channel }
configTab.value = 'form'
showAdvanced.value = false
visibleFields.value = {}
const cfg = parseConfigJson(channel.configJson)
channelConfig.value = extractChannelFields(cfg, channel.channelType)
accessControl.value = extractAccessControl(cfg)
renderConfig.value = extractRenderConfig(cfg)
rawConfigJson.value = channel.configJson || ''
}
function initForCreate() {
form.value = defaultForm()
channelConfig.value = {}
visibleFields.value = {}
accessControl.value = defaultAccessControl()
renderConfig.value = defaultRenderConfig()
rawConfigJson.value = ''
configTab.value = 'form'
showAdvanced.value = false
initDefaultFieldValues()
}
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
}
}
}
onMounted(() => {
if (props.editingChannel) initFromEditing(props.editingChannel)
else initForCreate()
})
// ========== Form actions ==========
function onChannelTypeChange() {
channelConfig.value = {}
visibleFields.value = {}
weixin.reset()
initDefaultFieldValues()
}
function switchTab(tab: 'form' | 'json') {
if (tab === 'json' && configTab.value === 'form') {
rawConfigJson.value = buildConfigJson({
channelType: form.value.channelType || '',
channelConfig: channelConfig.value,
accessControl: accessControl.value,
renderConfig: renderConfig.value,
})
} else if (tab === 'form' && configTab.value === 'json') {
const cfg = parseConfigJson(rawConfigJson.value)
channelConfig.value = extractChannelFields(cfg, form.value.channelType || '')
accessControl.value = extractAccessControl(cfg)
renderConfig.value = extractRenderConfig(cfg)
}
configTab.value = tab
}
function close() {
emit('update:modelValue', false)
}
function save() {
const payload: Partial<Channel> = { ...form.value }
if (configTab.value === 'json') {
if (rawConfigJson.value.trim()) {
try { JSON.parse(rawConfigJson.value) }
catch {
ElMessage.error(t('channels.messages.invalidJson'))
return
}
}
payload.configJson = rawConfigJson.value
} else {
payload.configJson = buildConfigJson({
channelType: form.value.channelType || '',
channelConfig: channelConfig.value,
accessControl: accessControl.value,
renderConfig: renderConfig.value,
})
}
emit('save', payload)
}
</script>
<style scoped>
.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); }
/* 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-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; }
/* Tabs */
.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 */
.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-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; }
.guide-steps :deep(b) { color: var(--mc-text-primary); font-weight: 600; }
/* WeCom auth */
.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; }
/* Weixin QR */
.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; }
/* Feishu permissions */
.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 / inline icons */
.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 */
.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 */
.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-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>

View File

@ -0,0 +1,87 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
const SDK_URL = 'https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js'
const SOURCE = 'mateclaw'
// Module-level guard: the SDK script tag is appended to <body> exactly once
// across all component instances and re-renders.
let sdkLoadPromise: Promise<void> | null = null
function loadSDK(): Promise<void> {
if ((window as any).WecomAIBotSDK) return Promise.resolve()
if (sdkLoadPromise) return sdkLoadPromise
sdkLoadPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = SDK_URL
script.async = true
script.onload = () => resolve()
script.onerror = () => {
sdkLoadPromise = null // allow retry on next call
reject(new Error('WeCom SDK load failed'))
}
document.body.appendChild(script)
})
return sdkLoadPromise
}
export interface WecomBotAuthResult {
botid: string
secret: string
}
/**
* JS SDK botid/secret
*
*
* - SDK "授权" <head>
* - append <script>sdkLoadPromise
* - template + loading
*/
export function useWecomBotAuth(onSuccess: (r: WecomBotAuthResult) => void) {
const { t } = useI18n()
const loading = ref(false)
async function start() {
loading.value = true
try {
await loadSDK()
} catch {
ElMessage.error(t('channels.wecom.sdkFailed'))
loading.value = false
return
}
const sdk = (window as any).WecomAIBotSDK
if (!sdk) {
ElMessage.error(t('channels.wecom.sdkFailed'))
loading.value = false
return
}
loading.value = false
const result = sdk.openBotInfoAuthWindow({ source: SOURCE })
if (!result || typeof result.then !== 'function') return
result.then(
(bot: WecomBotAuthResult) => {
if (bot?.botid) {
onSuccess(bot)
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 || ''))
}
},
)
}
return { loading, start }
}

View File

@ -0,0 +1,107 @@
import { ref, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { channelApi } from '@/api'
export type WeixinPollStatus = '' | 'polling' | 'scanned' | 'confirmed' | 'expired'
export interface WeixinQrcodeResult {
botToken: string
baseUrl?: string
}
/**
* iLink Bot
* 2s confirmed botToken/baseUrl
*
* composable
* - state
* - stopPolling
* -
*/
export function useWeixinQrcodePoll(onConfirmed: (r: WeixinQrcodeResult) => void) {
const { t } = useI18n()
const qrcodeImg = ref('')
const loading = ref(false)
const pollStatus = ref<WeixinPollStatus>('')
let pollTimer: ReturnType<typeof setInterval> | null = null
let confirmedFired = false
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function reset() {
stopPolling()
qrcodeImg.value = ''
pollStatus.value = ''
confirmedFired = false
}
async function start() {
reset()
loading.value = true
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'))
return
}
if (imgContent) {
qrcodeImg.value = imgContent.startsWith('http')
? imgContent
: `data:image/png;base64,${imgContent}`
}
pollStatus.value = 'polling'
if (!qrcodeId) return
pollTimer = setInterval(async () => {
try {
const s: any = await channelApi.weixinQrcodeStatus(qrcodeId)
const status = s?.status || ''
if (status === 'scanned') {
pollStatus.value = 'scanned'
}
if (status === 'confirmed' && s?.bot_token) {
if (confirmedFired) return
confirmedFired = true
stopPolling()
qrcodeImg.value = ''
pollStatus.value = 'confirmed'
onConfirmed({ botToken: s.bot_token, baseUrl: s.base_url })
ElMessage.success(t('channels.weixin.loginSuccess'))
}
if (status === 'expired') {
stopPolling()
qrcodeImg.value = ''
pollStatus.value = 'expired'
ElMessage.warning(t('channels.weixin.qrcodeExpired'))
}
} catch {
// Silent — transient network errors should not abort the loop.
}
}, 2000)
} catch {
ElMessage.error(t('channels.weixin.qrcodeFailed'))
} finally {
loading.value = false
}
}
onBeforeUnmount(stopPolling)
return { qrcodeImg, loading, pollStatus, start, reset, stopPolling }
}

View File

@ -1,28 +1,35 @@
import { createI18n } from 'vue-i18n'
import { ref } from 'vue'
import { settingsApi } from '@/api'
import enUS from './locales/en-US'
import zhCN from './locales/zh-CN'
export type AppLocale = 'zh-CN' | 'en-US'
const STORAGE_KEY = 'mateclaw_locale'
const DEFAULT_LOCALE: AppLocale = 'zh-CN'
const messages = {
'zh-CN': zhCN,
'en-US': enUS,
}
export const currentLocale = ref<AppLocale>(DEFAULT_LOCALE)
export const i18n = createI18n({
legacy: false,
locale: currentLocale.value,
locale: DEFAULT_LOCALE,
fallbackLocale: DEFAULT_LOCALE,
messages,
messages: {} as Record<AppLocale, any>,
})
const loadedLocales = new Set<AppLocale>()
// Each locale dictionary is ~78KB. Splitting them into their own chunks keeps
// the entry bundle ~150KB lighter — only the active locale is fetched on cold
// start, the other one only when the user switches language.
async function loadLocaleMessages(locale: AppLocale) {
if (loadedLocales.has(locale)) return
const messages = locale === 'zh-CN'
? (await import('./locales/zh-CN')).default
: (await import('./locales/en-US')).default
i18n.global.setLocaleMessage(locale, messages)
loadedLocales.add(locale)
}
function normalizeLocale(locale?: string | null): AppLocale {
if (locale === 'en' || locale === 'en-US') {
return 'en-US'
@ -30,8 +37,11 @@ function normalizeLocale(locale?: string | null): AppLocale {
return 'zh-CN'
}
export function applyLocale(locale?: string | null) {
export async function applyLocale(locale?: string | null) {
const normalized = normalizeLocale(locale)
// Must finish loading messages before flipping currentLocale, otherwise the
// first render after a switch would show the i18n keys verbatim.
await loadLocaleMessages(normalized)
currentLocale.value = normalized
i18n.global.locale.value = normalized
localStorage.setItem(STORAGE_KEY, normalized)
@ -41,8 +51,8 @@ export function applyLocale(locale?: string | null) {
export async function initializeLocale() {
try {
const res: any = await settingsApi.getLanguage()
return applyLocale(res.data)
return await applyLocale(res.data)
} catch {
return applyLocale(localStorage.getItem(STORAGE_KEY))
return await applyLocale(localStorage.getItem(STORAGE_KEY))
}
}

View File

@ -44,7 +44,10 @@ const router = createRouter({
path: 'channels',
name: 'Channels',
component: () => import('@/views/Channels.vue'),
meta: { title: 'Channels' },
// keepAlive: cache the component instance so navigating away and
// back doesn't re-mount + re-fetch the list. Channels.vue must
// pause polling in onDeactivated to avoid a leaked timer.
meta: { title: 'Channels', keepAlive: true },
},
{
path: 'skills',

View File

@ -0,0 +1,101 @@
import type { ChannelFieldDef } from '@/types'
import { CHANNEL_FIELD_DEFS } from '@/types'
export interface AccessControlValue {
dm_policy: string
group_policy: string
allow_from: string
deny_message: string
require_mention: boolean
}
export interface RenderConfigValue {
filter_thinking: boolean
filter_tool_messages: boolean
message_format: string
}
export const defaultAccessControl = (): AccessControlValue => ({
dm_policy: 'open',
group_policy: 'open',
allow_from: '',
deny_message: '',
require_mention: false,
})
export const defaultRenderConfig = (): RenderConfigValue => ({
filter_thinking: true,
filter_tool_messages: true,
message_format: 'auto',
})
export function parseConfigJson(json?: string): Record<string, any> {
if (!json) return {}
try { return JSON.parse(json) } catch { return {} }
}
export function extractChannelFields(cfg: Record<string, any>, channelType: string): Record<string, any> {
const fields: ChannelFieldDef[] = CHANNEL_FIELD_DEFS[channelType] || []
// Backwards compat: legacy Telegram configs without connection_mode but with
// webhook_url should be treated as webhook mode.
if (channelType === 'telegram' && cfg.webhook_url && !cfg.connection_mode) {
cfg = { ...cfg, connection_mode: 'webhook' }
}
const result: Record<string, any> = {}
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
}
export function extractAccessControl(cfg: Record<string, any>): AccessControlValue {
const d = defaultAccessControl()
return {
dm_policy: cfg.dm_policy || d.dm_policy,
group_policy: cfg.group_policy || d.group_policy,
allow_from: Array.isArray(cfg.allow_from) ? cfg.allow_from.join(', ') : (cfg.allow_from || ''),
deny_message: cfg.deny_message || d.deny_message,
require_mention: cfg.require_mention === true,
}
}
export function extractRenderConfig(cfg: Record<string, any>): RenderConfigValue {
const d = defaultRenderConfig()
return {
filter_thinking: cfg.filter_thinking !== false,
filter_tool_messages: cfg.filter_tool_messages !== false,
message_format: cfg.message_format || d.message_format,
}
}
export interface BuildConfigJsonInput {
channelType: string
channelConfig: Record<string, any>
accessControl: AccessControlValue
renderConfig: RenderConfigValue
}
export function buildConfigJson(input: BuildConfigJsonInput): string {
const cfg: Record<string, any> = {}
const fields: ChannelFieldDef[] = CHANNEL_FIELD_DEFS[input.channelType] || []
for (const f of fields) {
const val = input.channelConfig[f.key]
if (val !== undefined && val !== '' && val !== null) cfg[f.key] = val
}
cfg.dm_policy = input.accessControl.dm_policy
cfg.group_policy = input.accessControl.group_policy
cfg.allow_from = input.accessControl.allow_from
? input.accessControl.allow_from.split(/[,]/).map((s: string) => s.trim()).filter(Boolean)
: []
cfg.deny_message = input.accessControl.deny_message
cfg.require_mention = input.accessControl.require_mention
cfg.filter_thinking = input.renderConfig.filter_thinking
cfg.filter_tool_messages = input.renderConfig.filter_tool_messages
cfg.message_format = input.renderConfig.message_format
return JSON.stringify(cfg, null, 2)
}

File diff suppressed because it is too large Load Diff

View File

@ -140,7 +140,12 @@
</button>
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
</div>
<router-view :key="workspaceRouteKey" />
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" :key="workspaceRouteKey" v-if="route.meta?.keepAlive" />
</keep-alive>
<component :is="Component" :key="workspaceRouteKey" v-if="!route.meta?.keepAlive" />
</router-view>
</main>
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
@ -386,7 +391,7 @@ function logout() {
}
async function changeLocale(locale: AppLocale) {
applyLocale(locale)
await applyLocale(locale)
footerPanelOpen.value = false
try {
await settingsApi.update({ language: locale })