426 lines
11 KiB
Vue
426 lines
11 KiB
Vue
<!--
|
||
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>
|