feat: consolidate ticket domain baseline

This commit is contained in:
hailin
2026-07-27 18:57:51 +08:00
parent 5015e4407d
commit 96452934a3
25 changed files with 4236 additions and 218 deletions

View File

@@ -0,0 +1,105 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TicketCollaborator, TicketUser } from './types'
Object.defineProperties(Element.prototype, {
hasPointerCapture: { value: vi.fn(() => false), configurable: true },
setPointerCapture: { value: vi.fn(), configurable: true },
releasePointerCapture: { value: vi.fn(), configurable: true },
scrollIntoView: { value: vi.fn(), configurable: true }
})
const listTicketCollaborators = vi.fn()
const listTicketParticipants = vi.fn()
const addTicketCollaborator = vi.fn()
const removeTicketCollaborator = vi.fn()
vi.mock('./api', () => ({
listTicketCollaborators: (...args: unknown[]) => listTicketCollaborators(...args),
listTicketParticipants: (...args: unknown[]) => listTicketParticipants(...args),
addTicketCollaborator: (...args: unknown[]) => addTicketCollaborator(...args),
removeTicketCollaborator: (...args: unknown[]) => removeTicketCollaborator(...args)
}))
import { TicketCollaborationPanel } from './TicketCollaborationPanel'
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
const current: TicketCollaborator = {
id: 11,
user: { id: 3, username: 'support-a', display_name: '支持甲' },
role: 'collaborator',
added_by: { id: 1, username: 'admin', display_name: '管理员' },
created_at: '2026-07-27T08:00:00Z'
}
const watcher: TicketUser = {
id: 4,
username: 'support-b',
display_name: '支持乙'
}
describe('TicketCollaborationPanel', () => {
it('展示现有成员并允许移除', async () => {
const user = userEvent.setup()
listTicketCollaborators.mockResolvedValue([current])
listTicketParticipants.mockResolvedValue([current.user, watcher])
removeTicketCollaborator.mockResolvedValue({ success: true })
const onChanged = vi.fn()
render(<TicketCollaborationPanel ticketId={7} canManage onChanged={onChanged} />)
expect(await screen.findByText('支持甲')).toBeInTheDocument()
expect(screen.getAllByText('协作人')).not.toHaveLength(0)
await user.click(screen.getByRole('button', { name: '移除支持甲' }))
await waitFor(() => {
expect(removeTicketCollaborator).toHaveBeenCalledWith(7, 11)
})
expect(screen.queryByText('支持甲')).not.toBeInTheDocument()
expect(onChanged).toHaveBeenCalledOnce()
})
it('从启用账号中添加关注人', async () => {
const user = userEvent.setup()
listTicketCollaborators.mockResolvedValue([])
listTicketParticipants.mockResolvedValue([watcher])
addTicketCollaborator.mockResolvedValue({
id: 12,
user: watcher,
role: 'watcher',
added_by: current.added_by,
created_at: '2026-07-27T08:20:00Z'
} satisfies TicketCollaborator)
render(<TicketCollaborationPanel ticketId={7} canManage />)
await screen.findByText('暂无协作成员')
await user.click(screen.getByRole('combobox', { name: '选择协作成员' }))
await user.click(await screen.findByRole('option', { name: /支持乙/ }))
await user.click(screen.getByRole('combobox', { name: '选择协作角色' }))
await user.click(await screen.findByRole('option', { name: '关注人' }))
await user.click(screen.getByRole('button', { name: '添加成员' }))
await waitFor(() => {
expect(addTicketCollaborator).toHaveBeenCalledWith(7, 4, 'watcher')
})
expect(await screen.findByText('支持乙')).toBeInTheDocument()
})
it('只读账号不加载参与人目录也不显示管理按钮', async () => {
listTicketCollaborators.mockResolvedValue([current])
render(<TicketCollaborationPanel ticketId={7} canManage={false} />)
expect(await screen.findByText('支持甲')).toBeInTheDocument()
expect(listTicketParticipants).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: '移除支持甲' })).not.toBeInTheDocument()
expect(screen.getByText(/没有分派权限/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,234 @@
import * as React from 'react'
import { Eye, Handshake, Trash2, UserPlus, Users } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppSection } from '@/components/app/AppSection'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
addTicketCollaborator,
listTicketCollaborators,
listTicketParticipants,
removeTicketCollaborator
} from './api'
import type {
TicketCollaborator,
TicketCollaboratorRole,
TicketUser
} from './types'
type TicketCollaborationPanelProps = {
ticketId: number
canManage: boolean
onChanged?: () => void
}
function displayUser(user: TicketUser) {
return user.display_name || user.username
}
function roleLabel(role: TicketCollaboratorRole) {
return role === 'watcher' ? '关注人' : '协作人'
}
export function TicketCollaborationPanel({
ticketId,
canManage,
onChanged
}: TicketCollaborationPanelProps) {
const [collaborators, setCollaborators] = React.useState<TicketCollaborator[]>([])
const [participants, setParticipants] = React.useState<TicketUser[]>([])
const [selectedUserId, setSelectedUserId] = React.useState('')
const [role, setRole] = React.useState<TicketCollaboratorRole>('collaborator')
const [loading, setLoading] = React.useState(true)
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
try {
const [nextCollaborators, nextParticipants] = await Promise.all([
listTicketCollaborators(ticketId),
canManage ? listTicketParticipants() : Promise.resolve([])
])
setCollaborators(nextCollaborators)
setParticipants(nextParticipants)
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '协作成员加载失败')
} finally {
setLoading(false)
}
}, [canManage, ticketId])
React.useEffect(() => {
setLoading(true)
setSelectedUserId('')
void load()
}, [load])
const assignedUserIds = React.useMemo(
() => new Set(collaborators.map((item) => item.user.id)),
[collaborators]
)
const availableParticipants = participants.filter((user) => !assignedUserIds.has(user.id))
async function handleAdd() {
if (!selectedUserId) return
setSaving(true)
setError('')
try {
const created = await addTicketCollaborator(ticketId, Number(selectedUserId), role)
setCollaborators((current) => [...current, created])
setSelectedUserId('')
onChanged?.()
toast.success(`${roleLabel(role)}已添加`)
} catch (reason) {
const message = reason instanceof Error ? reason.message : '添加协作成员失败'
setError(message)
toast.error(message)
} finally {
setSaving(false)
}
}
async function handleRemove(item: TicketCollaborator) {
setSaving(true)
setError('')
try {
await removeTicketCollaborator(ticketId, item.id)
setCollaborators((current) => current.filter((candidate) => candidate.id !== item.id))
onChanged?.()
toast.success(`${roleLabel(item.role)}已移除`)
} catch (reason) {
const message = reason instanceof Error ? reason.message : '移除协作成员失败'
setError(message)
toast.error(message)
} finally {
setSaving(false)
}
}
return (
<AppSection
variant="card"
title="协作成员"
description="协作人参与处理,关注人只需持续获知进展。"
>
<div className="grid gap-3">
{error ? (
<Alert variant="destructive">
<AlertTitle>协作成员暂不可用</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<AppEmptyState title="正在加载成员" />
) : collaborators.length === 0 ? (
<AppEmptyState title="暂无协作成员" description="需要跨成员处理时可在这里添加。" />
) : (
<ul className="grid gap-2" aria-label="工单协作成员">
{collaborators.map((item) => {
const RoleIcon = item.role === 'watcher' ? Eye : Handshake
return (
<li
key={item.id}
className="flex items-center justify-between gap-2 rounded-lg border bg-muted/15 p-2.5"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{displayUser(item.user)}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge variant={item.role === 'watcher' ? 'outline' : 'secondary'}>
<RoleIcon />
{roleLabel(item.role)}
</Badge>
<span className="truncate text-xs text-muted-foreground">
@{item.user.username}
</span>
</div>
</div>
{canManage ? (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`移除${displayUser(item.user)}`}
disabled={saving}
onClick={() => void handleRemove(item)}
>
<Trash2 />
</Button>
) : null}
</li>
)
})}
</ul>
)}
{canManage ? (
<div className="grid gap-2 border-t pt-3">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_110px]">
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger aria-label="选择协作成员">
<SelectValue placeholder="选择启用账号" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableParticipants.map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{displayUser(user)} · {user.username}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={role}
onValueChange={(value) => setRole(value as TicketCollaboratorRole)}
>
<SelectTrigger aria-label="选择协作角色">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="collaborator">协作人</SelectItem>
<SelectItem value="watcher">关注人</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!selectedUserId || saving}
onClick={() => void handleAdd()}
>
<UserPlus data-icon="inline-start" />
{saving ? '正在更新' : '添加成员'}
</Button>
{availableParticipants.length === 0 && participants.length > 0 ? (
<p className="text-xs text-muted-foreground">所有启用账号均已加入此工单。</p>
) : null}
</div>
) : (
<p className="flex items-center gap-2 border-t pt-3 text-xs text-muted-foreground">
<Users className="size-4" />
当前账号没有分派权限,只能查看成员。
</p>
)}
</div>
</AppSection>
)
}

