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({ export function SolutionLinkPanel({
ticketId, ticketId,
onTicketChanged onTicketChanged,
onLinkedSolutionsChange
}: { }: {
ticketId: number ticketId: number
onTicketChanged?: () => void onTicketChanged?: () => void
onLinkedSolutionsChange?: (solutions: TicketSolution[]) => void
}) { }) {
const [solutions, setSolutions] = React.useState<Solution[]>([]) const [solutions, setSolutions] = React.useState<Solution[]>([])
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([]) const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
@@ -29,7 +31,15 @@ export function SolutionLinkPanel({
function refresh() { function refresh() {
listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([])) 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(() => { 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 { Card, CardContent } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel' 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 { formatDateTime } from '@/lib/format'
import { getTicket, listTicketEvents, transitionTicket } from './api' import { getTicket, listTicketEvents, transitionTicket } from './api'
@@ -64,6 +66,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const [targetActionLabel, setTargetActionLabel] = React.useState('') const [targetActionLabel, setTargetActionLabel] = React.useState('')
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false) const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
const [transitionError, setTransitionError] = React.useState('') const [transitionError, setTransitionError] = React.useState('')
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
React.useEffect(() => { React.useEffect(() => {
refreshTicket() refreshTicket()
@@ -127,6 +130,15 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
} }
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status) 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 ( return (
<AppPage maxWidth="6xl"> <AppPage maxWidth="6xl">
@@ -195,6 +207,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
<TicketMetaPanel ticket={ticket} /> <TicketMetaPanel ticket={ticket} />
<SolutionLinkPanel <SolutionLinkPanel
ticketId={ticket.id} ticketId={ticket.id}
onLinkedSolutionsChange={setLinkedSolutions}
onTicketChanged={() => { onTicketChanged={() => {
refreshTicket() refreshTicket()
refreshEvents() refreshEvents()
@@ -254,6 +267,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
open={transitionDialogOpen} open={transitionDialogOpen}
status={targetStatus} status={targetStatus}
actionLabel={targetActionLabel} actionLabel={targetActionLabel}
defaultResolution={transitionDefaultResolution}
defaultRemark={transitionDefaultRemark}
onOpenChange={setTransitionDialogOpen} onOpenChange={setTransitionDialogOpen}
onSubmit={handleTransition} onSubmit={handleTransition}
/> />

View File

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