Files
replypier/frontend/src/components/integrations/WhatsAppTemplateManager.vue
T
WangShen007 83a16ed07d feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
2026-10-17 22:05:00 +08:00

416 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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">
import { ref, watch, onMounted } from 'vue'
import channelsService, {
type ChannelAccount,
type WhatsAppTemplate,
} from '@/services/channels'
import BaseModal from '@/components/common/BaseModal.vue'
import { templatePreviewText, templateKey } from '@/utils/whatsappTemplates'
import { languageLabel } from '@/utils/whatsappLanguages'
const props = defineProps<{
accounts: ChannelAccount[]
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
const accountId = ref(props.accounts[0]?.id ?? '')
const templates = ref<WhatsAppTemplate[]>([])
const loading = ref(true)
const loadError = ref('')
/**
* Templates are written in Meta's Template Library, not here — it has ~150
* pre-written, pre-localised ones built to pass its own review.
*
* The link is fetched rather than built: it is scoped to this number's Business
* Account, and only the server can resolve which that is. It is fetched up
* front so the anchor is a real href by the time it is clicked — resolving it
* on click, after an await, would hand the browser a popup to block.
*/
const libraryUrl = ref('')
// Guards against a slow response for a previously selected number landing after
// a newer one and rendering its templates under the wrong account.
let loadToken = 0
// The link's own token: it is keyed to the account, not the list, so the two
// reload on different schedules and cannot invalidate each other.
let libraryToken = 0
const load = async () => {
const token = ++loadToken
loadError.value = ''
if (!accountId.value) {
templates.value = []
loading.value = false
return
}
loading.value = true
try {
const result = await channelsService.listWhatsAppTemplates(accountId.value)
if (token !== loadToken) return
templates.value = result
} catch (error: any) {
if (token !== loadToken) return
loadError.value = error?.response?.data?.detail || '无法加载模板'
templates.value = []
} finally {
if (token === loadToken) loading.value = false
}
}
/**
* Fetched alongside the list but deliberately not awaited with it: this costs a
* Graph call, and failing it must not take the list down with it. Without a URL
* the link simply doesn't render.
*
* Only ever called for a change of account. The link is derived from the
* account's Business Account, so re-resolving it when the list reloads would
* spend a Graph round trip to learn what we already know — and the list
* reloads on every "refresh" click.
*/
const loadLibraryUrl = async () => {
const token = ++libraryToken
libraryUrl.value = ''
if (!accountId.value) return
try {
const url = await channelsService.getWhatsAppTemplateLibraryUrl(accountId.value)
if (token === libraryToken) libraryUrl.value = url
} catch {
// Left empty: the list is still useful, and there is nothing to act on.
}
}
onMounted(() => {
load()
loadLibraryUrl()
})
watch(accountId, () => {
load()
loadLibraryUrl()
})
</script>
<template>
<BaseModal title="WhatsApp 模板" width="620px" @close="emit('close')">
<p class="wtm-intro">
客户 24 小时会话窗口关闭后,可通过模板重新发起会话。模板需在 WhatsApp Manager 中编写;
经 Meta 审核通过后会显示在此处,客服即可发送。
</p>
<label v-if="accounts.length > 1" class="wtm-field">
<span class="wtm-label">号码</span>
<select v-model="accountId" class="wtm-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="wtm-body">
<div v-if="loading" class="wtm-skeleton" aria-live="polite" aria-busy="true">
<div v-for="n in 3" :key="n" class="wtm-skeleton-row"></div>
</div>
<div v-else-if="loadError" class="wtm-empty">
<font-awesome-icon icon="fa-solid fa-circle-exclamation" />
<p>{{ loadError }}</p>
</div>
<div v-else-if="templates.length === 0" class="wtm-empty">
<p>暂无模板。</p>
</div>
<ul v-else class="wtm-list">
<!-- Keyed on name+language: one name can have a row per language, and
Vue needs them distinct. -->
<li v-for="template in templates" :key="templateKey(template)" class="wtm-row">
<div class="wtm-row-main">
<div class="wtm-row-head">
<span class="wtm-name">{{ template.name }}</span>
<span v-if="template.status" class="wtm-pill" :class="template.status.toLowerCase()">
{{ template.status }}
</span>
</div>
<div class="wtm-row-meta">
<span v-if="template.category">{{ template.category }}</span>
<span v-if="template.language">· {{ languageLabel(template.language) }}</span>
</div>
<p class="wtm-row-body">{{ templatePreviewText(template) }}</p>
</div>
</li>
</ul>
</div>
<!-- Meta's Template Library is where these get written: ~150 pre-written,
pre-localised templates, already shaped to pass its review. -->
<div v-if="accountId && !loading" class="wtm-create-guide">
<h4 class="wtm-guide-title">添加模板</h4>
<ol class="wtm-steps">
<li>打开 WhatsApp Manager,进入<strong>模板库</strong>。</li>
<li>选择现成模板,或选择<strong>创建模板</strong>自行编写。</li>
<li>
待 Meta 审核通过后,返回此处并点击<button type="button" class="wtm-link" @click="load">刷新</button>。
</li>
</ol>
<a
v-if="libraryUrl"
class="wtm-btn wtm-btn-primary wtm-guide-link"
:href="libraryUrl"
target="_blank"
rel="noopener noreferrer"
>
打开 WhatsApp Manager
<font-awesome-icon icon="fa-solid fa-arrow-up-right-from-square" />
</a>
</div>
</BaseModal>
</template>
<style scoped>
.wtm-intro {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.wtm-body {
flex: 1;
}
.wtm-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wtm-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
}
.wtm-row-main {
min-width: 0;
}
.wtm-row-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wtm-name {
font-weight: 600;
font-size: 14px;
}
/* Neutral by default: Meta adds statuses over time (PENDING_DELETION, IN_APPEAL,
FLAGGED, ...) and an unrecognised one must still read as a pill, not vanish. */
.wtm-pill {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--c-neutral) 16%, transparent);
color: var(--c-neutral);
border: 1px solid color-mix(in srgb, var(--c-neutral) 35%, transparent);
}
.wtm-pill.approved {
background: color-mix(in srgb, var(--c-positive) 16%, transparent);
color: var(--c-positive);
border-color: color-mix(in srgb, var(--c-positive) 35%, transparent);
}
.wtm-pill.pending {
background: color-mix(in srgb, var(--c-warn) 16%, transparent);
color: var(--c-warn);
border-color: color-mix(in srgb, var(--c-warn) 35%, transparent);
}
/* Rejected, paused and disabled all mean "cannot send" */
.wtm-pill.rejected,
.wtm-pill.paused,
.wtm-pill.disabled {
background: color-mix(in srgb, var(--c-danger) 16%, transparent);
color: var(--c-danger);
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
}
.wtm-row-meta {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.wtm-row-body {
margin: 6px 0 0;
font-size: 13px;
color: var(--muted);
line-height: 1.5;
word-break: break-word;
}
.wtm-field {
display: block;
margin-bottom: 12px;
}
.wtm-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.wtm-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;
}
.wtm-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Mirrors .wtm-row, the row it stands in for: a real row is legible because of
its border, not its fill (--background-soft is #0E0F14 against a #0B0C10
modal — a contrast ratio of 1.02). Without the border this was rendering
perfectly and showing nothing, so the modal just looked blank until the
fetch returned. */
.wtm-skeleton-row {
height: 64px;
border-radius: var(--radius-btn, 8px);
border: 1px solid var(--border-color);
background: var(--o08);
animation: wtm-pulse 1.4s ease-in-out infinite;
}
/* Only down to 0.6: at 0.5 the trough fell back below the threshold where the
row reads as being there at all. */
@keyframes wtm-pulse {
50% {
opacity: 0.6;
}
}
@media (prefers-reduced-motion: reduce) {
.wtm-skeleton-row {
animation: none;
}
}
.wtm-empty {
text-align: center;
padding: 24px 8px;
color: var(--muted);
font-size: 14px;
}
.wtm-empty p {
margin: 0 0 12px;
}
.wtm-create-guide {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.wtm-guide-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
.wtm-steps {
margin: 0 0 12px;
padding-left: 20px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
/* A button, because it acts on this page — styled as the inline link it reads as */
.wtm-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--c-info);
cursor: pointer;
text-decoration: underline;
}
.wtm-guide-link {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.wtm-btn {
padding: 8px 14px;
border-radius: var(--radius-btn, 8px);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--background-soft);
color: inherit;
}
.wtm-btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-color: transparent;
}
</style>