feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -0,0 +1,415 @@
|
||||
<!--
|
||||
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>
|
||||
Reference in new issue
Block a user