feat: prefill ticket resolution from linked solutions

This commit is contained in:
hailin
2026-07-03 16:46:16 +08:00
parent 6132d383ae
commit 0c1cf30566
4 changed files with 62 additions and 13 deletions

View File

@@ -16,10 +16,12 @@ const LINK_TYPE_LABELS: Record<string, string> = {
export function SolutionLinkPanel({
ticketId,
onTicketChanged
onTicketChanged,
onLinkedSolutionsChange
}: {
ticketId: number
onTicketChanged?: () => void
onLinkedSolutionsChange?: (solutions: TicketSolution[]) => void
}) {
const [solutions, setSolutions] = React.useState<Solution[]>([])
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
@@ -29,7 +31,15 @@ export function SolutionLinkPanel({
function refresh() {
listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([]))
listTicketSolutions(ticketId).then(setLinkedSolutions).catch(() => setLinkedSolutions([]))
listTicketSolutions(ticketId)
.then((links) => {
setLinkedSolutions(links)
onLinkedSolutionsChange?.(links)
})
.catch(() => {
setLinkedSolutions([])
onLinkedSolutionsChange?.([])
})
}
React.useEffect(() => {

View File

@@ -0,0 +1,14 @@
import type { TicketSolution } from './types'
const LINK_TYPE_WEIGHT: Record<string, number> = {
adopted: 2,
referenced: 1
}
export function selectDefaultTicketSolution(links: TicketSolution[]) {
return [...links].sort((left, right) => {
const weightDiff = (LINK_TYPE_WEIGHT[right.link_type] ?? 0) - (LINK_TYPE_WEIGHT[left.link_type] ?? 0)
if (weightDiff !== 0) return weightDiff
return new Date(right.created_at).getTime() - new Date(left.created_at).getTime()
})[0]
}

View File

@@ -13,6 +13,8 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
import { selectDefaultTicketSolution } from '@/features/solutions/solutionSelection'
import type { TicketSolution } from '@/features/solutions/types'
import { formatDateTime } from '@/lib/format'
import { getTicket, listTicketEvents, transitionTicket } from './api'
@@ -64,6 +66,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const [targetActionLabel, setTargetActionLabel] = React.useState('')
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
const [transitionError, setTransitionError] = React.useState('')
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
React.useEffect(() => {
refreshTicket()
@@ -127,6 +130,15 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
}
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status)
const defaultLinkedSolution = selectDefaultTicketSolution(linkedSolutions)
const transitionDefaultResolution =
targetStatus === 'resolved' || targetStatus === 'closed'
? ticket.resolution || defaultLinkedSolution?.solution.solution || ''
: ''
const transitionDefaultRemark =
targetStatus === 'closed' && transitionDefaultResolution && !ticket.resolution
? '根据关联解决方案关闭工单'
: ''
return (
<AppPage maxWidth="6xl">
@@ -195,6 +207,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
<TicketMetaPanel ticket={ticket} />
<SolutionLinkPanel
ticketId={ticket.id}
onLinkedSolutionsChange={setLinkedSolutions}
onTicketChanged={() => {
refreshTicket()
refreshEvents()
@@ -254,6 +267,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
open={transitionDialogOpen}
status={targetStatus}
actionLabel={targetActionLabel}
defaultResolution={transitionDefaultResolution}
defaultRemark={transitionDefaultRemark}
onOpenChange={setTransitionDialogOpen}
onSubmit={handleTransition}
/>

View File

@@ -26,6 +26,8 @@ type TicketTransitionDialogProps = {
open: boolean
status: TicketStatus | null
actionLabel?: string
defaultResolution?: string
defaultRemark?: string
onOpenChange: (open: boolean) => void
onSubmit: (
status: TicketStatus,
@@ -42,6 +44,8 @@ export function TicketTransitionDialog({
open,
status,
actionLabel: customActionLabel,
defaultResolution = '',
defaultRemark = '',
onOpenChange,
onSubmit
}: TicketTransitionDialogProps) {
@@ -51,16 +55,22 @@ export function TicketTransitionDialog({
const [submitting, setSubmitting] = React.useState(false)
const actionLabel = customActionLabel ?? (status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态')
const isResolveAction = status === 'resolved'
const isOutcomeAction = status === 'resolved' || status === 'closed'
const canSubmit = isResolveAction ? Boolean((resolution || remark).trim()) : Boolean(remark.trim())
React.useEffect(() => {
if (!open) {
setRemark('')
setResolution('')
if (open) {
setRemark(defaultRemark)
setResolution(defaultResolution)
setRepliedToCustomer(true)
setSubmitting(false)
return
}
}, [open])
setRemark('')
setResolution('')
setRepliedToCustomer(true)
setSubmitting(false)
}, [defaultRemark, defaultResolution, open])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
@@ -86,14 +96,14 @@ export function TicketTransitionDialog({
<DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription>
{isResolveAction ? '填写本次处理结论,系统会同步记录到工单详情和流转时间线。' : '请说明本次流转的原因、处理动作或待确认事项。'}
{isOutcomeAction ? '填写本次处理结论,系统会同步记录到工单详情和流转时间线。' : '请说明本次流转的原因、处理动作或待确认事项。'}
</DialogDescription>
</DialogHeader>
{isResolveAction ? (
<AppField label="解决方案" htmlFor="transition-resolution" required>
{isOutcomeAction ? (
<AppField label="解决方案" htmlFor="transition-resolution" required={isResolveAction}>
<Textarea
id="transition-resolution"
required
required={isResolveAction}
minLength={2}
placeholder="例如:已查询采集进度,当前已完成 80%,预计今日完成。"
value={resolution}
@@ -101,17 +111,17 @@ export function TicketTransitionDialog({
/>
</AppField>
) : null}
<AppField label={isResolveAction ? '处理说明' : '处理说明'} htmlFor="transition-remark" required={!isResolveAction}>
<AppField label="处理说明" htmlFor="transition-remark" required={!isResolveAction}>
<Textarea
id="transition-remark"
required={!isResolveAction}
minLength={2}
placeholder={isResolveAction ? '可补充内部排查说明。' : '例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。'}
placeholder={isOutcomeAction ? '可补充内部排查说明。' : '例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。'}
value={remark}
onChange={(event) => setRemark(event.target.value)}
/>
</AppField>
{isResolveAction ? (
{isOutcomeAction ? (
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div className="grid gap-1">
<span className="text-sm font-medium text-foreground">已回复客户</span>