feat: prefill ticket resolution from linked solutions
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
14
frontend/src/features/solutions/solutionSelection.ts
Normal file
14
frontend/src/features/solutions/solutionSelection.ts
Normal 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]
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user