refactor: enhance ticket detail workflow view
This commit is contained in:
@@ -7,8 +7,8 @@ type AppDetailLayoutProps = {
|
|||||||
|
|
||||||
export function AppDetailLayout({ children, side }: AppDetailLayoutProps) {
|
export function AppDetailLayout({ children, side }: AppDetailLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<div className="grid min-w-0 gap-4">{children}</div>
|
<div className="grid min-w-0 content-start gap-5">{children}</div>
|
||||||
<aside className="grid content-start gap-4">{side}</aside>
|
<aside className="grid content-start gap-4">{side}</aside>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Bot, CheckCircle2, CircleDot, Send, XCircle } from 'lucide-react'
|
import { Bot, CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||||
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
|
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
|
||||||
@@ -10,8 +10,10 @@ import { AppSection } from '@/components/app/AppSection'
|
|||||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
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 { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
||||||
import { TicketComments } from './TicketComments'
|
import { TicketComments } from './TicketComments'
|
||||||
@@ -37,6 +39,14 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
|
|||||||
closed: { label: '关闭工单', icon: XCircle }
|
closed: { label: '关闭工单', icon: XCircle }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_ACTION_HINTS: Partial<Record<TicketStatus, string>> = {
|
||||||
|
pending: '补充信息完成后进入支持队列。',
|
||||||
|
in_progress: '确认接手并开始技术排查。',
|
||||||
|
pending_confirm: '已给出处理结果,等待业务侧确认。',
|
||||||
|
resolved: '业务确认问题已解决,保留后续关闭入口。',
|
||||||
|
closed: '关闭代表流程结束,不再继续处理。'
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
||||||
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
||||||
return '取消工单'
|
return '取消工单'
|
||||||
@@ -44,6 +54,16 @@ function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketS
|
|||||||
return STATUS_ACTIONS[targetStatus]?.label ?? '调整状态'
|
return STATUS_ACTIONS[targetStatus]?.label ?? '调整状态'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayUser(user: Ticket['requester']) {
|
||||||
|
return user?.display_name || user?.username || '未设置'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionVariant(status: TicketStatus) {
|
||||||
|
if (status === 'closed') return 'outline'
|
||||||
|
if (status === 'resolved') return 'default'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||||
const [events, setEvents] = React.useState<TicketEvent[]>([])
|
const [events, setEvents] = React.useState<TicketEvent[]>([])
|
||||||
@@ -104,34 +124,65 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppPage maxWidth="6xl">
|
<AppPage maxWidth="6xl">
|
||||||
<AppPageHeader
|
<AppPageHeader
|
||||||
title={ticket.title}
|
title={ticket.title}
|
||||||
description={ticket.ticket_no}
|
description={ticket.ticket_no}
|
||||||
meta={
|
meta={
|
||||||
<>
|
<>
|
||||||
<AppStatusBadge status={ticket.status} />
|
<AppStatusBadge status={ticket.status} />
|
||||||
<AppPriorityBadge priority={ticket.priority} />
|
<AppPriorityBadge priority={ticket.priority} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||||
const action = STATUS_ACTIONS[status]
|
const action = STATUS_ACTIONS[status]
|
||||||
const Icon = action?.icon
|
const Icon = action?.icon
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={status}
|
key={status}
|
||||||
variant={status === 'closed' ? 'outline' : 'secondary'}
|
variant={getActionVariant(status)}
|
||||||
onClick={() => openTransition(status)}
|
onClick={() => openTransition(status)}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon data-icon="inline-start" /> : null}
|
{Icon ? <Icon data-icon="inline-start" /> : null}
|
||||||
{getStatusActionLabel(ticket.status, status)}
|
{getStatusActionLabel(ticket.status, status)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TicketOverviewCard ticket={ticket} />
|
||||||
|
{NEXT_STATUSES[ticket.status].length ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
|
||||||
|
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||||
|
const action = STATUS_ACTIONS[status]
|
||||||
|
const Icon = action?.icon ?? CircleDot
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
className="flex min-h-20 items-start gap-3 rounded-md border border-border bg-background p-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||||
|
onClick={() => openTransition(status)}
|
||||||
|
>
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-foreground">
|
||||||
|
{getStatusActionLabel(ticket.status, status)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||||
|
{STATUS_ACTION_HINTS[status] ?? '记录本次处理动作并推进流程。'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
<AppDetailLayout
|
<AppDetailLayout
|
||||||
side={
|
side={
|
||||||
<>
|
<>
|
||||||
@@ -167,3 +218,31 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
</AppPage>
|
</AppPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
||||||
|
const items = [
|
||||||
|
{ label: '请求人', value: displayUser(ticket.requester) },
|
||||||
|
{ label: '处理人', value: displayUser(ticket.assignee) },
|
||||||
|
{ label: '关联系统', value: ticket.related_system || '未填写' },
|
||||||
|
{ label: '更新时间', value: formatDateTime(ticket.updated_at) }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.label} className="min-w-0">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{item.label}</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-medium text-foreground">{item.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Clock3 className="size-4" />
|
||||||
|
创建于 {formatDateTime(ticket.created_at)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { CheckCircle2, CircleDot, MessageSquareText, Paperclip, PencilLine } from 'lucide-react'
|
||||||
|
|
||||||
import { AppSection } from '@/components/app/AppSection'
|
import { AppSection } from '@/components/app/AppSection'
|
||||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
import type { Ticket, TicketEvent } from './types'
|
import type { Ticket, TicketEvent } from './types'
|
||||||
|
|
||||||
@@ -27,13 +31,12 @@ const COMMENT_TYPE_LABELS: Record<string, string> = {
|
|||||||
user: '用户评论'
|
user: '用户评论'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventTime(value: string) {
|
const EVENT_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
created: CheckCircle2,
|
||||||
month: 'numeric',
|
updated: PencilLine,
|
||||||
day: 'numeric',
|
status_change: CircleDot,
|
||||||
hour: '2-digit',
|
commented: MessageSquareText,
|
||||||
minute: '2-digit'
|
attachment_added: Paperclip
|
||||||
}).format(new Date(value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayStatus(value: unknown) {
|
function displayStatus(value: unknown) {
|
||||||
@@ -65,6 +68,13 @@ function describeEvent(event: TicketEvent) {
|
|||||||
return event.remark || '记录已更新'
|
return event.remark || '记录已更新'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventVariant(eventType: string) {
|
||||||
|
if (eventType === 'status_change') return 'info'
|
||||||
|
if (eventType === 'commented') return 'secondary'
|
||||||
|
if (eventType === 'created') return 'success'
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
|
||||||
type TicketStatusTimelineProps = {
|
type TicketStatusTimelineProps = {
|
||||||
ticket: Ticket
|
ticket: Ticket
|
||||||
events: TicketEvent[]
|
events: TicketEvent[]
|
||||||
@@ -73,15 +83,16 @@ type TicketStatusTimelineProps = {
|
|||||||
export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) {
|
export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) {
|
||||||
return (
|
return (
|
||||||
<AppSection title="处理记录">
|
<AppSection title="处理记录">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-4">
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
|
<div className="grid gap-3 rounded-md border border-border bg-background p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground">当前状态</p>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-sm font-medium text-foreground">当前流程状态</p>
|
||||||
最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')}
|
<AppStatusBadge status={ticket.status} />
|
||||||
</p>
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">最后更新时间:{formatDateTime(ticket.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<AppStatusBadge status={ticket.status} />
|
<Badge variant="outline">{ticket.ticket_no}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
@@ -89,25 +100,28 @@ export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelinePro
|
|||||||
<p className="text-sm text-muted-foreground">暂无处理记录</p>
|
<p className="text-sm text-muted-foreground">暂无处理记录</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-0">
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => (
|
||||||
<div key={event.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
<div key={event.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="size-2 rounded-full bg-primary" />
|
<TimelineIcon eventType={event.event_type} />
|
||||||
{index < events.length - 1 ? <div className="h-full min-h-8 w-px bg-border" /> : null}
|
{index < events.length - 1 ? <div className="h-full min-h-10 w-px bg-border" /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-3">
|
<div className="pb-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="rounded-md border border-border bg-background p-3">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{EVENT_LABELS[event.event_type] ?? event.event_type}
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
</p>
|
<p className="text-sm font-medium text-foreground">
|
||||||
{event.event_type === 'created' ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
|
{EVENT_LABELS[event.event_type] ?? event.event_type}
|
||||||
|
</p>
|
||||||
|
<Badge variant={eventVariant(event.event_type)}>{displayOperator(event)}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatDateTime(event.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-sm text-foreground">{describeEvent(event)}</p>
|
||||||
|
{event.remark ? <p className="mt-2 text-xs leading-5 text-muted-foreground">说明:{event.remark}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-foreground">{describeEvent(event)}</p>
|
|
||||||
{event.remark ? <p className="mt-1 text-xs text-muted-foreground">说明:{event.remark}</p> : null}
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{displayOperator(event)} · {formatEventTime(event.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -117,3 +131,13 @@ export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelinePro
|
|||||||
</AppSection>
|
</AppSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TimelineIcon({ eventType }: { eventType: string }) {
|
||||||
|
const Icon = EVENT_ICONS[eventType] ?? CircleDot
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-7 items-center justify-center rounded-full border border-border bg-card text-primary shadow-sm">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { AppField } from '@/components/app/AppField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -66,10 +67,7 @@ export function TicketTransitionDialog({
|
|||||||
<DialogTitle>{actionLabel}</DialogTitle>
|
<DialogTitle>{actionLabel}</DialogTitle>
|
||||||
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
|
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-2">
|
<AppField label="处理说明" htmlFor="transition-remark" required>
|
||||||
<label className="text-sm font-medium text-foreground" htmlFor="transition-remark">
|
|
||||||
处理说明
|
|
||||||
</label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="transition-remark"
|
id="transition-remark"
|
||||||
required
|
required
|
||||||
@@ -78,7 +76,7 @@ export function TicketTransitionDialog({
|
|||||||
value={remark}
|
value={remark}
|
||||||
onChange={(event) => setRemark(event.target.value)}
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</AppField>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
Reference in New Issue
Block a user