feat: add ticket solution closure loop
This commit is contained in:
@@ -24,30 +24,30 @@ export function TicketComments({ ticketId }: { ticketId: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppSection title="评论与协作" description="记录内部沟通、排查过程和用户可见回复。">
|
||||
<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
|
||||
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
|
||||
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="secondary">
|
||||
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<AppSection title="评论与协作">
|
||||
<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
|
||||
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
|
||||
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="secondary">
|
||||
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,10 +49,7 @@ export function TicketCreatePage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto grid max-w-6xl gap-5">
|
||||
<AppPageHeader
|
||||
title="创建工单"
|
||||
description="记录明确的技术支持请求,便于后续分派、排查和复用方案。"
|
||||
/>
|
||||
<AppPageHeader title="创建工单" />
|
||||
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4">
|
||||
{error ? (
|
||||
@@ -63,7 +60,7 @@ export function TicketCreatePage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AppSection title="基础信息" description="先说明这张工单要解决什么问题。">
|
||||
<AppSection title="基础信息">
|
||||
<AppField label="工单标题">
|
||||
<Input
|
||||
required
|
||||
@@ -74,7 +71,7 @@ export function TicketCreatePage() {
|
||||
</AppField>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="影响与优先级" description="帮助支持团队判断响应顺序和处理风险。">
|
||||
<AppSection title="影响与优先级">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="优先级">
|
||||
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
||||
@@ -99,7 +96,7 @@ export function TicketCreatePage() {
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="环境与系统" description="补充问题发生的系统域和运行环境。">
|
||||
<AppSection title="环境与系统">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="关联系统">
|
||||
<Input
|
||||
@@ -118,7 +115,7 @@ export function TicketCreatePage() {
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="问题描述" description="请描述现象、出现时间、复现步骤和已尝试的处理方式。">
|
||||
<AppSection title="问题描述">
|
||||
<AppField label="详细描述">
|
||||
<Textarea
|
||||
required
|
||||
@@ -129,7 +126,7 @@ export function TicketCreatePage() {
|
||||
</AppField>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="提交确认" description="提交后工单会进入待处理队列。">
|
||||
<AppSection title="提交确认">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 size-4" />
|
||||
@@ -144,7 +141,7 @@ export function TicketCreatePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-4">
|
||||
<AppSection title="建单提示" description="高质量工单能减少来回确认。">
|
||||
<AppSection title="建单提示">
|
||||
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex gap-2">
|
||||
<Lightbulb className="mt-0.5 size-4" />
|
||||
@@ -163,7 +160,6 @@ export function TicketCreatePage() {
|
||||
|
||||
<AppSection
|
||||
title="AI 建单辅助"
|
||||
description="后续阶段能力预留"
|
||||
action={<Badge variant="ai"><Bot className="size-3" /> 即将接入</Badge>}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { Ticket, TicketStatus } from './types'
|
||||
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
||||
pending_info: ['pending', 'closed'],
|
||||
pending: ['in_progress', 'closed'],
|
||||
in_progress: ['pending_confirm', 'resolved', 'closed'],
|
||||
in_progress: ['pending_confirm'],
|
||||
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||
resolved: ['closed'],
|
||||
closed: []
|
||||
@@ -36,10 +36,18 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
|
||||
closed: { label: '关闭工单', icon: XCircle }
|
||||
}
|
||||
|
||||
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
||||
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
||||
return '取消工单'
|
||||
}
|
||||
return STATUS_ACTIONS[targetStatus]?.label ?? '调整状态'
|
||||
}
|
||||
|
||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||
const [error, setError] = React.useState('')
|
||||
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
||||
const [targetActionLabel, setTargetActionLabel] = React.useState('')
|
||||
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
||||
const [transitionError, setTransitionError] = React.useState('')
|
||||
|
||||
@@ -51,6 +59,9 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
|
||||
function openTransition(status: TicketStatus) {
|
||||
setTargetStatus(status)
|
||||
if (ticket) {
|
||||
setTargetActionLabel(getStatusActionLabel(ticket.status, status))
|
||||
}
|
||||
setTransitionError('')
|
||||
setTransitionDialogOpen(true)
|
||||
}
|
||||
@@ -107,7 +118,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
onClick={() => openTransition(status)}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : null}
|
||||
{action?.label ?? '调整状态'}
|
||||
{getStatusActionLabel(ticket.status, status)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
@@ -132,7 +143,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AppSection title="问题描述" description="当前工单的核心问题、影响场景和处理上下文。">
|
||||
<AppSection title="问题描述">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||
</AppSection>
|
||||
<TicketStatusTimeline ticket={ticket} />
|
||||
@@ -142,6 +153,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
<TicketTransitionDialog
|
||||
open={transitionDialogOpen}
|
||||
status={targetStatus}
|
||||
actionLabel={targetActionLabel}
|
||||
onOpenChange={setTransitionDialogOpen}
|
||||
onSubmit={handleTransition}
|
||||
/>
|
||||
|
||||
@@ -198,7 +198,6 @@ export function TicketListPage() {
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<AppPageHeader
|
||||
title="工单中心"
|
||||
description="集中查看、筛选和处理企业技术支持工单。"
|
||||
actions={
|
||||
<Button onClick={() => (window.location.hash = '/tickets/new')}>
|
||||
<Plus className="size-4" />
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
import type { Ticket } from './types'
|
||||
@@ -20,7 +20,6 @@ export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>工单属性</CardTitle>
|
||||
<CardDescription>当前处理状态和关键上下文。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
|
||||
@@ -13,7 +13,7 @@ const EVENT_LABELS = [
|
||||
|
||||
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
|
||||
return (
|
||||
<AppSection title="处理记录" description="记录状态流转、协作说明和关键处理节点。">
|
||||
<AppSection title="处理记录">
|
||||
<div className="grid gap-3">
|
||||
<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">
|
||||
|
||||
@@ -23,6 +23,7 @@ const STATUS_ACTION_LABELS: Partial<Record<TicketStatus, string>> = {
|
||||
type TicketTransitionDialogProps = {
|
||||
open: boolean
|
||||
status: TicketStatus | null
|
||||
actionLabel?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (status: TicketStatus, remark: string) => Promise<void>
|
||||
}
|
||||
@@ -30,12 +31,13 @@ type TicketTransitionDialogProps = {
|
||||
export function TicketTransitionDialog({
|
||||
open,
|
||||
status,
|
||||
actionLabel: customActionLabel,
|
||||
onOpenChange,
|
||||
onSubmit
|
||||
}: TicketTransitionDialogProps) {
|
||||
const [remark, setRemark] = React.useState('')
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const actionLabel = status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态'
|
||||
const actionLabel = customActionLabel ?? (status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
|
||||
Reference in New Issue
Block a user