feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

@@ -0,0 +1,402 @@
<!--
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>