View File

@@ -0,0 +1,74 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TicketComment } from './types'
const listTicketComments = vi.fn()
const addTicketComment = vi.fn()
vi.mock('./api', () => ({
listTicketComments: (...args: unknown[]) => listTicketComments(...args),
addTicketComment: (...args: unknown[]) => addTicketComment(...args)
}))
import { TicketComments } from './TicketComments'
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
const history: TicketComment[] = [
{
id: 1,
content: '已检查应用日志,等待数据库反馈。',
comment_type: 'internal_note',
visibility: 'internal',
created_by: { id: 3, username: 'support', display_name: '技术支持' },
created_at: '2026-07-27T08:00:00Z'
}
]
describe('TicketComments', () => {
it('加载完整历史,并新增内部备注后保留在时间线', async () => {
const user = userEvent.setup()
const created: TicketComment = {
...history[0],
id: 2,
content: '数据库连接数正常,继续检查网关。',
created_at: '2026-07-27T08:10:00Z'
}
listTicketComments.mockResolvedValue(history)
addTicketComment.mockResolvedValue(created)
const onCommentAdded = vi.fn()
render(<TicketComments ticketId={7} canComment onCommentAdded={onCommentAdded} />)
expect(await screen.findByText('已检查应用日志,等待数据库反馈。')).toBeInTheDocument()
expect(screen.getByText('技术支持')).toBeInTheDocument()
expect(screen.getByText('仅内部')).toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: '评论内容' }), created.content)
await user.click(screen.getByRole('button', { name: '保存内部备注' }))
await waitFor(() => {
expect(addTicketComment).toHaveBeenCalledWith(7, created.content, {
comment_type: 'internal_note',
visibility: 'internal'
})
})
expect(await screen.findByText(created.content)).toBeInTheDocument()
expect(onCommentAdded).toHaveBeenCalledOnce()
})
it('只读账号可查看历史但不能填写评论', async () => {
listTicketComments.mockResolvedValue(history)
render(<TicketComments ticketId={7} canComment={false} />)
expect(await screen.findByText(history[0].content)).toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: '评论内容' })).not.toBeInTheDocument()
expect(screen.getByText(/只有查看权限/)).toBeInTheDocument()
})
})

