feat: adopt solution and close ticket
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react'
|
||||
import { Search, Sparkles } from 'lucide-react'
|
||||
|
||||
import { AppSection } from '@/components/app/AppSection'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { linkSolutionToTicket, listSolutions, listTicketSolutions } from './api'
|
||||
import { adoptSolutionAndCloseTicket, linkSolutionToTicket, listSolutions, listTicketSolutions } from './api'
|
||||
import type { Solution, TicketSolution } from './types'
|
||||
|
||||
const LINK_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -12,10 +14,18 @@ const LINK_TYPE_LABELS: Record<string, string> = {
|
||||
adopted: '已采纳'
|
||||
}
|
||||
|
||||
export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
||||
export function SolutionLinkPanel({
|
||||
ticketId,
|
||||
onTicketChanged
|
||||
}: {
|
||||
ticketId: number
|
||||
onTicketChanged?: () => void
|
||||
}) {
|
||||
const [solutions, setSolutions] = React.useState<Solution[]>([])
|
||||
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
|
||||
const [query, setQuery] = React.useState('')
|
||||
const [message, setMessage] = React.useState('')
|
||||
const [submittingSolutionId, setSubmittingSolutionId] = React.useState<number | null>(null)
|
||||
|
||||
function refresh() {
|
||||
listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([]))
|
||||
@@ -32,8 +42,28 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function handleAdoptAndClose(solution: Solution) {
|
||||
setSubmittingSolutionId(solution.id)
|
||||
try {
|
||||
await adoptSolutionAndCloseTicket(solution.id, ticketId)
|
||||
setMessage(`已采纳「${solution.title}」并关闭工单`)
|
||||
refresh()
|
||||
onTicketChanged?.()
|
||||
} finally {
|
||||
setSubmittingSolutionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const linkedSolutionIds = new Set(linkedSolutions.map((link) => link.solution_id))
|
||||
const availableSolutions = solutions.filter((solution) => !linkedSolutionIds.has(solution.id))
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const availableSolutions = solutions
|
||||
.filter((solution) => !linkedSolutionIds.has(solution.id))
|
||||
.filter((solution) => {
|
||||
if (!normalizedQuery) return true
|
||||
return `${solution.title} ${solution.problem_signature} ${solution.solution} ${solution.root_cause}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
return (
|
||||
<AppSection
|
||||
@@ -66,9 +96,25 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 border-t border-border pt-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="搜索方案标题、问题特征或解决方案"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<Sparkles className="size-4" />
|
||||
<span>自动匹配将在 AI 接入后基于问题描述、对象和历史方案推荐。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableSolutions.length > 0 ? (
|
||||
<div className="grid gap-2 border-t border-border pt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">可关联方案</p>
|
||||
<div className="grid gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">可用方案</p>
|
||||
{availableSolutions.map((solution) => (
|
||||
<div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}>
|
||||
<div>
|
||||
@@ -82,7 +128,13 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
||||
<Button size="sm" variant="secondary" onClick={() => void handleLink(solution, 'referenced')}>
|
||||
推荐方案
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleLink(solution, 'adopted')}>已采纳</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleAdoptAndClose(solution)}
|
||||
disabled={submittingSolutionId === solution.id}
|
||||
>
|
||||
{submittingSolutionId === solution.id ? '处理中...' : '采纳并关闭'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -26,3 +26,14 @@ export function linkSolutionToTicket(solutionId: number, ticketId: number, linkT
|
||||
body: JSON.stringify({ ticket_id: ticketId, link_type: linkType })
|
||||
})
|
||||
}
|
||||
|
||||
export function adoptSolutionAndCloseTicket(solutionId: number, ticketId: number) {
|
||||
return apiRequest<TicketSolutionLink>(`/solutions/${solutionId}/adopt-ticket`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
replied_to_customer: true,
|
||||
remark: '采纳解决方案并关闭工单'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,11 +66,15 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
const [transitionError, setTransitionError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshTicket()
|
||||
refreshEvents()
|
||||
}, [ticketId])
|
||||
|
||||
function refreshTicket() {
|
||||
getTicket(ticketId)
|
||||
.then(setTicket)
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||
refreshEvents()
|
||||
}, [ticketId])
|
||||
}
|
||||
|
||||
function refreshEvents() {
|
||||
listTicketEvents(ticketId).then(setEvents).catch(() => setEvents([]))
|
||||
@@ -189,7 +193,13 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
side={
|
||||
<>
|
||||
<TicketMetaPanel ticket={ticket} />
|
||||
<SolutionLinkPanel ticketId={ticket.id} />
|
||||
<SolutionLinkPanel
|
||||
ticketId={ticket.id}
|
||||
onTicketChanged={() => {
|
||||
refreshTicket()
|
||||
refreshEvents()
|
||||
}}
|
||||
/>
|
||||
<AppSection title="AI 支持分析" action={<Badge variant="ai">后续接入</Badge>}>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
|
||||
Reference in New Issue
Block a user