feat: add category-driven ticket workflows

This commit is contained in:
hailin
2026-07-03 16:17:32 +08:00
parent 6586889733
commit 7692374067
16 changed files with 777 additions and 87 deletions

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { AlertCircle, Bot, CheckCircle2, Lightbulb, Send } from 'lucide-react'
import { AlertCircle, CheckCircle2, Lightbulb, Send } from 'lucide-react'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
@@ -13,17 +13,25 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
import { Textarea } from '@/components/ui/textarea'
import { createTicket } from './api'
import { TICKET_CATEGORIES } from './ticketCategories'
import type { TicketInput } from './types'
const initialForm: TicketInput = {
title: '',
description: '',
category_code: 'business_issue',
priority: 'medium',
problem_performance: '',
problem_object: '',
submitter_name: '',
submitter_org: '',
impact_scope: '',
environment: '',
related_system: ''
}
const ENVIRONMENT_OPTIONS = ['生产环境', '测试环境', '预发布环境', '本地环境']
export function TicketCreatePage() {
const [form, setForm] = React.useState<TicketInput>(initialForm)
const [error, setError] = React.useState('')
@@ -36,6 +44,10 @@ export function TicketCreatePage() {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError('')
if (!form.environment || !form.problem_performance || !form.problem_object || !form.submitter_name || !form.submitter_org) {
setError('请完整填写问题表现、问题对象、提出人、提出机构和环境。')
return
}
setSubmitting(true)
try {
const ticket = await createTicket(form)
@@ -61,18 +73,94 @@ export function TicketCreatePage() {
) : null}
<AppSection title="基础信息">
<AppField label="工单标题" htmlFor="ticket-title" required>
<Input
id="ticket-title"
<div className="grid gap-4 md:grid-cols-2">
<AppField label="问题分类" required>
<Select
value={form.category_code}
onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择问题分类" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TICKET_CATEGORIES.map((category) => (
<SelectItem key={category.code} value={category.code}>
{category.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField label="工单标题" htmlFor="ticket-title" required>
<Input
id="ticket-title"
required
placeholder="例如:查询客户采集进度"
value={form.title}
onChange={(event) => updateField('title', event.target.value)}
/>
</AppField>
</div>
</AppSection>
<AppSection title="问题信息">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="问题表现" htmlFor="ticket-problem-performance" required>
<Input
id="ticket-problem-performance"
required
placeholder="例如:采集进度长时间未更新"
value={form.problem_performance}
onChange={(event) => updateField('problem_performance', event.target.value)}
/>
</AppField>
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
<Input
id="ticket-problem-object"
required
placeholder="例如:客户名称、账号、机构或任务编号"
value={form.problem_object}
onChange={(event) => updateField('problem_object', event.target.value)}
/>
</AppField>
</div>
<AppField label="问题描述" htmlFor="ticket-description" required>
<Textarea
id="ticket-description"
required
placeholder="例如:生产环境登录后跳转失败"
value={form.title}
onChange={(event) => updateField('title', event.target.value)}
placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。"
value={form.description}
onChange={(event) => updateField('description', event.target.value)}
/>
</AppField>
</AppSection>
<AppSection title="影响与优先级">
<AppSection title="提出信息">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="提出人" htmlFor="ticket-submitter-name" required>
<Input
id="ticket-submitter-name"
required
placeholder="请输入提出人"
value={form.submitter_name}
onChange={(event) => updateField('submitter_name', event.target.value)}
/>
</AppField>
<AppField label="提出机构" htmlFor="ticket-submitter-org" required>
<Input
id="ticket-submitter-org"
required
placeholder="请输入提出机构"
value={form.submitter_org}
onChange={(event) => updateField('submitter_org', event.target.value)}
/>
</AppField>
</div>
</AppSection>
<AppSection title="影响与环境">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="优先级" required>
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
@@ -92,15 +180,12 @@ export function TicketCreatePage() {
<AppField label="影响范围" htmlFor="ticket-impact-scope">
<Input
id="ticket-impact-scope"
placeholder="例如:单个用户、某部门、全部用户"
placeholder="例如:单个客户、某部门、全部用户"
value={form.impact_scope}
onChange={(event) => updateField('impact_scope', event.target.value)}
/>
</AppField>
</div>
</AppSection>
<AppSection title="环境与系统">
<div className="grid gap-4 md:grid-cols-2">
<AppField label="关联系统" htmlFor="ticket-related-system">
<Input
@@ -110,34 +195,30 @@ export function TicketCreatePage() {
onChange={(event) => updateField('related_system', event.target.value)}
/>
</AppField>
<AppField label="运行环境" htmlFor="ticket-environment">
<Input
id="ticket-environment"
placeholder="例如:生产环境、测试环境、Windows 终端"
value={form.environment}
onChange={(event) => updateField('environment', event.target.value)}
/>
<AppField label="环境" required>
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择问题环境" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ENVIRONMENT_OPTIONS.map((environment) => (
<SelectItem key={environment} value={environment}>
{environment}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
</div>
</AppSection>
<AppSection title="问题描述">
<AppField label="详细描述" htmlFor="ticket-description" required>
<Textarea
id="ticket-description"
required
placeholder="请描述影响范围、报错信息、操作步骤和已尝试的处理方式。"
value={form.description}
onChange={(event) => updateField('description', event.target.value)}
/>
</AppField>
</AppSection>
<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" />
<span>请确认标题和问题描述已足够支持技术人员开始排查。</span>
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
</div>
<Button type="submit" disabled={submitting}>
<Send data-icon="inline-start" />
@@ -152,25 +233,25 @@ export function TicketCreatePage() {
<div className="grid gap-3 text-sm text-muted-foreground">
<div className="flex gap-2">
<Lightbulb className="mt-0.5 size-4" />
<span>标题建议包含系统、场景和异常现象。</span>
<span>业务、操作类问题可在详情页直接填写解决方案并完成。</span>
</div>
<div className="flex gap-2">
<Lightbulb className="mt-0.5 size-4" />
<span>描述中写清楚复现步骤、报错信息和影响范围。</span>
<span>技术类问题会保留开始处理、提交确认、解决和关闭流程。</span>
</div>
<div className="flex gap-2">
<Lightbulb className="mt-0.5 size-4" />
<span>紧急优先级应只用于阻断生产或大范围影响问题。</span>
<span>数据类问题支持处理后解决,也支持明确后直接解决。</span>
</div>
</div>
</AppSection>
<AppSection
title="AI 建单辅助"
title="AI 解决方案"
action={<Badge variant="ai">即将接入</Badge>}
>
<p className="text-sm text-muted-foreground">
后续阶段将接入建单 Agent,用于补全字段、追问缺失信息和推荐相似问题。
后续阶段将由 AI 识别问题后自动回填,当前不作为人工必填项。
</p>
</AppSection>
</div>

View File

@@ -17,20 +17,12 @@ import { formatDateTime } from '@/lib/format'
import { getTicket, listTicketEvents, transitionTicket } from './api'
import { TicketComments } from './TicketComments'
import { getTicketCategoryLabel, getTicketNextStatuses } from './ticketCategories'
import { TicketMetaPanel } from './TicketMetaPanel'
import { TicketStatusTimeline } from './TicketStatusTimeline'
import { TicketTransitionDialog } from './TicketTransitionDialog'
import type { Ticket, TicketEvent, TicketStatus } from './types'
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
pending_info: ['pending', 'closed'],
pending: ['in_progress', 'closed'],
in_progress: ['pending_confirm'],
pending_confirm: ['resolved', 'in_progress', '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 },
@@ -93,10 +85,18 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
setTransitionDialogOpen(true)
}
async function handleTransition(status: TicketStatus, remark: string) {
async function handleTransition(
status: TicketStatus,
values: {
remark: string
resolution: string
is_resolved: boolean
replied_to_customer: boolean
}
) {
if (!ticket) return
try {
const updated = await transitionTicket(ticket.id, status, remark)
const updated = await transitionTicket(ticket.id, status, values)
setTicket(updated)
setTransitionError('')
refreshEvents()
@@ -122,6 +122,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
)
}
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status)
return (
<AppPage maxWidth="6xl">
<AppPageHeader
@@ -135,7 +137,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
}
actions={
<div className="flex flex-wrap gap-2">
{NEXT_STATUSES[ticket.status].map((status) => {
{nextStatuses.map((status) => {
const action = STATUS_ACTIONS[status]
const Icon = action?.icon
return (
@@ -153,10 +155,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
}
/>
<TicketOverviewCard ticket={ticket} />
{NEXT_STATUSES[ticket.status].length ? (
{nextStatuses.length ? (
<Card>
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
{NEXT_STATUSES[ticket.status].map((status) => {
{nextStatuses.map((status) => {
const action = STATUS_ACTIONS[status]
const Icon = action?.icon ?? CircleDot
return (
@@ -202,7 +204,37 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
}
>
<AppSection title="问题描述">
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
<div className="grid gap-4">
<div>
<p className="text-xs font-medium text-muted-foreground">问题分类</p>
<p className="mt-1 text-sm font-medium text-foreground">{getTicketCategoryLabel(ticket.category_code)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">问题表现</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.problem_performance || '未填写'}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">问题描述</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
</div>
</div>
</AppSection>
<AppSection title="解决方案">
{ticket.resolution ? (
<div className="grid gap-3">
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.resolution}</p>
<div className="flex flex-wrap gap-2">
<Badge variant={ticket.is_resolved ? 'success' : 'outline'}>
{ticket.is_resolved ? '已解决' : '未解决'}
</Badge>
<Badge variant={ticket.replied_to_customer ? 'info' : 'outline'}>
{ticket.replied_to_customer ? '已回复客户' : '未回复客户'}
</Badge>
</div>
</div>
) : (
<AppEmptyState title="暂无解决方案" description="解决工单后将在这里展示处理结论。" />
)}
</AppSection>
<TicketStatusTimeline ticket={ticket} events={events} />
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
@@ -221,6 +253,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
const items = [
{ label: '问题分类', value: getTicketCategoryLabel(ticket.category_code) },
{ label: '问题对象', value: ticket.problem_object || '未填写' },
{ label: '提出人', value: ticket.submitter_name || displayUser(ticket.requester) },
{ label: '提出机构', value: ticket.submitter_org || '未填写' },
{ label: '请求人', value: displayUser(ticket.requester) },
{ label: '处理人', value: displayUser(ticket.assignee) },
{ label: '关联系统', value: ticket.related_system || '未填写' },

View File

@@ -5,13 +5,17 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { TICKET_CATEGORIES } from './ticketCategories'
type TicketFilterBarProps = {
q: string
status: string
categoryCode: string
priority: string
relatedSystem: string
onQChange: (value: string) => void
onStatusChange: (value: string) => void
onCategoryCodeChange: (value: string) => void
onPriorityChange: (value: string) => void
onRelatedSystemChange: (value: string) => void
onReset: () => void
@@ -20,10 +24,12 @@ type TicketFilterBarProps = {
export function TicketFilterBar({
q,
status,
categoryCode,
priority,
relatedSystem,
onQChange,
onStatusChange,
onCategoryCodeChange,
onPriorityChange,
onRelatedSystemChange,
onReset
@@ -54,6 +60,21 @@ export function TicketFilterBar({
</SelectGroup>
</SelectContent>
</Select>
<Select onValueChange={onCategoryCodeChange} value={categoryCode}>
<SelectTrigger className="w-full">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部分类</SelectItem>
{TICKET_CATEGORIES.map((category) => (
<SelectItem key={category.code} value={category.code}>
{category.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select onValueChange={onPriorityChange} value={priority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="全部优先级" />

View File

@@ -24,6 +24,7 @@ import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { listTickets } from './api'
import { getTicketCategoryLabel } from './ticketCategories'
import { TicketFilterBar } from './TicketFilterBar'
import { TicketMetricCards } from './TicketMetricCards'
import type { Ticket, TicketListResponse, TicketListView } from './types'
@@ -67,10 +68,13 @@ const columns: AppDataTableColumn<Ticket>[] = [
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{ticket.ticket_no}</Badge>
<Badge variant="secondary">{getTicketCategoryLabel(ticket.category_code)}</Badge>
<span className="text-xs text-muted-foreground">请求人:{displayUser(ticket.requester)}</span>
</div>
<p className="mt-2 line-clamp-1 font-medium text-foreground">{ticket.title}</p>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{ticket.description || '暂无问题描述'}</p>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
{ticket.problem_performance || ticket.description || '暂无问题描述'}
</p>
</div>
)
},
@@ -92,7 +96,9 @@ const columns: AppDataTableColumn<Ticket>[] = [
cell: (ticket) => (
<div className="grid min-w-0 gap-1">
<p className="truncate text-sm text-foreground">{ticket.related_system || '未填写系统'}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">{ticket.impact_scope || ticket.environment || '暂无影响范围'}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">
{ticket.problem_object || ticket.impact_scope || ticket.environment || '暂无影响范围'}
</p>
</div>
)
},
@@ -149,6 +155,7 @@ export function TicketListPage() {
})
const [view, setView] = React.useState<TicketListView>('all')
const [status, setStatus] = React.useState('all')
const [categoryCode, setCategoryCode] = React.useState('all')
const [priority, setPriority] = React.useState('all')
const [relatedSystem, setRelatedSystem] = React.useState('')
const [q, setQ] = React.useState('')
@@ -163,6 +170,7 @@ export function TicketListPage() {
page_size: PAGE_SIZE,
view,
status: status === 'all' ? '' : status,
category_code: categoryCode === 'all' ? '' : categoryCode,
priority: priority === 'all' ? '' : priority,
related_system: relatedSystem,
q
@@ -173,10 +181,11 @@ export function TicketListPage() {
})
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
.finally(() => setLoading(false))
}, [page, priority, q, relatedSystem, status, view])
}, [categoryCode, page, priority, q, relatedSystem, status, view])
function resetFilters() {
setStatus('all')
setCategoryCode('all')
setPriority('all')
setRelatedSystem('')
setQ('')
@@ -198,6 +207,11 @@ export function TicketListPage() {
setPage(1)
}
function updateCategoryCode(value: string) {
setCategoryCode(value)
setPage(1)
}
function updateRelatedSystem(value: string) {
setRelatedSystem(value)
setPage(1)
@@ -213,7 +227,7 @@ export function TicketListPage() {
setPage(target)
}
const hasActiveFilters = status !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
const hasActiveFilters = status !== 'all' || categoryCode !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
return (
<AppPage contentClassName="gap-5">
@@ -252,10 +266,12 @@ export function TicketListPage() {
<TicketFilterBar
q={q}
status={status}
categoryCode={categoryCode}
priority={priority}
relatedSystem={relatedSystem}
onQChange={updateQuery}
onStatusChange={updateStatus}
onCategoryCodeChange={updateCategoryCode}
onPriorityChange={updatePriority}
onRelatedSystemChange={updateRelatedSystem}
onReset={resetFilters}

View File

@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { getTicketCategoryLabel } from './ticketCategories'
import type { Ticket } from './types'
type TicketMetaPanelProps = {
@@ -29,6 +30,19 @@ export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
<AppField label="优先级">
<AppPriorityBadge priority={ticket.priority} />
</AppField>
<AppField label="问题分类">
<span className="text-sm text-foreground">{getTicketCategoryLabel(ticket.category_code)}</span>
</AppField>
<Separator />
<AppField label="提出人">
<span className="text-sm text-foreground">{ticket.submitter_name || '未填写'}</span>
</AppField>
<AppField label="提出机构">
<span className="text-sm text-foreground">{ticket.submitter_org || '未填写'}</span>
</AppField>
<AppField label="问题对象">
<span className="text-sm text-foreground">{ticket.problem_object || '未填写'}</span>
</AppField>
<Separator />
<AppField label="处理人">
<span className="text-sm text-foreground">{displayUser(ticket.assignee)}</span>

View File

@@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import type { TicketStatus } from './types'
@@ -26,7 +27,15 @@ type TicketTransitionDialogProps = {
status: TicketStatus | null
actionLabel?: string
onOpenChange: (open: boolean) => void
onSubmit: (status: TicketStatus, remark: string) => Promise<void>
onSubmit: (
status: TicketStatus,
values: {
remark: string
resolution: string
is_resolved: boolean
replied_to_customer: boolean
}
) => Promise<void>
}
export function TicketTransitionDialog({
@@ -37,22 +46,33 @@ export function TicketTransitionDialog({
onSubmit
}: TicketTransitionDialogProps) {
const [remark, setRemark] = React.useState('')
const [resolution, setResolution] = React.useState('')
const [repliedToCustomer, setRepliedToCustomer] = React.useState(true)
const [submitting, setSubmitting] = React.useState(false)
const actionLabel = customActionLabel ?? (status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态')
const isResolveAction = status === 'resolved'
const canSubmit = isResolveAction ? Boolean((resolution || remark).trim()) : Boolean(remark.trim())
React.useEffect(() => {
if (!open) {
setRemark('')
setResolution('')
setRepliedToCustomer(true)
setSubmitting(false)
}
}, [open])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!status || !remark.trim()) return
if (!status || !canSubmit) return
setSubmitting(true)
try {
await onSubmit(status, remark.trim())
await onSubmit(status, {
remark: remark.trim(),
resolution: resolution.trim(),
is_resolved: status === 'resolved',
replied_to_customer: repliedToCustomer
})
onOpenChange(false)
} finally {
setSubmitting(false)
@@ -65,23 +85,46 @@ export function TicketTransitionDialog({
<form className="grid gap-4" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
<DialogDescription>
{isResolveAction ? '填写本次处理结论,系统会同步记录到工单详情和流转时间线。' : '请说明本次流转的原因、处理动作或待确认事项。'}
</DialogDescription>
</DialogHeader>
<AppField label="处理说明" htmlFor="transition-remark" required>
{isResolveAction ? (
<AppField label="解决方案" htmlFor="transition-resolution" required>
<Textarea
id="transition-resolution"
required
minLength={2}
placeholder="例如:已查询采集进度,当前已完成 80%,预计今日完成。"
value={resolution}
onChange={(event) => setResolution(event.target.value)}
/>
</AppField>
) : null}
<AppField label={isResolveAction ? '处理说明' : '处理说明'} htmlFor="transition-remark" required={!isResolveAction}>
<Textarea
id="transition-remark"
required
required={!isResolveAction}
minLength={2}
placeholder="例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。"
placeholder={isResolveAction ? '可补充内部排查说明。' : '例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。'}
value={remark}
onChange={(event) => setRemark(event.target.value)}
/>
</AppField>
{isResolveAction ? (
<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>
<span className="text-xs text-muted-foreground">开启后会在工单结果中记录为已回复客户。</span>
</div>
<Switch checked={repliedToCustomer} onCheckedChange={setRepliedToCustomer} />
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={!remark.trim() || submitting}>
<Button type="submit" disabled={!canSubmit || submitting}>
{submitting ? '提交中...' : '确认提交'}
</Button>
</DialogFooter>

View File

@@ -36,10 +36,25 @@ export function listTicketEvents(ticketId: number) {
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
}
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
export function transitionTicket(
ticketId: number,
status: TicketStatus,
options: {
remark?: string
resolution?: string
is_resolved?: boolean
replied_to_customer?: boolean
} = {}
) {
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
method: 'POST',
body: JSON.stringify({ status, remark })
body: JSON.stringify({
status,
remark: options.remark ?? '',
resolution: options.resolution ?? '',
is_resolved: options.is_resolved,
replied_to_customer: options.replied_to_customer
})
})
}

View File

@@ -0,0 +1,88 @@
import type { TicketCategoryCode, TicketStatus } from './types'
export const TICKET_CATEGORIES: Array<{
code: TicketCategoryCode
label: string
description: string
policy: '短流程' | '标准流程' | '中流程'
}> = [
{
code: 'business_issue',
label: '业务问题',
description: '采集进度、业务规则、业务状态确认',
policy: '短流程'
},
{
code: 'technical_issue',
label: '技术问题',
description: '系统报错、接口异常、服务不可用',
policy: '标准流程'
},
{
code: 'data_issue',
label: '数据问题',
description: '数据缺失、不一致、采集或同步异常',
policy: '中流程'
},
{
code: 'operation_issue',
label: '操作问题',
description: '操作指导、配置入口、权限使用',
policy: '短流程'
}
]
export const TICKET_CATEGORY_LABELS = Object.fromEntries(
TICKET_CATEGORIES.map((category) => [category.code, category.label])
) as Record<TicketCategoryCode, string>
export const CATEGORY_NEXT_STATUSES: Record<TicketCategoryCode | 'default', Record<TicketStatus, TicketStatus[]>> = {
default: {
pending_info: ['pending', 'closed'],
pending: ['in_progress', 'closed'],
in_progress: ['pending_confirm'],
pending_confirm: ['resolved', 'in_progress', 'closed'],
resolved: ['closed'],
closed: []
},
business_issue: {
pending_info: ['pending', 'resolved', 'closed'],
pending: ['resolved', 'closed'],
in_progress: ['resolved', 'closed'],
pending_confirm: ['resolved', 'closed'],
resolved: ['closed'],
closed: []
},
operation_issue: {
pending_info: ['pending', 'resolved', 'closed'],
pending: ['resolved', 'closed'],
in_progress: ['resolved', 'closed'],
pending_confirm: ['resolved', 'closed'],
resolved: ['closed'],
closed: []
},
data_issue: {
pending_info: ['pending', 'closed'],
pending: ['in_progress', 'resolved', 'closed'],
in_progress: ['resolved', 'pending_confirm', 'closed'],
pending_confirm: ['resolved', 'in_progress', 'closed'],
resolved: ['closed'],
closed: []
},
technical_issue: {
pending_info: ['pending', 'closed'],
pending: ['in_progress', 'closed'],
in_progress: ['pending_confirm'],
pending_confirm: ['resolved', 'in_progress', 'closed'],
resolved: ['closed'],
closed: []
}
}
export function getTicketCategoryLabel(code?: TicketCategoryCode | null) {
return code ? TICKET_CATEGORY_LABELS[code] : '未分类'
}
export function getTicketNextStatuses(code: TicketCategoryCode | null, status: TicketStatus) {
return CATEGORY_NEXT_STATUSES[code ?? 'default'][status]
}

View File

@@ -8,19 +8,38 @@ export type TicketStatus =
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
export type TicketCategoryCode = 'business_issue' | 'technical_issue' | 'data_issue' | 'operation_issue'
export type TicketUser = {
id: number
username: string
display_name: string
}
export type TicketCategory = {
id: number
code: TicketCategoryCode
name: string
description: string
}
export type Ticket = {
id: number
ticket_no: string
title: string
description: string
category: TicketCategory | null
category_code: TicketCategoryCode | null
status: TicketStatus
priority: TicketPriority
problem_performance: string
problem_object: string
submitter_name: string
submitter_org: string
ai_solution: string
resolution: string
is_resolved: boolean
replied_to_customer: boolean
impact_scope: string
environment: string
related_system: string
@@ -47,6 +66,7 @@ export type TicketListParams = {
page_size?: number
status?: string
priority?: string
category_code?: string
q?: string
related_system?: string
assignee_id?: number
@@ -56,7 +76,12 @@ export type TicketListParams = {
export type TicketInput = {
title: string
description: string
category_code: TicketCategoryCode
priority: TicketPriority
problem_performance: string
problem_object: string
submitter_name: string
submitter_org: string
impact_scope: string
environment: string
related_system: string