feat: redesign ticket detail experience
This commit is contained in:
@@ -2,6 +2,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import { AppSection } from '@/components/app/AppSection'
|
import { AppSection } from '@/components/app/AppSection'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
import { addTicketComment } from './api'
|
import { addTicketComment } from './api'
|
||||||
@@ -9,20 +10,33 @@ import { addTicketComment } from './api'
|
|||||||
export function TicketComments({ ticketId }: { ticketId: number }) {
|
export function TicketComments({ ticketId }: { ticketId: number }) {
|
||||||
const [content, setContent] = React.useState('')
|
const [content, setContent] = React.useState('')
|
||||||
const [lastComment, setLastComment] = React.useState('')
|
const [lastComment, setLastComment] = React.useState('')
|
||||||
|
const [commentMode, setCommentMode] = React.useState('internal')
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!content.trim()) return
|
if (!content.trim()) return
|
||||||
const comment = await addTicketComment(ticketId, content)
|
const comment = await addTicketComment(ticketId, content, {
|
||||||
|
comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note',
|
||||||
|
visibility: commentMode === 'public' ? 'public' : 'internal'
|
||||||
|
})
|
||||||
setLastComment(comment.content)
|
setLastComment(comment.content)
|
||||||
setContent('')
|
setContent('')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSection title="评论协作" description="记录内部沟通、排查过程和处理补充。">
|
<AppSection title="评论与协作" description="记录内部沟通、排查过程和用户可见回复。">
|
||||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||||
|
<Select value={commentMode} onValueChange={setCommentMode}>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue placeholder="选择回复类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="internal">内部备注</SelectItem>
|
||||||
|
<SelectItem value="public">用户可见回复</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="请输入处理说明或补充信息"
|
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -30,7 +44,7 @@ export function TicketComments({ ticketId }: { ticketId: number }) {
|
|||||||
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" variant="secondary">
|
<Button type="submit" variant="secondary">
|
||||||
发表回复
|
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,34 +1,47 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { Bot, CheckCircle2, CircleDot, 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'
|
||||||
import { AppField } from '@/components/app/AppField'
|
|
||||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||||
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||||
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 { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
||||||
|
|
||||||
import { getTicket, transitionTicket } from './api'
|
import { getTicket, transitionTicket } from './api'
|
||||||
import { TicketComments } from './TicketComments'
|
import { TicketComments } from './TicketComments'
|
||||||
|
import { TicketMetaPanel } from './TicketMetaPanel'
|
||||||
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
||||||
|
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
||||||
import type { Ticket, TicketStatus } from './types'
|
import type { Ticket, TicketStatus } from './types'
|
||||||
|
|
||||||
const NEXT_STATUSES: TicketStatus[] = ['in_progress', 'pending_confirm', 'resolved', 'closed']
|
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
||||||
const STATUS_ACTION_LABELS: Record<TicketStatus, string> = {
|
pending_info: ['pending', 'closed'],
|
||||||
pending_info: '退回补充',
|
pending: ['in_progress', 'closed'],
|
||||||
pending: '转为待处理',
|
in_progress: ['pending_confirm', 'resolved', 'closed'],
|
||||||
in_progress: '开始处理',
|
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||||
pending_confirm: '提交确认',
|
resolved: ['closed'],
|
||||||
resolved: '标记解决',
|
closed: []
|
||||||
closed: '关闭工单'
|
}
|
||||||
|
|
||||||
|
const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.ComponentType<{ className?: string }> }>> = {
|
||||||
|
pending: { label: '转为待处理', icon: CircleDot },
|
||||||
|
in_progress: { label: '开始处理', icon: CircleDot },
|
||||||
|
pending_confirm: { label: '提交确认', icon: Send },
|
||||||
|
resolved: { label: '标记解决', icon: CheckCircle2 },
|
||||||
|
closed: { label: '关闭工单', icon: XCircle }
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
||||||
|
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
||||||
|
const [transitionError, setTransitionError] = React.useState('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
getTicket(ticketId)
|
getTicket(ticketId)
|
||||||
@@ -36,16 +49,28 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||||
}, [ticketId])
|
}, [ticketId])
|
||||||
|
|
||||||
async function handleTransition(status: TicketStatus) {
|
function openTransition(status: TicketStatus) {
|
||||||
|
setTargetStatus(status)
|
||||||
|
setTransitionError('')
|
||||||
|
setTransitionDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransition(status: TicketStatus, remark: string) {
|
||||||
if (!ticket) return
|
if (!ticket) return
|
||||||
const updated = await transitionTicket(ticket.id, status, `状态调整为:${STATUS_ACTION_LABELS[status]}`)
|
try {
|
||||||
setTicket(updated)
|
const updated = await transitionTicket(ticket.id, status, remark)
|
||||||
|
setTicket(updated)
|
||||||
|
setTransitionError('')
|
||||||
|
} catch (reason) {
|
||||||
|
setTransitionError(reason instanceof Error ? reason.message : '状态流转失败')
|
||||||
|
throw reason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
<AppEmptyState title="Unable to load ticket" description={error} />
|
<AppEmptyState title="无法加载工单" description={error} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -53,7 +78,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
<AppEmptyState title="Loading ticket" />
|
<AppEmptyState title="正在加载工单" />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,49 +97,54 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{NEXT_STATUSES.map((status) => (
|
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||||
<Button key={status} variant="secondary" onClick={() => void handleTransition(status)}>
|
const action = STATUS_ACTIONS[status]
|
||||||
{STATUS_ACTION_LABELS[status]}
|
const Icon = action?.icon
|
||||||
</Button>
|
return (
|
||||||
))}
|
<Button
|
||||||
|
key={status}
|
||||||
|
variant={status === 'closed' ? 'outline' : 'secondary'}
|
||||||
|
onClick={() => openTransition(status)}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className="size-4" /> : null}
|
||||||
|
{action?.label ?? '调整状态'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AppDetailLayout
|
<AppDetailLayout
|
||||||
side={
|
side={
|
||||||
<>
|
<>
|
||||||
<AppSection title="关键字段">
|
<TicketMetaPanel ticket={ticket} />
|
||||||
<div className="grid gap-3">
|
|
||||||
<AppField label="状态">
|
|
||||||
<AppStatusBadge status={ticket.status} />
|
|
||||||
</AppField>
|
|
||||||
<AppField label="优先级">
|
|
||||||
<AppPriorityBadge priority={ticket.priority} />
|
|
||||||
</AppField>
|
|
||||||
<AppField label="关联系统">
|
|
||||||
<span className="text-sm">{ticket.related_system || '-'}</span>
|
|
||||||
</AppField>
|
|
||||||
<AppField label="运行环境">
|
|
||||||
<span className="text-sm">{ticket.environment || '-'}</span>
|
|
||||||
</AppField>
|
|
||||||
<AppField label="影响范围">
|
|
||||||
<span className="text-sm">{ticket.impact_scope || '-'}</span>
|
|
||||||
</AppField>
|
|
||||||
</div>
|
|
||||||
</AppSection>
|
|
||||||
<SolutionLinkPanel ticketId={ticket.id} />
|
<SolutionLinkPanel ticketId={ticket.id} />
|
||||||
<AppSection title="AI 支持分析" action={<Badge variant="ai">后续接入</Badge>}>
|
<AppSection title="AI 支持分析" action={<Badge variant="ai">后续接入</Badge>}>
|
||||||
<p className="text-sm text-muted-foreground">后续阶段将在这里展示排查建议、相似工单和引用来源。</p>
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<Bot className="size-4" />
|
||||||
|
智能排查占位
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-sm text-muted-foreground">后续阶段将在这里展示排查建议、相似工单和引用来源。</p>
|
||||||
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSection title="工单概览" description="当前工单的核心问题和处理上下文。">
|
<AppSection title="问题描述" description="当前工单的核心问题、影响场景和处理上下文。">
|
||||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
<TicketStatusTimeline ticket={ticket} />
|
<TicketStatusTimeline ticket={ticket} />
|
||||||
<TicketComments ticketId={ticket.id} />
|
<TicketComments ticketId={ticket.id} />
|
||||||
</AppDetailLayout>
|
</AppDetailLayout>
|
||||||
|
{transitionError ? <AppEmptyState title="状态流转失败" description={transitionError} /> : null}
|
||||||
|
<TicketTransitionDialog
|
||||||
|
open={transitionDialogOpen}
|
||||||
|
status={targetStatus}
|
||||||
|
onOpenChange={setTransitionDialogOpen}
|
||||||
|
onSubmit={handleTransition}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
59
frontend/src/features/tickets/TicketMetaPanel.tsx
Normal file
59
frontend/src/features/tickets/TicketMetaPanel.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { AppField } from '@/components/app/AppField'
|
||||||
|
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||||
|
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
import type { Ticket } from './types'
|
||||||
|
|
||||||
|
type TicketMetaPanelProps = {
|
||||||
|
ticket: Ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUser(user: Ticket['assignee']) {
|
||||||
|
return user?.display_name || user?.username || '未设置'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>工单属性</CardTitle>
|
||||||
|
<CardDescription>当前处理状态和关键上下文。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<AppField label="当前状态">
|
||||||
|
<AppStatusBadge status={ticket.status} />
|
||||||
|
</AppField>
|
||||||
|
<AppField label="优先级">
|
||||||
|
<AppPriorityBadge priority={ticket.priority} />
|
||||||
|
</AppField>
|
||||||
|
<Separator />
|
||||||
|
<AppField label="处理人">
|
||||||
|
<span className="text-sm text-foreground">{displayUser(ticket.assignee)}</span>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="请求人">
|
||||||
|
<span className="text-sm text-foreground">{displayUser(ticket.requester)}</span>
|
||||||
|
</AppField>
|
||||||
|
<Separator />
|
||||||
|
<AppField label="关联系统">
|
||||||
|
<span className="text-sm text-foreground">{ticket.related_system || '未填写'}</span>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="运行环境">
|
||||||
|
<span className="text-sm text-foreground">{ticket.environment || '未填写'}</span>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="影响范围">
|
||||||
|
<span className="text-sm text-foreground">{ticket.impact_scope || '未填写'}</span>
|
||||||
|
</AppField>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">SLA</span>
|
||||||
|
<Badge variant="outline">后续接入</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,44 @@
|
|||||||
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 type { Ticket } from './types'
|
import type { Ticket } from './types'
|
||||||
|
|
||||||
|
const EVENT_LABELS = [
|
||||||
|
{ label: '创建工单', description: '工单已进入支持队列。' },
|
||||||
|
{ label: '状态变更', description: '处理状态会在这里沉淀为时间线。' },
|
||||||
|
{ label: '新增评论', description: '内部备注和用户可见回复会记录协作过程。' },
|
||||||
|
{ label: '新增附件', description: '后续附件能力接入后展示上传记录。' }
|
||||||
|
]
|
||||||
|
|
||||||
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
|
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
|
||||||
return (
|
return (
|
||||||
<AppSection title="处理动态" description="记录状态流转和关键处理节点。">
|
<AppSection title="处理记录" description="记录状态流转、协作说明和关键处理节点。">
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2">
|
<div className="grid gap-3">
|
||||||
<div className="size-2 rounded-full bg-info" />
|
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground">当前状态</p>
|
<p className="text-sm font-medium text-foreground">当前状态</p>
|
||||||
<div className="mt-1">
|
<p className="mt-1 text-xs text-muted-foreground">最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')}</p>
|
||||||
<AppStatusBadge status={ticket.status} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<AppStatusBadge status={ticket.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{EVENT_LABELS.map((event, index) => (
|
||||||
|
<div key={event.label} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="size-2 rounded-full bg-primary" />
|
||||||
|
{index < EVENT_LABELS.length - 1 ? <div className="h-full min-h-7 w-px bg-border" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{event.label}</p>
|
||||||
|
{index === 0 ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{event.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|||||||
92
frontend/src/features/tickets/TicketTransitionDialog.tsx
Normal file
92
frontend/src/features/tickets/TicketTransitionDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
|
import type { TicketStatus } from './types'
|
||||||
|
|
||||||
|
const STATUS_ACTION_LABELS: Partial<Record<TicketStatus, string>> = {
|
||||||
|
in_progress: '开始处理',
|
||||||
|
pending_confirm: '提交确认',
|
||||||
|
resolved: '标记解决',
|
||||||
|
closed: '关闭工单'
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketTransitionDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
status: TicketStatus | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (status: TicketStatus, remark: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketTransitionDialog({
|
||||||
|
open,
|
||||||
|
status,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit
|
||||||
|
}: TicketTransitionDialogProps) {
|
||||||
|
const [remark, setRemark] = React.useState('')
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
const actionLabel = status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态'
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setRemark('')
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!status || !remark.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await onSubmit(status, remark.trim())
|
||||||
|
onOpenChange(false)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{actionLabel}</DialogTitle>
|
||||||
|
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="transition-remark">
|
||||||
|
处理说明
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="transition-remark"
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
placeholder="例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。"
|
||||||
|
value={remark}
|
||||||
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!remark.trim() || submitting}>
|
||||||
|
{submitting ? '提交中...' : '确认提交'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,9 +31,17 @@ export function transitionTicket(ticketId: number, status: TicketStatus, remark
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addTicketComment(ticketId: number, content: string) {
|
export function addTicketComment(
|
||||||
|
ticketId: number,
|
||||||
|
content: string,
|
||||||
|
options: { comment_type?: string; visibility?: string } = {}
|
||||||
|
) {
|
||||||
return apiRequest<TicketComment>(`/tickets/${ticketId}/comments`, {
|
return apiRequest<TicketComment>(`/tickets/${ticketId}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ content, comment_type: 'internal_note', visibility: 'internal' })
|
body: JSON.stringify({
|
||||||
|
content,
|
||||||
|
comment_type: options.comment_type ?? 'internal_note',
|
||||||
|
visibility: options.visibility ?? 'internal'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user