feat: adopt solution and close ticket

This commit is contained in:
hailin
2026-07-03 16:36:46 +08:00
parent 40ab7ca548
commit 6132d383ae
7 changed files with 325 additions and 10 deletions

View File

@@ -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>
))}

View File

@@ -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: '采纳解决方案并关闭工单'
})
})
}

View File

@@ -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">