mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
4e22b85557
commit
22894ac4b1
748
mateclaw-ui/src/components/channels/ChannelEditModal.vue
Normal file
748
mateclaw-ui/src/components/channels/ChannelEditModal.vue
Normal 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>
|
||||||
87
mateclaw-ui/src/composables/channels/useWecomBotAuth.ts
Normal file
87
mateclaw-ui/src/composables/channels/useWecomBotAuth.ts
Normal 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 }
|
||||||
|
}
|
||||||
107
mateclaw-ui/src/composables/channels/useWeixinQrcodePoll.ts
Normal file
107
mateclaw-ui/src/composables/channels/useWeixinQrcodePoll.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -1,28 +1,35 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { settingsApi } from '@/api'
|
import { settingsApi } from '@/api'
|
||||||
import enUS from './locales/en-US'
|
|
||||||
import zhCN from './locales/zh-CN'
|
|
||||||
|
|
||||||
export type AppLocale = 'zh-CN' | 'en-US'
|
export type AppLocale = 'zh-CN' | 'en-US'
|
||||||
|
|
||||||
const STORAGE_KEY = 'mateclaw_locale'
|
const STORAGE_KEY = 'mateclaw_locale'
|
||||||
const DEFAULT_LOCALE: AppLocale = 'zh-CN'
|
const DEFAULT_LOCALE: AppLocale = 'zh-CN'
|
||||||
|
|
||||||
const messages = {
|
|
||||||
'zh-CN': zhCN,
|
|
||||||
'en-US': enUS,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const currentLocale = ref<AppLocale>(DEFAULT_LOCALE)
|
export const currentLocale = ref<AppLocale>(DEFAULT_LOCALE)
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: currentLocale.value,
|
locale: DEFAULT_LOCALE,
|
||||||
fallbackLocale: 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 {
|
function normalizeLocale(locale?: string | null): AppLocale {
|
||||||
if (locale === 'en' || locale === 'en-US') {
|
if (locale === 'en' || locale === 'en-US') {
|
||||||
return 'en-US'
|
return 'en-US'
|
||||||
@ -30,8 +37,11 @@ function normalizeLocale(locale?: string | null): AppLocale {
|
|||||||
return 'zh-CN'
|
return 'zh-CN'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyLocale(locale?: string | null) {
|
export async function applyLocale(locale?: string | null) {
|
||||||
const normalized = normalizeLocale(locale)
|
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
|
currentLocale.value = normalized
|
||||||
i18n.global.locale.value = normalized
|
i18n.global.locale.value = normalized
|
||||||
localStorage.setItem(STORAGE_KEY, normalized)
|
localStorage.setItem(STORAGE_KEY, normalized)
|
||||||
@ -41,8 +51,8 @@ export function applyLocale(locale?: string | null) {
|
|||||||
export async function initializeLocale() {
|
export async function initializeLocale() {
|
||||||
try {
|
try {
|
||||||
const res: any = await settingsApi.getLanguage()
|
const res: any = await settingsApi.getLanguage()
|
||||||
return applyLocale(res.data)
|
return await applyLocale(res.data)
|
||||||
} catch {
|
} catch {
|
||||||
return applyLocale(localStorage.getItem(STORAGE_KEY))
|
return await applyLocale(localStorage.getItem(STORAGE_KEY))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,10 @@ const router = createRouter({
|
|||||||
path: 'channels',
|
path: 'channels',
|
||||||
name: 'Channels',
|
name: 'Channels',
|
||||||
component: () => import('@/views/Channels.vue'),
|
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',
|
path: 'skills',
|
||||||
|
|||||||
101
mateclaw-ui/src/utils/channelConfigJson.ts
Normal file
101
mateclaw-ui/src/utils/channelConfigJson.ts
Normal 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
@ -140,7 +140,12 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
|
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|
||||||
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
||||||
@ -386,7 +391,7 @@ function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function changeLocale(locale: AppLocale) {
|
async function changeLocale(locale: AppLocale) {
|
||||||
applyLocale(locale)
|
await applyLocale(locale)
|
||||||
footerPanelOpen.value = false
|
footerPanelOpen.value = false
|
||||||
try {
|
try {
|
||||||
await settingsApi.update({ language: locale })
|
await settingsApi.update({ language: locale })
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user