402 lines
12 KiB
Vue
402 lines
12 KiB
Vue
<!--
|
||||
|
|
Copyright 2024-2026 ReplyPier
|
|||
|
|
|
|||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|||
|
|
you may not use this file except in compliance with the License.
|
|||
|
|
You may obtain a copy of the License at
|
|||
|
|
|
|||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|||
|
|
|
|||
|
|
Unless required by applicable law or agreed to in writing, software
|
|||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
|
|
See the License for the specific language governing permissions and
|
|||
|
|
limitations under the License.
|
|||
|
|
-->
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed, onMounted } from 'vue'
|
|||
|
|
import { toast } from 'vue-sonner'
|
|||
|
|
import channelsService, { type ChannelAccount } from '@/services/channels'
|
|||
|
|
import { agentService } from '@/services/agent'
|
|||
|
|
import type { Agent } from '@/types/agent'
|
|||
|
|
import MessengerPagePicker from './MessengerPagePicker.vue'
|
|||
|
|
import MetaOwnAppWebhook from './MetaOwnAppWebhook.vue'
|
|||
|
|
import { useMetaSignup } from '@/composables/useMetaSignup'
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
channel: 'whatsapp' | 'messenger' | 'instagram'
|
|||
|
|
existingAccount?: ChannelAccount | null
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
(e: 'close'): void
|
|||
|
|
(e: 'connected', account: ChannelAccount): void
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
// Where the credentials are copied from — linked so it's one click, not a
|
|||
|
|
// hostname to retype.
|
|||
|
|
const META_APPS_URL = 'https://developers.facebook.com/apps/'
|
|||
|
|
|
|||
|
|
// Meta signs your own app's webhooks with its secret: without it a number or
|
|||
|
|
// Page from your app can send but never receives.
|
|||
|
|
const APP_SECRET_FIELD = {
|
|||
|
|
key: 'app_secret', label: 'App Secret(可选)', placeholder: 'App settings → Basic', secret: true,
|
|||
|
|
} as const
|
|||
|
|
|
|||
|
|
// Per-channel copy + credential fields. The intro is split around the link
|
|||
|
|
// rather than carrying markup, so it renders as escaped text.
|
|||
|
|
const META_FORMS = {
|
|||
|
|
whatsapp: {
|
|||
|
|
title: '连接 WhatsApp',
|
|||
|
|
introBefore: '在你的 Meta 应用中(',
|
|||
|
|
introAfter: ' → WhatsApp → API Setup)复制电话号码 ID 和永久访问 Token。添加 App Secret 后才能接收消息。',
|
|||
|
|
fields: [
|
|||
|
|
{ key: 'phone_number_id', label: '电话号码 ID', placeholder: '1234567890', secret: false },
|
|||
|
|
{ key: 'access_token', label: '访问 Token', placeholder: 'EAAG…', secret: true },
|
|||
|
|
{ key: 'waba_id', label: 'WhatsApp Business 账号 ID(可选)', placeholder: '用于自动订阅 Webhook', secret: false },
|
|||
|
|
APP_SECRET_FIELD,
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
messenger: {
|
|||
|
|
title: '连接 Messenger',
|
|||
|
|
introBefore: '在你的 Meta 应用中(',
|
|||
|
|
introAfter: ' → Messenger → 设置)为要连接的 Facebook 公共主页生成公共主页访问 Token。添加 App Secret 后才能接收消息。',
|
|||
|
|
fields: [
|
|||
|
|
{ key: 'page_id', label: 'Facebook 公共主页 ID', placeholder: '1234567890', secret: false },
|
|||
|
|
{ key: 'page_access_token', label: '公共主页访问 Token', placeholder: 'EAAG…', secret: true },
|
|||
|
|
APP_SECRET_FIELD,
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
instagram: {
|
|||
|
|
title: '连接 Instagram',
|
|||
|
|
introBefore: '你的 Instagram 账号必须是已关联 Facebook 公共主页的专业账号。请使用所关联公共主页的访问 Token,来源:',
|
|||
|
|
introAfter: ' → Instagram。',
|
|||
|
|
fields: [
|
|||
|
|
{ key: 'ig_id', label: 'Instagram 账号 ID', placeholder: '17841400000000000', secret: false },
|
|||
|
|
{ key: 'page_access_token', label: '关联公共主页访问 Token', placeholder: 'EAAG…', secret: true },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
} as const
|
|||
|
|
|
|||
|
|
const form = computed(() => META_FORMS[props.channel])
|
|||
|
|
const values = ref<Record<string, string>>({})
|
|||
|
|
const connecting = ref(false)
|
|||
|
|
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
|
|||
|
|
|
|||
|
|
const agents = ref<Agent[]>([])
|
|||
|
|
const selectedAgentId = ref('')
|
|||
|
|
const savingAgent = ref(false)
|
|||
|
|
|
|||
|
|
// The three one-click logins live in their own composable; this component owns
|
|||
|
|
// the manual credentials form and agent assignment.
|
|||
|
|
const {
|
|||
|
|
signupEnabled,
|
|||
|
|
signingUp,
|
|||
|
|
showManualForm,
|
|||
|
|
signupPages,
|
|||
|
|
connectingPage,
|
|||
|
|
copy: signupCopy,
|
|||
|
|
startSignup,
|
|||
|
|
onPageSelected,
|
|||
|
|
} = useMetaSignup({
|
|||
|
|
channel: props.channel,
|
|||
|
|
existingAccount: props.existingAccount,
|
|||
|
|
onConnected: (connected) => { account.value = connected },
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
try {
|
|||
|
|
agents.value = await agentService.getOrganizationAgents()
|
|||
|
|
selectedAgentId.value = String(
|
|||
|
|
props.existingAccount?.agent_id || agents.value[0]?.id || '')
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error loading agents:', error)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const connect = async () => {
|
|||
|
|
const missing = form.value.fields.filter(f => !f.label.includes('可选') && !values.value[f.key]?.trim())
|
|||
|
|
if (missing.length > 0) {
|
|||
|
|
toast.error(`请填写:${missing.map(f => f.label).join('、')}`)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
connecting.value = true
|
|||
|
|
const payload: any = Object.fromEntries(
|
|||
|
|
form.value.fields
|
|||
|
|
.map(f => [f.key, values.value[f.key]?.trim()])
|
|||
|
|
.filter(([, v]) => v)
|
|||
|
|
)
|
|||
|
|
if (props.channel === 'whatsapp') {
|
|||
|
|
account.value = await channelsService.connectWhatsApp(payload)
|
|||
|
|
} else if (props.channel === 'messenger') {
|
|||
|
|
account.value = await channelsService.connectMessenger(payload)
|
|||
|
|
} else {
|
|||
|
|
account.value = await channelsService.connectInstagram(payload)
|
|||
|
|
}
|
|||
|
|
toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`)
|
|||
|
|
} catch (error: any) {
|
|||
|
|
toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`)
|
|||
|
|
} finally {
|
|||
|
|
connecting.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const saveAgent = async () => {
|
|||
|
|
if (!account.value || !selectedAgentId.value) return
|
|||
|
|
try {
|
|||
|
|
savingAgent.value = true
|
|||
|
|
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
|
|||
|
|
toast.success('智能体已分配 — 该渠道已生效!')
|
|||
|
|
emit('connected', updated)
|
|||
|
|
} catch (error: any) {
|
|||
|
|
toast.error(error?.response?.data?.detail || '分配智能体失败')
|
|||
|
|
} finally {
|
|||
|
|
savingAgent.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="meta-modal" @click.self="emit('close')">
|
|||
|
|
<div class="meta-modal-content">
|
|||
|
|
<div class="meta-modal-header">
|
|||
|
|
<h3>{{ form.title }}</h3>
|
|||
|
|
<button class="meta-close-btn" @click="emit('close')">×</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Step 1: credentials -->
|
|||
|
|
<div v-if="!account" class="meta-modal-body">
|
|||
|
|
<!-- Login for Business can grant several accounts at once; the customer
|
|||
|
|
picks which one this channel answers. -->
|
|||
|
|
<MessengerPagePicker
|
|||
|
|
v-if="signupPages.length"
|
|||
|
|
:pages="signupPages"
|
|||
|
|
:connecting="connectingPage"
|
|||
|
|
@select="onPageSelected"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- One-click signup under ReplyPier's Meta app; the manual form
|
|||
|
|
stays available for anyone who already has their own credentials. -->
|
|||
|
|
<div v-else-if="signupEnabled && !showManualForm" class="meta-signup">
|
|||
|
|
<p class="meta-intro">{{ signupCopy.intro }}</p>
|
|||
|
|
<button class="meta-btn meta-btn-primary meta-signup-btn" :disabled="signingUp" @click="startSignup">
|
|||
|
|
<font-awesome-icon v-if="signingUp" icon="fa-solid fa-spinner" spin />
|
|||
|
|
{{ signingUp ? '正在等待 Meta…' : signupCopy.cta }}
|
|||
|
|
</button>
|
|||
|
|
<button class="meta-link-btn" @click="showManualForm = true">
|
|||
|
|
改为手动输入凭据
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template v-else>
|
|||
|
|
<p class="meta-intro">
|
|||
|
|
{{ form.introBefore }}<a
|
|||
|
|
:href="META_APPS_URL"
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
class="meta-intro-link"
|
|||
|
|
>developers.facebook.com</a>{{ form.introAfter }}
|
|||
|
|
</p>
|
|||
|
|
<div v-for="field in form.fields" :key="field.key" class="meta-field">
|
|||
|
|
<label class="meta-label" :for="`meta-${field.key}`">{{ field.label }}</label>
|
|||
|
|
<input
|
|||
|
|
:id="`meta-${field.key}`"
|
|||
|
|
v-model="values[field.key]"
|
|||
|
|
:type="field.secret ? 'password' : 'text'"
|
|||
|
|
class="meta-input"
|
|||
|
|
:placeholder="field.placeholder"
|
|||
|
|
:name="`meta-${channel}-${field.key}`"
|
|||
|
|
:autocomplete="field.secret ? 'new-password' : 'off'"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div class="meta-actions">
|
|||
|
|
<button class="meta-btn meta-btn-secondary" @click="emit('close')">取消</button>
|
|||
|
|
<button class="meta-btn meta-btn-primary" :disabled="connecting" @click="connect">
|
|||
|
|
{{ connecting ? '连接中…' : '连接' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Step 2: route to an agent -->
|
|||
|
|
<div v-else class="meta-modal-body">
|
|||
|
|
<div v-if="account.needs_reconnect" class="meta-reconnect">
|
|||
|
|
<p class="meta-reconnect-text">Meta 已停止接受该账号的访问权限,请重新连接以恢复回复。</p>
|
|||
|
|
<button class="meta-btn meta-btn-primary" @click="account = null">重新连接</button>
|
|||
|
|
</div>
|
|||
|
|
<p class="meta-intro">
|
|||
|
|
<strong>{{ account.display_name }}</strong> 已连接。
|
|||
|
|
</p>
|
|||
|
|
<MetaOwnAppWebhook v-if="account.webhook_url && account.webhook_verify_token" :account="account" />
|
|||
|
|
<p class="meta-intro">选择由哪个 AI 智能体应答其消息:</p>
|
|||
|
|
<label class="meta-label" for="meta-agent">AI 智能体</label>
|
|||
|
|
<select id="meta-agent" v-model="selectedAgentId" class="meta-input">
|
|||
|
|
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
|
|||
|
|
{{ agent.display_name || agent.name }}
|
|||
|
|
</option>
|
|||
|
|
</select>
|
|||
|
|
<div class="meta-actions">
|
|||
|
|
<button class="meta-btn meta-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
|
|||
|
|
<button class="meta-btn meta-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
|
|||
|
|
{{ savingAgent ? '保存中…' : '分配智能体' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.meta-modal {
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.5);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 1000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-modal-content {
|
|||
|
|
background: var(--background-color);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: var(--radius-lg, 12px);
|
|||
|
|
width: min(460px, calc(100vw - 32px));
|
|||
|
|
padding: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-modal-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-modal-header h3 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-close-btn {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
font-size: 22px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-reconnect {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
margin: 0 0 16px;
|
|||
|
|
padding: 12px;
|
|||
|
|
border-radius: var(--radius-md, 8px);
|
|||
|
|
background: var(--error-bg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-reconnect-text {
|
|||
|
|
margin: 0;
|
|||
|
|
flex: 1;
|
|||
|
|
color: var(--error-color);
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-intro {
|
|||
|
|
margin: 0 0 16px;
|
|||
|
|
color: var(--muted);
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-intro-link {
|
|||
|
|
/* --accent-ink, not --accent-solid: the latter is the lime fill and stays
|
|||
|
|
lime in both themes, which is unreadable as text on a light background. */
|
|||
|
|
color: var(--accent-ink);
|
|||
|
|
text-decoration: underline;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-intro-link:hover {
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-field {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-label {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-input {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: var(--radius-btn, 8px);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
color: inherit;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-actions {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-top: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-btn {
|
|||
|
|
padding: 9px 16px;
|
|||
|
|
border-radius: var(--radius-btn, 8px);
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
background: var(--background-soft);
|
|||
|
|
color: inherit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-btn-primary {
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
border-color: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-btn:disabled {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-signup {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: stretch;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-signup-btn {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-link-btn {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--muted);
|
|||
|
|
font-size: 13px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
text-decoration: underline;
|
|||
|
|
padding: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.meta-link-btn:hover {
|
|||
|
|
color: inherit;
|
|||
|
|
}
|
|||
|
|
</style>
|