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({
|
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(() => {
|
||||||
|
|||||||
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 { 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user