View File

@@ -1,70 +1,193 @@
import * as React from 'react'
import { Eye, LockKeyhole, MessageSquareText, Send } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppSection } from '@/components/app/AppSection'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { formatDateTime } from '@/lib/format'
import { addTicketComment } from './api'
import { addTicketComment, listTicketComments } from './api'
import type { TicketComment } from './types'
type TicketCommentsProps = {
ticketId: number
canComment: boolean
onCommentAdded?: () => void
}
export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps) {
function commentAuthor(comment: TicketComment) {
return comment.created_by?.display_name || comment.created_by?.username || '系统'
}
export function TicketComments({
ticketId,
canComment,
onCommentAdded
}: TicketCommentsProps) {
const [comments, setComments] = React.useState<TicketComment[]>([])
const [content, setContent] = React.useState('')
const [lastComment, setLastComment] = React.useState('')
const [commentMode, setCommentMode] = React.useState('internal')
const [commentMode, setCommentMode] = React.useState<'internal' | 'public'>('internal')
const [loading, setLoading] = React.useState(true)
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState('')
const loadComments = React.useCallback(async () => {
try {
setComments(await listTicketComments(ticketId))
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '评论历史加载失败')
} finally {
setLoading(false)
}
}, [ticketId])
React.useEffect(() => {
setLoading(true)
void loadComments()
}, [loadComments])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!content.trim()) return
const comment = await addTicketComment(ticketId, content, {
comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note',
visibility: commentMode === 'public' ? 'public' : 'internal'
})
setLastComment(comment.content)
setContent('')
onCommentAdded?.()
const nextContent = content.trim()
if (!nextContent || !canComment) return
setSubmitting(true)
setError('')
try {
const comment = await addTicketComment(ticketId, nextContent, {
comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note',
visibility: commentMode === 'public' ? 'public' : 'internal'
})
setComments((current) => [...current, comment])
setContent('')
onCommentAdded?.()
toast.success(commentMode === 'public' ? '客户可见记录已保存' : '内部备注已保存')
} catch (reason) {
const message = reason instanceof Error ? reason.message : '评论保存失败'
setError(message)
toast.error(message)
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<AppSection
variant="card"
title="评论与协作"
description="选择内容的可见范围后再保存。"
footer={
<>
<span className="text-xs text-muted-foreground">{commentMode === 'public' ? '用户可以看到此回复' : '仅内部成员可见'}</span>
<Button type="submit" variant="secondary" disabled={!content.trim()}>
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
</Button>
</>
}
>
<div className="grid gap-3">
<Select value={commentMode} onValueChange={setCommentMode}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="选择回复类型" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="internal">内部备注</SelectItem>
<SelectItem value="public">用户可见回复</SelectItem>
</SelectGroup>
</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>
</AppSection>
</form>
<AppSection
variant="card"
title="评论记录"
description="按时间保留内部协作和客户可见记录;客户可见记录不代表渠道消息已经送达。"
>
<div className="grid gap-4">
{error ? (
<Alert variant="destructive">
<AlertTitle>评论暂不可用</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<AppEmptyState title="正在加载评论" />
) : comments.length === 0 ? (
<AppEmptyState title="暂无评论" description="排查记录和沟通纪要会按时间显示在这里。" />
) : (
<ol className="grid gap-3" aria-label="评论历史">
{comments.map((comment) => {
const isPublic = comment.visibility === 'public'
const VisibilityIcon = isPublic ? Eye : LockKeyhole
return (
<li key={comment.id} className="rounded-lg border bg-muted/15 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{commentAuthor(comment)}</span>
<Badge variant={isPublic ? 'info' : 'secondary'}>
<VisibilityIcon />
{isPublic ? '客户可见' : '仅内部'}
</Badge>
</div>
<time
className="text-xs text-muted-foreground"
dateTime={comment.created_at}
>
{formatDateTime(comment.created_at)}
</time>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6">{comment.content}</p>
</li>
)
})}
</ol>
)}
{canComment ? (
<form className="grid gap-3 border-t pt-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Select
value={commentMode}
onValueChange={(value) => setCommentMode(value as 'internal' | 'public')}
>
<SelectTrigger className="w-full sm:w-48" aria-label="评论可见范围">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="internal">内部备注</SelectItem>
<SelectItem value="public">客户可见记录</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{commentMode === 'public'
? '此处只记录客户可见内容,实际发送仍须通过客户回复任务。'
: '只有内部支持成员可以看到。'}
</span>
</div>
<Textarea
aria-label="评论内容"
placeholder={
commentMode === 'public'
? '记录已向客户说明的处理进展'
: '记录排查过程、协作结论或后续动作'
}
value={content}
maxLength={10000}
onChange={(event) => setContent(event.target.value)}
/>
<div className="flex items-center justify-between gap-3">
<span className="text-xs tabular-nums text-muted-foreground">
{content.length}/10000
</span>
<Button type="submit" variant="secondary" disabled={!content.trim() || submitting}>
{submitting ? (
<MessageSquareText data-icon="inline-start" />
) : (
<Send data-icon="inline-start" />
)}
{submitting
? '正在保存'
: commentMode === 'public'
? '保存客户记录'
: '保存内部备注'}
</Button>
</div>
</form>
) : (
<p className="border-t pt-4 text-sm text-muted-foreground">
当前账号只有查看权限,不能新增评论。
</p>
)}
</div>
</AppSection>
)
}

