feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,425 @@
|
||||
<!--
|
||||
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 { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
import { peopleService } from '@/services/people'
|
||||
import type { PersonListItem } from '@/types/people'
|
||||
import type { TicketDetail, TicketPriority } from '@/types/ticket'
|
||||
import { PRIORITIES, priorityMeta } from './ticketMeta'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
// Present when creating from a conversation: enables the AI-drafted prefill.
|
||||
sessionId?: string | null
|
||||
sessionLabel?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'created', detail: TicketDetail): void
|
||||
}>()
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const priority = ref<TicketPriority>('medium')
|
||||
const fromConversation = ref(false)
|
||||
const isDrafting = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// Manual tickets have no conversation to deliver through — collecting an
|
||||
// email gives notifications a direct-email path. Suggestions come from the
|
||||
// org's identified People.
|
||||
const customerEmail = ref('')
|
||||
const customerName = ref('')
|
||||
const customerSuggestions = ref<PersonListItem[]>([])
|
||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(customerEmail, (query) => {
|
||||
if (suggestTimer) clearTimeout(suggestTimer)
|
||||
const term = query.trim()
|
||||
if (term.length < 2) {
|
||||
customerSuggestions.value = []
|
||||
return
|
||||
}
|
||||
const matched = customerSuggestions.value.find((p) => p.email === term)
|
||||
if (matched && !customerName.value) customerName.value = matched.name || ''
|
||||
suggestTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await peopleService.listPeople({ search: term, page_size: 8 })
|
||||
customerSuggestions.value = response.items.filter((p) => p.email)
|
||||
} catch {
|
||||
customerSuggestions.value = []
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (suggestTimer) clearTimeout(suggestTimer)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
title.value = ''
|
||||
description.value = ''
|
||||
priority.value = 'medium'
|
||||
fromConversation.value = false
|
||||
customerEmail.value = ''
|
||||
customerName.value = ''
|
||||
customerSuggestions.value = []
|
||||
if (props.sessionId) useConversationDraft()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function useConversationDraft() {
|
||||
if (!props.sessionId) return
|
||||
fromConversation.value = true
|
||||
isDrafting.value = true
|
||||
try {
|
||||
const draft = await ticketService.draftFromSession(props.sessionId)
|
||||
if (!title.value) title.value = draft.title
|
||||
if (!description.value) description.value = draft.description
|
||||
} catch {
|
||||
// Draft is best-effort; the form still works blank.
|
||||
} finally {
|
||||
isDrafting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!title.value.trim() || isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const linkSession = fromConversation.value && props.sessionId
|
||||
const detail = await ticketService.createTicket({
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
priority: priority.value,
|
||||
session_id: linkSession ? props.sessionId! : undefined,
|
||||
customer_email: !linkSession ? customerEmail.value.trim() || undefined : undefined,
|
||||
customer_name: !linkSession ? customerName.value.trim() || undefined : undefined,
|
||||
})
|
||||
toast.success(`工单 ${detail.ticket.display_number} 已创建`)
|
||||
emit('created', detail)
|
||||
emit('close')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '创建工单失败')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-overlay" @click="emit('close')">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">新建工单</div>
|
||||
<button class="close-btn" @click="emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="sessionId" class="source-toggle">
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: !fromConversation }"
|
||||
@click="fromConversation = false"
|
||||
>
|
||||
空白
|
||||
</button>
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: fromConversation }"
|
||||
@click="useConversationDraft"
|
||||
>
|
||||
来自会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="fromConversation" class="draft-note">
|
||||
<span class="draft-glyph">✎</span>
|
||||
<span v-if="isDrafting">正在根据会话生成草稿…</span>
|
||||
<span v-else>
|
||||
已根据
|
||||
<span class="mono">{{ sessionLabel || '该会话' }}</span>
|
||||
生成草稿——可随意修改。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="field-label">主题</label>
|
||||
<input
|
||||
v-model="title"
|
||||
class="field-input"
|
||||
placeholder="简要描述问题…"
|
||||
maxlength="500"
|
||||
/>
|
||||
|
||||
<label class="field-label">描述</label>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="field-textarea"
|
||||
placeholder="发生了什么?请说明报错、对客户的影响和复现步骤…"
|
||||
></textarea>
|
||||
|
||||
<template v-if="!fromConversation">
|
||||
<label class="field-label">客户邮箱 <span class="optional">(选填,用于通知客户)</span></label>
|
||||
<input
|
||||
v-model="customerEmail"
|
||||
class="field-input"
|
||||
type="email"
|
||||
list="ticket-customer-suggestions"
|
||||
placeholder="customer@company.com"
|
||||
maxlength="320"
|
||||
/>
|
||||
<datalist id="ticket-customer-suggestions">
|
||||
<option
|
||||
v-for="person in customerSuggestions"
|
||||
:key="person.id"
|
||||
:value="person.email || ''"
|
||||
>
|
||||
{{ person.name || person.email }}
|
||||
</option>
|
||||
</datalist>
|
||||
|
||||
<template v-if="customerEmail.trim()">
|
||||
<label class="field-label">客户名称 <span class="optional">(选填)</span></label>
|
||||
<input
|
||||
v-model="customerName"
|
||||
class="field-input"
|
||||
placeholder="Jane Doe"
|
||||
maxlength="200"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<label class="field-label">优先级</label>
|
||||
<div class="priority-pills">
|
||||
<button
|
||||
v-for="p in PRIORITIES"
|
||||
:key="p"
|
||||
class="priority-pill"
|
||||
:class="{ active: priority === p }"
|
||||
:style="{ '--pill-color': priorityMeta(p).color }"
|
||||
@click="priority = p"
|
||||
>
|
||||
<span class="dot"></span>{{ priorityMeta(p).label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="emit('close')">取消</button>
|
||||
<button class="btn-primary" :disabled="!title.trim() || isSubmitting" @click="submit">
|
||||
{{ isSubmitting ? '创建中…' : '创建工单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim, rgba(4, 5, 8, 0.62));
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 20px 20px;
|
||||
}
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
}
|
||||
.modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
}
|
||||
.close-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 22px;
|
||||
}
|
||||
.source-toggle {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.source-option {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.source-option.active {
|
||||
background: var(--o08);
|
||||
color: var(--text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.draft-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 13px;
|
||||
background: var(--accent-bg-08);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 11px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.draft-glyph {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.optional {
|
||||
color: var(--muted2);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
.field-input,
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
outline: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field-textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.priority-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.priority-pill {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--o10);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
.priority-pill .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--pill-color);
|
||||
}
|
||||
.priority-pill.active {
|
||||
border-color: var(--pill-color);
|
||||
color: var(--pill-color);
|
||||
background: var(--o05);
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--o07);
|
||||
background: var(--surface);
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 9px 17px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--text);
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 9px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user