352 lines
11 KiB
Vue
352 lines
11 KiB
Vue
<!--
|
||
Copyright 2024-2026 ChatterMate
|
||
|
||
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">
|
||
/**
|
||
* Start a WhatsApp conversation with a phone number.
|
||
*
|
||
* One shared modal behind both entry points — the Conversations "New
|
||
* conversation" button and the People drawer's "Message on WhatsApp" — so
|
||
* template choice and variable filling can never diverge between them.
|
||
*/
|
||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||
import { toast } from 'vue-sonner'
|
||
import channelsService, { type ChannelAccount } from '@/services/channels'
|
||
import { peopleService } from '@/services/people'
|
||
import type { PersonListItem } from '@/types/people'
|
||
import { DEFAULT_LANGUAGE } from '@/utils/whatsappLanguages'
|
||
import BaseModal from '@/components/common/BaseModal.vue'
|
||
import WhatsAppTemplateSelect, {
|
||
type TemplateSelection,
|
||
} from '@/components/conversations/WhatsAppTemplateSelect.vue'
|
||
|
||
const props = defineProps<{
|
||
accounts: ChannelAccount[]
|
||
/** Pre-selected person (the People-drawer entry point). */
|
||
person?: { id: string; name?: string | null; phone?: string | null } | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'close'): void
|
||
(e: 'started', sessionId: string): void
|
||
}>()
|
||
|
||
// Outbound = business-initiated: Meta only reliably delivers Utility and
|
||
// Authentication templates to someone who hasn't messaged us; Marketing also
|
||
// carries per-user caps. The server enforces the same rule.
|
||
const OUTBOUND_CATEGORIES = ['UTILITY', 'AUTHENTICATION']
|
||
|
||
const accountId = ref(props.accounts[0]?.id ?? '')
|
||
const to = ref(props.person?.phone ?? '')
|
||
const name = ref('')
|
||
const selection = ref<TemplateSelection | null>(null)
|
||
const sending = ref(false)
|
||
|
||
/** The person this number belongs to, when known — prevents a duplicate row. */
|
||
const pickedPerson = ref<{ id: string; label: string } | null>(
|
||
props.person ? { id: props.person.id, label: props.person.name || props.person.phone || '' } : null,
|
||
)
|
||
// The phone the pick was based on. Editing away from it unlinks — but a
|
||
// person picked WITHOUT a stored phone stays linked to whatever is typed:
|
||
// that's the drawer's "message this person at a number you enter" case.
|
||
let pickedPhone: string | null = props.person?.phone ?? null
|
||
|
||
/**
|
||
* Autocomplete against People. Degrades silently to a plain phone input where
|
||
* People isn't available (non-Pro plan or missing permission): the feature is
|
||
* a convenience, not a requirement, and a 403 must not break outbound.
|
||
*/
|
||
const suggestions = ref<PersonListItem[]>([])
|
||
const peopleAvailable = ref(true)
|
||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||
/** Guards against out-of-order responses — see searchPeople. */
|
||
let searchToken = 0
|
||
|
||
const searchPeople = async () => {
|
||
const term = to.value.trim()
|
||
if (!peopleAvailable.value || pickedPerson.value || term.length < 3) {
|
||
suggestions.value = []
|
||
return
|
||
}
|
||
// The debounce limits how OFTEN we ask, not what order the answers arrive in:
|
||
// pause mid-number and two requests are in flight. A broader prefix scans more
|
||
// rows, so it tends to resolve LAST and overwrite the narrower result — the
|
||
// agent then picks from suggestions for a number they have already moved past.
|
||
const token = ++searchToken
|
||
try {
|
||
const result = await peopleService.listPeople({ search: term, page_size: 5 })
|
||
if (token !== searchToken) return
|
||
suggestions.value = result.items.filter((p) => p.phone || p.name)
|
||
} catch (error: any) {
|
||
if (token !== searchToken) return
|
||
// Only a 403 means People genuinely isn't available to this user (non-Pro
|
||
// plan or missing permission) and is worth latching off. Latching on ANY
|
||
// error let a single network blip kill autocomplete for the rest of the
|
||
// modal's life — and a silently dead autocomplete invites exactly the
|
||
// duplicate person that picking someone exists to prevent.
|
||
if (error?.response?.status === 403) peopleAvailable.value = false
|
||
suggestions.value = []
|
||
}
|
||
}
|
||
|
||
watch(to, () => {
|
||
// Typing away from the picked person's own number un-picks them.
|
||
if (pickedPerson.value && pickedPhone && to.value !== pickedPhone) {
|
||
pickedPerson.value = null
|
||
pickedPhone = null
|
||
}
|
||
clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(searchPeople, 300)
|
||
})
|
||
|
||
const pick = (person: PersonListItem) => {
|
||
// Falls back to the phone like the props path does: searchPeople admits
|
||
// anyone with a phone OR a name, so a phone-only person reaches here and
|
||
// would otherwise render "Sending to" followed by nothing.
|
||
pickedPerson.value = {
|
||
id: person.id,
|
||
label: person.name || person.email || person.phone || '该联系人',
|
||
}
|
||
pickedPhone = person.phone ?? null
|
||
if (person.phone) to.value = person.phone
|
||
suggestions.value = []
|
||
}
|
||
|
||
const unpick = () => {
|
||
pickedPerson.value = null
|
||
pickedPhone = null
|
||
}
|
||
|
||
// Client-side mirror of the server's normalize_phone: '+' then 8-15 digits,
|
||
// decoration forgiven. The server remains the authority.
|
||
const phoneLooksValid = computed(() =>
|
||
/^\+[1-9]\d{7,14}$/.test(to.value.replace(/[\s\-().]/g, '')),
|
||
)
|
||
|
||
const canSend = computed(
|
||
() => phoneLooksValid.value && !!selection.value?.complete && !sending.value,
|
||
)
|
||
|
||
const send = async () => {
|
||
if (!canSend.value || !selection.value) return
|
||
const { template, components } = selection.value
|
||
try {
|
||
sending.value = true
|
||
const result = await channelsService.startWhatsAppConversation(accountId.value, {
|
||
to: to.value,
|
||
template_name: template.name,
|
||
language: template.language || DEFAULT_LANGUAGE,
|
||
components,
|
||
customer_id: pickedPerson.value?.id,
|
||
customer_name: name.value.trim() || undefined,
|
||
})
|
||
toast.success('消息已发送', {
|
||
description: '会话已进入收件箱,对方回复后将由 AI 客服应答。',
|
||
})
|
||
emit('started', result.session_id)
|
||
} catch (error: any) {
|
||
toast.error('无法发起会话', {
|
||
description: error?.response?.data?.detail || '请重试',
|
||
closeButton: true,
|
||
})
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(() => clearTimeout(searchTimer))
|
||
</script>
|
||
|
||
<template>
|
||
<BaseModal title="新建 WhatsApp 会话" width="560px" @close="emit('close')">
|
||
<!-- Meta's opt-in policy is the operator's obligation; state it, don't police it -->
|
||
<p class="nwc-intro">
|
||
只能联系已同意在 WhatsApp 接收消息的人,否则消息被举报后 Meta 会封禁企业账号。
|
||
</p>
|
||
|
||
<label v-if="accounts.length > 1" class="nwc-field">
|
||
<span class="nwc-label">发送号码</span>
|
||
<select v-model="accountId" class="nwc-input">
|
||
<option v-for="account in accounts" :key="account.id" :value="account.id">
|
||
{{ account.display_name || account.external_account_id }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="nwc-field">
|
||
<span class="nwc-label">接收方</span>
|
||
<input
|
||
v-model="to"
|
||
class="nwc-input"
|
||
placeholder="+91 12345 67890"
|
||
autocomplete="off"
|
||
:aria-label="peopleAvailable ? '电话号码或搜索联系人' : '电话号码'"
|
||
/>
|
||
<span v-if="pickedPerson" class="nwc-picked">
|
||
发送至 <strong>{{ pickedPerson.label }}</strong>
|
||
<button type="button" class="nwc-unpick" aria-label="不是此人" @click="unpick">×</button>
|
||
</span>
|
||
<span v-else-if="to && !phoneLooksValid" class="nwc-hint">
|
||
请使用带国家代码的国际格式,例如 +91…
|
||
</span>
|
||
<!-- A plain list of buttons, deliberately NOT role="listbox". The
|
||
children are real buttons: Tab reaches each one and Enter picks it.
|
||
Claiming listbox told screen readers to expect role="option"
|
||
children and announced "listbox, 0 options" over working controls —
|
||
worse than the honest markup, and the full combobox pattern
|
||
(aria-expanded/controls/activedescendant plus arrow-key handling)
|
||
is more than a field that degrades to a plain phone input needs. -->
|
||
<ul v-if="suggestions.length" class="nwc-suggestions" aria-label="匹配的联系人">
|
||
<li v-for="person in suggestions" :key="person.id">
|
||
<button type="button" class="nwc-suggestion" @click="pick(person)">
|
||
<span class="nwc-suggestion-name">{{ person.name || person.email }}</span>
|
||
<span v-if="person.phone" class="nwc-suggestion-phone">{{ person.phone }}</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<label v-if="!pickedPerson" class="nwc-field">
|
||
<span class="nwc-label">姓名(选填)</span>
|
||
<input v-model="name" class="nwc-input" placeholder="例如:张伟" autocomplete="off" />
|
||
<span class="nwc-hint">仅当该号码尚未存在于联系人中时使用。</span>
|
||
</label>
|
||
|
||
<div class="nwc-field">
|
||
<span class="nwc-label">模板</span>
|
||
<!-- Key: switching the sending number reloads that account's templates -->
|
||
<WhatsAppTemplateSelect
|
||
:key="accountId"
|
||
v-model:selection="selection"
|
||
:account-id="accountId"
|
||
:categories="OUTBOUND_CATEGORIES"
|
||
empty-hint="主动发起的会话需要已审核的工具类(Utility)或身份验证类(Authentication)模板。可在「设置 → 集成」的 WhatsApp 卡片中创建。"
|
||
/>
|
||
</div>
|
||
|
||
<template #actions>
|
||
<button class="modal-btn" @click="emit('close')">取消</button>
|
||
<button
|
||
class="modal-btn modal-btn-primary"
|
||
:disabled="!canSend"
|
||
:aria-busy="sending"
|
||
@click="send"
|
||
>
|
||
<font-awesome-icon v-if="sending" icon="fa-solid fa-spinner" spin />
|
||
{{ sending ? '发送中…' : '发送并开启会话' }}
|
||
</button>
|
||
</template>
|
||
</BaseModal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
.nwc-intro {
|
||
margin: 0 0 16px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.nwc-field {
|
||
display: block;
|
||
margin-bottom: 14px;
|
||
position: relative;
|
||
}
|
||
|
||
.nwc-label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.nwc-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;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.nwc-hint,
|
||
.nwc-picked {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.nwc-suggestions {
|
||
list-style: none;
|
||
margin: 4px 0 0;
|
||
padding: 4px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-btn, 8px);
|
||
background: var(--background-color);
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 10;
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.nwc-suggestion {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border: none;
|
||
background: none;
|
||
color: inherit;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.nwc-suggestion:hover {
|
||
background: var(--o08);
|
||
}
|
||
|
||
.nwc-unpick {
|
||
border: none;
|
||
background: none;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.nwc-suggestion-phone {
|
||
color: var(--muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
|
||
|
||
|
||
</style>
|