View File

@@ -0,0 +1,98 @@
import * as React from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TicketEditSheet } from './TicketEditSheet'
import type { Ticket } from './types'
const mocks = vi.hoisted(() => ({
updateTicket: vi.fn()
}))
vi.mock('./api', () => ({
updateTicket: mocks.updateTicket
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
const ticket: Ticket = {
id: 7,
ticket_no: 'TK202607270007',
title: '报销单无法提交',
description: '华南公司反馈生产环境报销单无法提交。',
category: null,
category_code: null,
status: 'pending_info',
priority: 'medium',
problem_performance: '点击提交没有响应',
problem_object: '报销单',
submitter_name: '张三',
submitter_org: '',
resolution: '',
resolution_id: null,
resolution_type: null,
resolution_source: null,
resolution_root_cause: '',
linked_solution_id: null,
is_resolved: false,
replied_to_customer: false,
impact_scope: '',
environment: '生产环境',
related_system: '财务系统',
product_name: '财务系统',
module_name: '报销模块',
tags: ['报销'],
attempted_actions: '',
error_information: '',
customer_request: '恢复报销提交',
current_result: '尚未恢复',
requester_id: 1,
assignee_id: null,
requester: { id: 1, username: 'support', display_name: '技术支持' },
assignee: null,
problem_started_at: null,
customer_last_message_at: null,
support_first_response_at: null,
support_last_reply_at: null,
resolved_at: null,
closed_at: null,
archived_at: null,
archived_by: null,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z'
}
describe('TicketEditSheet', () => {
it('突出待补全字段并只提交人工实际修改的字段', async () => {
const user = userEvent.setup()
const onSaved = vi.fn()
const onOpenChange = vi.fn()
const updated = { ...ticket, submitter_org: '华南公司', status: 'pending' as const }
mocks.updateTicket.mockResolvedValue(updated)
render(
<TicketEditSheet
ticket={ticket}
missingFields={['submitter_org']}
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>
)
expect(screen.getByRole('heading', { name: '补全工单信息' })).toBeInTheDocument()
expect(screen.getByText('待补全')).toBeInTheDocument()
await user.type(screen.getByLabelText(/提出机构/), '华南公司')
await user.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => {
expect(mocks.updateTicket).toHaveBeenCalledWith(7, { submitter_org: '华南公司' })
})
expect(onSaved).toHaveBeenCalledWith(updated)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,400 @@
import * as React from 'react'
import { Save, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { updateTicket } from './api'
import { TICKET_CATEGORIES } from './ticketCategories'
import type { Ticket, TicketCategoryCode, TicketPriority, TicketUpdateInput } from './types'
type TicketEditSheetProps = {
ticket: Ticket
missingFields: string[]
open: boolean
onOpenChange: (open: boolean) => void
onSaved: (ticket: Ticket) => void
}
type FormValues = {
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
product_name: string
module_name: string
tags: string
attempted_actions: string
error_information: string
customer_request: string
current_result: string
problem_started_at: string
customer_last_message_at: string
support_first_response_at: string
support_last_reply_at: string
}
const TEXT_FIELDS = [
'title',
'description',
'problem_performance',
'problem_object',
'submitter_name',
'submitter_org',
'impact_scope',
'environment',
'related_system',
'product_name',
'module_name',
'attempted_actions',
'error_information',
'customer_request',
'current_result'
] as const
const TIME_FIELDS = [
'problem_started_at',
'customer_last_message_at',
'support_first_response_at',
'support_last_reply_at'
] as const
function toLocalDateTime(value: string | null) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (item: number) => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function toIsoDateTime(value: string) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function normalizeTags(value: string) {
return [...new Set(value.split(/[,,]/).map((item) => item.trim()).filter(Boolean))].slice(0, 20)
}
function initialValues(ticket: Ticket): FormValues {
return {
title: ticket.title,
description: ticket.description,
category_code: ticket.category_code ?? '',
priority: ticket.priority,
problem_performance: ticket.problem_performance,
problem_object: ticket.problem_object,
submitter_name: ticket.submitter_name,
submitter_org: ticket.submitter_org,
impact_scope: ticket.impact_scope,
environment: ticket.environment,
related_system: ticket.related_system,
product_name: ticket.product_name,
module_name: ticket.module_name,
tags: ticket.tags.join(','),
attempted_actions: ticket.attempted_actions,
error_information: ticket.error_information,
customer_request: ticket.customer_request,
current_result: ticket.current_result,
problem_started_at: toLocalDateTime(ticket.problem_started_at),
customer_last_message_at: toLocalDateTime(ticket.customer_last_message_at),
support_first_response_at: toLocalDateTime(ticket.support_first_response_at),
support_last_reply_at: toLocalDateTime(ticket.support_last_reply_at)
}
}
export function buildTicketChanges(ticket: Ticket, values: FormValues): TicketUpdateInput {
// 只提交真实变化,避免把未触碰的 Agent 字段误标记为人工保护字段。
const changes: TicketUpdateInput = {}
TEXT_FIELDS.forEach((field) => {
if (values[field] !== ticket[field]) changes[field] = values[field]
})
if ((values.category_code || null) !== ticket.category_code) {
changes.category_code = values.category_code || null
}
if (values.priority !== ticket.priority) changes.priority = values.priority
const tags = normalizeTags(values.tags)
if (JSON.stringify(tags) !== JSON.stringify(ticket.tags)) changes.tags = tags
TIME_FIELDS.forEach((field) => {
const value = toIsoDateTime(values[field])
const current = ticket[field] ? new Date(ticket[field]).toISOString() : null
if (value !== current) changes[field] = value
})
return changes
}
export function TicketEditSheet({
ticket,
missingFields,
open,
onOpenChange,
onSaved
}: TicketEditSheetProps) {
const [values, setValues] = React.useState<FormValues>(() => initialValues(ticket))
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState('')
const missing = React.useMemo(() => new Set(missingFields), [missingFields])
React.useEffect(() => {
if (open) {
setValues(initialValues(ticket))
setError('')
}
}, [open, ticket])
function setField<K extends keyof FormValues>(field: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [field]: value }))
}
function label(field: string, text: string) {
return (
<span className="flex items-center gap-2">
<span>{text}</span>
{missing.has(field) ? <Badge variant="warning">待补全</Badge> : null}
</span>
)
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const changes = buildTicketChanges(ticket, values)
if (Object.keys(changes).length === 0) {
onOpenChange(false)
return
}
setSaving(true)
setError('')
try {
const updated = await updateTicket(ticket.id, changes)
onSaved(updated)
onOpenChange(false)
toast.success('工单信息已保存')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存失败,请检查输入后重试')
} finally {
setSaving(false)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-2xl">
<SheetHeader>
<SheetTitle>补全工单信息</SheetTitle>
<SheetDescription>
只保存发生变化的字段。人工修改会进入保护状态,后续 Agent 不会静默覆盖。
</SheetDescription>
</SheetHeader>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4">
<Alert>
<ShieldCheck />
<AlertTitle>人工信息优先</AlertTitle>
<AlertDescription>
时间按当前设备时区转换并保存为带时区时间;不确定的时间请保持为空。
</AlertDescription>
</Alert>
{error ? (
<Alert variant="destructive">
<AlertTitle>信息未保存</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<FieldSet>
<FieldLegend>核心问题</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel htmlFor="ticket-title">{label('title', '工单标题')}</FieldLabel>
<Input id="ticket-title" value={values.title} onChange={(event) => setField('title', event.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ticket-description">{label('description', '问题描述')}</FieldLabel>
<Textarea id="ticket-description" rows={5} value={values.description} onChange={(event) => setField('description', event.target.value)} />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>{label('category_code', '问题分类')}</FieldLabel>
<Select value={values.category_code || 'unclassified'} onValueChange={(value) => setField('category_code', value === 'unclassified' ? '' : value as TicketCategoryCode)}>
<SelectTrigger><SelectValue placeholder="选择问题分类" /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="unclassified">暂不分类</SelectItem>
{TICKET_CATEGORIES.map((category) => (
<SelectItem key={category.code} value={category.code}>{category.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>优先级</FieldLabel>
<Select value={values.priority} onValueChange={(value) => setField('priority', value as TicketPriority)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="low">低</SelectItem>
<SelectItem value="medium">中</SelectItem>
<SelectItem value="high">高</SelectItem>
<SelectItem value="urgent">紧急</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>提出方与业务上下文</FieldLegend>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<TextField id="ticket-submitter-name" label={label('submitter_name', '提出人')} value={values.submitter_name} onChange={(value) => setField('submitter_name', value)} />
<TextField id="ticket-submitter-org" label={label('submitter_org', '提出机构')} value={values.submitter_org} onChange={(value) => setField('submitter_org', value)} />
<TextField id="ticket-problem-object" label={label('problem_object', '问题对象')} value={values.problem_object} onChange={(value) => setField('problem_object', value)} />
<TextField id="ticket-environment" label={label('environment', '运行环境')} value={values.environment} onChange={(value) => setField('environment', value)} />
<TextField id="ticket-product-name" label={label('product_name', '产品')} value={values.product_name} onChange={(value) => setField('product_name', value)} />
<TextField id="ticket-module-name" label={label('module_name', '模块')} value={values.module_name} onChange={(value) => setField('module_name', value)} />
<TextField id="ticket-related-system" label={label('related_system', '关联系统')} value={values.related_system} onChange={(value) => setField('related_system', value)} />
<TextField id="ticket-impact-scope" label={label('impact_scope', '影响范围')} value={values.impact_scope} onChange={(value) => setField('impact_scope', value)} />
</div>
<Field>
<FieldLabel htmlFor="ticket-tags">标签</FieldLabel>
<Input id="ticket-tags" value={values.tags} onChange={(event) => setField('tags', event.target.value)} placeholder="使用逗号分隔,例如 E500,报销" />
<FieldDescription>最多保存 20 个标签,每个标签最长 50 个字符。</FieldDescription>
</Field>
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>问题过程</FieldLegend>
<FieldGroup>
<LongTextField id="ticket-performance" label={label('problem_performance', '问题表现')} value={values.problem_performance} onChange={(value) => setField('problem_performance', value)} />
<LongTextField id="ticket-error" label={label('error_information', '错误信息')} value={values.error_information} onChange={(value) => setField('error_information', value)} />
<LongTextField id="ticket-actions" label={label('attempted_actions', '已尝试动作')} value={values.attempted_actions} onChange={(value) => setField('attempted_actions', value)} />
<LongTextField id="ticket-request" label={label('customer_request', '客户诉求')} value={values.customer_request} onChange={(value) => setField('customer_request', value)} />
<LongTextField id="ticket-result" label={label('current_result', '当前结果')} value={values.current_result} onChange={(value) => setField('current_result', value)} />
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>业务时间</FieldLegend>
<FieldDescription>仅填写材料中能够可靠确认的时间。</FieldDescription>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<DateTimeField id="ticket-problem-started" label={label('problem_started_at', '问题开始时间')} value={values.problem_started_at} onChange={(value) => setField('problem_started_at', value)} />
<DateTimeField id="ticket-customer-last" label={label('customer_last_message_at', '客户最后消息')} value={values.customer_last_message_at} onChange={(value) => setField('customer_last_message_at', value)} />
<DateTimeField id="ticket-support-first" label={label('support_first_response_at', '支持首次响应')} value={values.support_first_response_at} onChange={(value) => setField('support_first_response_at', value)} />
<DateTimeField id="ticket-support-last" label={label('support_last_reply_at', '支持最后回复')} value={values.support_last_reply_at} onChange={(value) => setField('support_last_reply_at', value)} />
</div>
</FieldGroup>
</FieldSet>
</div>
<SheetFooter>
<SheetClose asChild>
<Button type="button" variant="outline" disabled={saving}>取消</Button>
</SheetClose>
<Button type="submit" disabled={saving}>
<Save data-icon="inline-start" />
{saving ? '正在保存' : '保存修改'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
function TextField({
id,
label,
value,
onChange
}: {
id: string
label: React.ReactNode
value: string
onChange: (value: string) => void
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input id={id} value={value} onChange={(event) => onChange(event.target.value)} />
</Field>
)
}
function LongTextField({
id,
label,
value,
onChange
}: {
id: string
label: React.ReactNode
value: string
onChange: (value: string) => void
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Textarea id={id} rows={3} value={value} onChange={(event) => onChange(event.target.value)} />
</Field>
)
}
function DateTimeField({
id,
label,
value,
onChange
}: {
id: string
label: React.ReactNode
value: string
onChange: (value: string) => void
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input id={id} type="datetime-local" value={value} onChange={(event) => onChange(event.target.value)} />
</Field>
)
}

View File

@@ -34,7 +34,6 @@ type TicketTransitionDialogProps = {
values: {
remark: string
resolution: string
is_resolved?: boolean
replied_to_customer: boolean
}
) => Promise<void>
@@ -80,7 +79,6 @@ export function TicketTransitionDialog({
await onSubmit(status, {
remark: remark.trim(),
resolution: resolution.trim(),
is_resolved: status === 'resolved' ? true : undefined,
replied_to_customer: repliedToCustomer
})
onOpenChange(false)

View File

@@ -1,13 +1,24 @@
import { apiRequest } from '@/lib/api'
import type {
CustomerReplyTask,
Ticket,
TicketAttachment,
TicketCollaborator,
TicketCollaboratorRole,
TicketComment,
TicketEvent,
TicketFieldEvidence,
TicketInput,
TicketIntakeTrace,
TicketKnowledgeDistillation,
TicketListParams,
TicketListResponse,
TicketStatus
TicketSimilaritySuggestion,
TicketSupportAnalysis,
TicketStatus,
TicketUpdateInput,
TicketUser
} from './types'
export function listTickets(params: TicketListParams = {}) {
@@ -32,17 +43,95 @@ export function getTicket(ticketId: number) {
return apiRequest<Ticket>(`/tickets/${ticketId}`)
}
export function updateTicket(ticketId: number, payload: TicketUpdateInput) {
return apiRequest<Ticket>(`/tickets/${ticketId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listTicketEvents(ticketId: number) {
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
}
export function listTicketFieldEvidence(ticketId: number) {
return apiRequest<TicketFieldEvidence[]>(`/tickets/${ticketId}/field-evidence`)
}
export function listTicketAttachments(ticketId: number) {
return apiRequest<TicketAttachment[]>(`/tickets/${ticketId}/attachments`)
}
export function getTicketIntakeTrace(ticketId: number) {
return apiRequest<TicketIntakeTrace>(`/tickets/${ticketId}/intake-trace`)
}
export function listSimilarTickets(ticketId: number) {
return apiRequest<TicketSimilaritySuggestion[]>(`/tickets/${ticketId}/similar`)
}
export function listTicketSupportAnalyses(ticketId: number) {
return apiRequest<TicketSupportAnalysis[]>(`/tickets/${ticketId}/support-analyses`)
}
export function startTicketSupportAnalysis(ticketId: number) {
return apiRequest<TicketSupportAnalysis>(`/tickets/${ticketId}/support-analyses`, {
method: 'POST'
})
}
export function listTicketKnowledgeDistillations(ticketId: number) {
return apiRequest<TicketKnowledgeDistillation[]>(
`/tickets/${ticketId}/knowledge-distillations`
)
}
export function startTicketKnowledgeDistillation(ticketId: number) {
return apiRequest<TicketKnowledgeDistillation>(
`/tickets/${ticketId}/knowledge-distillations`,
{ method: 'POST' }
)
}
export function listCustomerReplyTasks(ticketId: number) {
return apiRequest<CustomerReplyTask[]>(`/tickets/${ticketId}/reply-tasks`)
}
export function updateCustomerReplyTask(
ticketId: number,
replyTaskId: number,
finalText: string,
expectedRevision: number
) {
return apiRequest<CustomerReplyTask>(`/tickets/${ticketId}/reply-tasks/${replyTaskId}`, {
method: 'PATCH',
body: JSON.stringify({
final_text: finalText,
expected_revision: expectedRevision
})
})
}
export function confirmCustomerReplySent(
ticketId: number,
replyTaskId: number,
expectedRevision: number
) {
return apiRequest<CustomerReplyTask>(
`/tickets/${ticketId}/reply-tasks/${replyTaskId}/confirm-sent`,
{
method: 'POST',
body: JSON.stringify({ expected_revision: expectedRevision })
}
)
}
export function transitionTicket(
ticketId: number,
status: TicketStatus,
options: {
remark?: string
resolution?: string
is_resolved?: boolean
replied_to_customer?: boolean
} = {}
) {
@@ -52,7 +141,6 @@ export function transitionTicket(
status,
remark: options.remark ?? '',
resolution: options.resolution ?? '',
is_resolved: options.is_resolved,
replied_to_customer: options.replied_to_customer
})
})
@@ -72,3 +160,33 @@ export function addTicketComment(
})
})
}
export function listTicketComments(ticketId: number) {
return apiRequest<TicketComment[]>(`/tickets/${ticketId}/comments`)
}
export function listTicketParticipants() {
return apiRequest<TicketUser[]>('/tickets/participants')
}
export function listTicketCollaborators(ticketId: number) {
return apiRequest<TicketCollaborator[]>(`/tickets/${ticketId}/collaborators`)
}
export function addTicketCollaborator(
ticketId: number,
userId: number,
role: TicketCollaboratorRole
) {
return apiRequest<TicketCollaborator>(`/tickets/${ticketId}/collaborators`, {
method: 'POST',
body: JSON.stringify({ user_id: userId, role })
})
}
export function removeTicketCollaborator(ticketId: number, collaboratorId: number) {
return apiRequest<{ success: boolean }>(
`/tickets/${ticketId}/collaborators/${collaboratorId}`,
{ method: 'DELETE' }
)
}

View File

@@ -36,17 +36,36 @@ export type Ticket = {
problem_object: string
submitter_name: string
submitter_org: string
ai_solution: string
resolution: string
resolution_id: number | null
resolution_type: string | null
resolution_source: string | null
resolution_root_cause: string
linked_solution_id: number | null
is_resolved: boolean
replied_to_customer: boolean
impact_scope: string
environment: string
related_system: string
product_name: string
module_name: string
tags: string[]
attempted_actions: string
error_information: string
customer_request: string
current_result: string
requester_id: number | null
assignee_id: number | null
requester: TicketUser | null
assignee: TicketUser | null
problem_started_at: string | null
customer_last_message_at: string | null
support_first_response_at: string | null
support_last_reply_at: string | null
resolved_at: string | null
closed_at: string | null
archived_at: string | null
archived_by: TicketUser | null
created_at: string
updated_at: string
}
@@ -61,6 +80,131 @@ export type TicketListResponse = {
export type TicketListView = 'all' | 'assigned_to_me' | 'created_by_me' | 'high_priority'
export type SupportAnalysisReference = {
rank: number
source_type: 'knowledge' | 'solution' | 'historical_ticket'
source_id: number
source_version: string
title: string
excerpt: string
score: number
applicable_scope: Record<string, unknown>
auto_resolve_policy?: {
allow_auto_resolve: boolean
requires_human_action: boolean
requires_external_confirmation: boolean
requires_production_change: boolean
}
}
export type SupportAnalysisOutput = {
analysis_summary: string
troubleshooting_steps: string[]
candidate_solution_ids: number[]
customer_reply_draft: string
citations: number[]
auto_resolve_recommended: boolean
}
export type TicketSupportAnalysis = {
id: number
ticket_id: number
run_public_id: string
agent_version: number
status: string
progress_stage: string
progress_message: string
query: string
retrieval_policy: Record<string, unknown>
references: SupportAnalysisReference[]
final_output: SupportAnalysisOutput | null
automation_decision: {
policy_version?: string
status?: 'eligible' | 'manual_recommendation' | 'auto_resolved'
threshold?: number
solution_id?: number | null
score?: number
checks?: Record<string, boolean>
reasons?: string[]
applied?: boolean
evaluated_at?: string
}
applied_solution_id: number | null
processed_at: string | null
error_code: string
error_message: string
created_at: string
started_at: string | null
finished_at: string | null
}
export type KnowledgeDistillationOutput = {
should_distill: boolean
reason: string
assessment: {
novelty_score: number
reuse_value_score: number
completeness_score: number
duplicate_risk_score: number
}
citations: number[]
knowledge_draft: Record<string, unknown>
troubleshooting_plan_draft: Record<string, unknown>
solution_draft: Record<string, unknown>
}
export type TicketKnowledgeDistillation = {
id: number
ticket_id: number
run_public_id: string
agent_version: number
status: string
progress_stage: string
progress_message: string
references: SupportAnalysisReference[]
retrieval_policy: Record<string, unknown>
final_output: KnowledgeDistillationOutput | null
should_distill: boolean | null
reason: string
assessment: Record<string, number>
knowledge_item_id: number | null
troubleshooting_plan_id: number | null
solution_id: number | null
processed_at: string | null
error_code: string
error_message: string
created_at: string
started_at: string | null
finished_at: string | null
}
export type CustomerReplyRevision = {
version: number
content: string
source: 'agent' | 'human' | 'channel' | 'system'
edited_by_id: number | null
created_at: string
}
export type CustomerReplyTask = {
id: number
ticket_id: number
support_analysis_id: number | null
status: 'draft' | 'approved' | 'sent' | 'cancelled'
draft_text: string
final_text: string
revision: number
created_by_id: number | null
updated_by_id: number | null
approved_by_id: number | null
approved_at: string | null
sent_by_id: number | null
sent_at: string | null
created_at: string
updated_at: string
revisions: CustomerReplyRevision[]
}
export type TicketListParams = {
page?: number
page_size?: number
@@ -73,6 +217,7 @@ export type TicketListParams = {
view?: TicketListView
status_group?: 'active' | 'completed' | ''
resolved_within_days?: number
include_archived?: boolean
}
export type TicketInput = {
@@ -87,13 +232,56 @@ export type TicketInput = {
impact_scope: string
environment: string
related_system: string
product_name?: string
module_name?: string
tags?: string[]
attempted_actions?: string
error_information?: string
customer_request?: string
current_result?: string
}
export type TicketUpdateInput = Partial<{
title: string
description: string
category_code: TicketCategoryCode | null
priority: TicketPriority
problem_performance: string
problem_object: string
submitter_name: string
submitter_org: string
impact_scope: string
environment: string
related_system: string
product_name: string
module_name: string
tags: string[]
attempted_actions: string
error_information: string
customer_request: string
current_result: string
problem_started_at: string | null
customer_last_message_at: string | null
support_first_response_at: string | null
support_last_reply_at: string | null
}>
export type TicketComment = {
id: number
content: string
comment_type: string
visibility: string
created_by: TicketUser | null
created_at: string
}
export type TicketCollaboratorRole = 'collaborator' | 'watcher'
export type TicketCollaborator = {
id: number
user: TicketUser
role: TicketCollaboratorRole
added_by: TicketUser | null
created_at: string
}
@@ -104,5 +292,82 @@ export type TicketEvent = {
to_value: Record<string, unknown>
remark: string
operator: TicketUser | null
source: 'human' | 'agent' | 'channel' | 'system'
agent_run_id: number | null
metadata: Record<string, unknown>
created_at: string
}
export type TicketFieldEvidence = {
id: number
field_name: string
value: unknown
source: 'human' | 'agent' | 'channel' | 'system'
confidence: number | null
evidence: {
excerpt?: string
source_refs?: string[]
location?: string
raw_value?: string
timezone?: string
conflicts?: string[]
generated_from?: string
}
agent_run_id: number | null
is_protected: boolean
created_by: TicketUser | null
created_at: string
}
export type TicketAttachment = {
id: number
file_name: string
file_type: string
mime_type: string
file_size: number
storage_path: string
sha256: string
scan_status: string
scan_result: Record<string, unknown>
parse_status: string
parse_error: string
parsed_text: string
created_at: string
}
export type TicketIntakeTrace = {
available: boolean
public_id: string | null
status: string
source_text: string
problem_valid: boolean | null
invalid_reason: string
missing_fields: string[]
message_timeline: Array<{
sequence: number
speaker: string
role: 'customer' | 'support' | 'unknown'
content: string
occurred_at: string
occurred_at_raw: string
timezone: string
source_ref: string
confidence: number | null
conflict: string
}>
agent_run_public_id: string | null
}
export type TicketSimilaritySuggestion = {
ticket_id: number
ticket_no: string
title: string
status: TicketStatus
category_code: TicketCategoryCode | null
score: number
title_similarity: number
keyword_score: number
matched_excerpt: string
algorithm_version: string
created_at: string | null
}