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

426 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 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>