feat: consolidate ticket domain baseline
This commit is contained in:
105
frontend/src/features/tickets/TicketCollaborationPanel.test.tsx
Normal file
105
frontend/src/features/tickets/TicketCollaborationPanel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
234
frontend/src/features/tickets/TicketCollaborationPanel.tsx
Normal file
234
frontend/src/features/tickets/TicketCollaborationPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/features/tickets/TicketComments.test.tsx
Normal file
74
frontend/src/features/tickets/TicketComments.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
98
frontend/src/features/tickets/TicketEditSheet.test.tsx
Normal file
98
frontend/src/features/tickets/TicketEditSheet.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
400
frontend/src/features/tickets/TicketEditSheet.tsx
Normal file
400
frontend/src/features/tickets/TicketEditSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user