feat: add intelligent support workflow

This commit is contained in:
hailin
2026-07-27 19:00:35 +08:00
parent 93065c0a00
commit 1a49ce4eae
38 changed files with 5644 additions and 33 deletions

View File

@@ -7,8 +7,9 @@ import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Field, FieldContent, 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 { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { getTicket } from '@/features/tickets/api'
@@ -23,7 +24,11 @@ const initialForm: SolutionInput = {
root_cause: '',
solution: '',
risk_note: '',
status: 'draft'
status: 'draft',
allow_auto_resolve: false,
requires_human_action: false,
requires_external_confirmation: false,
requires_production_change: false
}
function getSourceTicketId() {
@@ -65,7 +70,7 @@ export function SolutionCreatePage() {
try {
const solution = await createSolution(form)
if (sourceTicketId) {
await linkSolutionToTicket(solution.id, sourceTicketId, 'adopted')
await linkSolutionToTicket(solution.id, sourceTicketId, 'referenced')
window.location.hash = `/tickets/${sourceTicketId}`
return
}
@@ -106,19 +111,6 @@ export function SolutionCreatePage() {
onChange={(event) => updateField('title', event.target.value)}
/>
</AppField>
<AppField label="状态" required>
<Select value={form.status} onValueChange={(value) => updateField('status', value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="published">发布</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField label="关联系统" htmlFor="solution-related-system">
<Input
id="solution-related-system"
@@ -174,25 +166,65 @@ export function SolutionCreatePage() {
</AppSection>
<AppSection title="风险提示">
<AppField label="风险提示" htmlFor="solution-risk-note">
<Textarea
id="solution-risk-note"
placeholder="可选:说明操作风险、影响范围或上线前确认事项。"
value={form.risk_note}
onChange={(event) => updateField('risk_note', event.target.value)}
/>
</AppField>
<div className="grid gap-5">
<AppField label="风险提示" htmlFor="solution-risk-note">
<Textarea
id="solution-risk-note"
placeholder="可选:说明操作风险、影响范围或上线前确认事项。"
value={form.risk_note}
onChange={(event) => updateField('risk_note', event.target.value)}
/>
</AppField>
<FieldSet>
<FieldLegend>自动化边界</FieldLegend>
<FieldGroup>
<PolicySwitch
id="allow-auto-resolve"
label="允许自动解决"
description="仅代表该方案具备自动复用资格,仍需同时满足范围、阈值、权限和全局策略。"
checked={form.allow_auto_resolve}
onCheckedChange={(checked) => updateField('allow_auto_resolve', checked)}
/>
<PolicySwitch
id="requires-human-action"
label="需要人工操作"
description="勾选后该方案只能作为人工建议,不能自动解决。"
checked={form.requires_human_action}
onCheckedChange={(checked) => updateField('requires_human_action', checked)}
/>
<PolicySwitch
id="requires-external-confirmation"
label="需要外部确认"
description="需要客户、第三方或外部系统确认时禁止自动解决。"
checked={form.requires_external_confirmation}
onCheckedChange={(checked) =>
updateField('requires_external_confirmation', checked)}
/>
<PolicySwitch
id="requires-production-change"
label="涉及生产变更"
description="任何生产变更都必须人工处理和确认。"
checked={form.requires_production_change}
onCheckedChange={(checked) =>
updateField('requires_production_change', checked)}
/>
</FieldGroup>
</FieldSet>
</div>
</AppSection>
</div>
<div className="grid content-start gap-4">
<AppSection title="提交">
<div className="grid gap-3">
<p className="text-sm text-muted-foreground">
方案固定保存为草稿,需在知识治理中心提交并由另一名审核人发布。
</p>
{sourceTicketId ? (
<p className="text-sm text-muted-foreground">保存后会自动关联到当前工单,并标记为已采纳方案。</p>
<p className="text-sm text-muted-foreground">保存后会关联到当前工单,审核发布后可由处理人正式采纳。</p>
) : null}
<Button type="submit" disabled={submitting}>
<Save className="size-4" />
<Save data-icon="inline-start" />
{submitting ? '保存中...' : '保存方案'}
</Button>
</div>
@@ -202,3 +234,27 @@ export function SolutionCreatePage() {
</AppPage>
)
}
function PolicySwitch({
id,
label,
description,
checked,
onCheckedChange
}: {
id: string
label: string
description: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<FieldDescription>{description}</FieldDescription>
</FieldContent>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
</Field>
)
}

View File

@@ -12,7 +12,7 @@ export function listSolutions(params: { status?: string } = {}) {
export function createSolution(payload: SolutionInput) {
return apiRequest<Solution>('/solutions', {
method: 'POST',
body: JSON.stringify(payload)
body: JSON.stringify({ ...payload, status: 'draft' })
})
}

View File

@@ -8,6 +8,10 @@ export type Solution = {
solution: string
risk_note: string
status: string
allow_auto_resolve: boolean
requires_human_action: boolean
requires_external_confirmation: boolean
requires_production_change: boolean
usage_count: number
created_at: string
updated_at: string
@@ -22,6 +26,10 @@ export type SolutionInput = {
solution: string
risk_note: string
status: string
allow_auto_resolve: boolean
requires_human_action: boolean
requires_external_confirmation: boolean
requires_production_change: boolean
}
export type TicketSolutionLink = {

View File

@@ -0,0 +1,303 @@
import * as React from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CustomerReplyPanel, SimilarTicketsPanel, SupportAnalysisPanel } from './TicketDetailPage'
import type {
CustomerReplyTask,
Ticket,
TicketSimilaritySuggestion,
TicketSupportAnalysis
} from './types'
afterEach(cleanup)
const suggestions: TicketSimilaritySuggestion[] = [
{
ticket_id: 42,
ticket_no: 'TK-20260727-0042',
title: '财务系统登录后持续白屏',
status: 'resolved',
category_code: 'technical_issue',
score: 0.86,
title_similarity: 0.9,
keyword_score: 0.82,
matched_excerpt: '登录财务系统后页面一直白屏,清理缓存无效。',
algorithm_version: 'fts_trigram_v1',
created_at: '2026-07-27T08:00:00Z'
}
]
describe('SimilarTicketsPanel', () => {
it('展示非阻断提示、候选分数和工单链接', () => {
render(<SimilarTicketsPanel items={suggestions} />)
expect(screen.getByText('相似工单提示')).toBeInTheDocument()
expect(screen.getByText('仅供参考,不会合并工单')).toBeInTheDocument()
expect(screen.getByText('86%')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /财务系统登录后持续白屏/ })).toHaveAttribute('href', '#/tickets/42')
})
it('没有候选时不占用详情页空间', () => {
const { container } = render(<SimilarTicketsPanel items={[]} />)
expect(container).toBeEmptyDOMElement()
})
})
const activeTicket = {
id: 7,
status: 'in_progress'
} as Ticket
const completedAnalysis: TicketSupportAnalysis = {
id: 9,
ticket_id: 7,
run_public_id: '31ca95a8-e8d5-40d4-9304-17a178d25422',
agent_version: 1,
status: 'succeeded',
progress_stage: 'completed',
progress_message: '草稿生成完成',
query: '订单接口 TIMEOUT-504',
retrieval_policy: {
policy_version: 'published_scope_hybrid_rerank_v2'
},
references: [
{
rank: 1,
source_type: 'solution',
source_id: 12,
source_version: '2026-07-27T08:00:00Z',
title: '订单连接池恢复方案',
excerpt: '订单接口出现 TIMEOUT-504 时检查连接池。',
score: 0.91,
applicable_scope: { related_system: '订单系统' },
auto_resolve_policy: {
allow_auto_resolve: true,
requires_human_action: false,
requires_external_confirmation: false,
requires_production_change: false
}
}
],
final_output: {
analysis_summary: '现象与连接池耗尽的已发布方案一致。',
troubleshooting_steps: ['检查连接池占用', '核对慢请求'],
candidate_solution_ids: [12],
customer_reply_draft: '我们正在检查订单服务连接池状态。',
citations: [1],
auto_resolve_recommended: false
},
automation_decision: {
policy_version: 'support_auto_resolve_v1',
status: 'manual_recommendation',
threshold: 0.85,
solution_id: 12,
score: 0.91,
checks: {
agent_recommended: false,
global_policy_enabled: true
},
reasons: ['agent_not_recommended'],
applied: false,
evaluated_at: '2026-07-27T08:00:05Z'
},
applied_solution_id: null,
processed_at: '2026-07-27T08:00:05Z',
error_code: '',
error_message: '',
created_at: '2026-07-27T08:00:00Z',
started_at: '2026-07-27T08:00:01Z',
finished_at: '2026-07-27T08:00:05Z'
}
describe('SupportAnalysisPanel', () => {
it('未运行时提供明确启动入口', () => {
const onStart = vi.fn()
render(
<SupportAnalysisPanel
ticket={activeTicket}
items={[]}
error=""
starting={false}
onStart={onStart}
/>
)
expect(screen.getByText('尚未运行支持分析')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '开始分析' }))
expect(onStart).toHaveBeenCalledOnce()
})
it('展示结构化建议、回复草稿和按排名冻结的引用', () => {
render(
<SupportAnalysisPanel
ticket={activeTicket}
items={[completedAnalysis]}
error=""
starting={false}
onStart={vi.fn()}
/>
)
expect(screen.getByText('现象与连接池耗尽的已发布方案一致。')).toBeInTheDocument()
expect(screen.getByText('检查连接池占用')).toBeInTheDocument()
expect(screen.getByText('我们正在检查订单服务连接池状态。')).toBeInTheDocument()
expect(screen.getByText('策略降级为人工处理')).toBeInTheDocument()
expect(screen.getByText(/Agent 未建议自动解决/)).toBeInTheDocument()
expect(screen.getByText('订单连接池恢复方案')).toBeInTheDocument()
expect(screen.getByText('91%')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /查看运行轨迹/ })).toHaveAttribute(
'href',
'#/agent-runs/31ca95a8-e8d5-40d4-9304-17a178d25422'
)
})
it('运行中禁止重复启动并说明不会修改工单', () => {
render(
<SupportAnalysisPanel
ticket={activeTicket}
items={[{ ...completedAnalysis, status: 'running', final_output: null }]}
error=""
starting={false}
onStart={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '分析进行中' })).toBeDisabled()
expect(screen.getByText(/不会修改工单状态或覆盖人工字段/)).toBeInTheDocument()
})
})
const replyTask: CustomerReplyTask = {
id: 15,
ticket_id: 7,
support_analysis_id: 9,
status: 'draft',
draft_text: '我们正在检查订单服务连接池状态。',
final_text: '我们正在检查订单服务连接池状态。',
revision: 1,
created_by_id: 3,
updated_by_id: null,
approved_by_id: null,
approved_at: null,
sent_by_id: null,
sent_at: null,
created_at: '2026-07-27T08:00:05Z',
updated_at: '2026-07-27T08:00:05Z',
revisions: [
{
version: 1,
content: '我们正在检查订单服务连接池状态。',
source: 'agent',
edited_by_id: null,
created_at: '2026-07-27T08:00:05Z'
}
]
}
describe('CustomerReplyPanel', () => {
it('允许人工修订并携带当前版本保存', () => {
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<CustomerReplyPanel
tasks={[replyTask]}
canEdit
canConfirm
canSendChannel
onSave={onSave}
onConfirm={vi.fn().mockResolvedValue(undefined)}
onSendChannel={vi.fn()}
/>
)
fireEvent.change(screen.getByLabelText('最终回复内容'), {
target: { value: '连接池已恢复,请重新尝试。' }
})
fireEvent.click(screen.getByRole('button', { name: '保存修订' }))
expect(onSave).toHaveBeenCalledWith(replyTask, '连接池已恢复,请重新尝试。')
})
it('未修改时要求人工确认已发送,并展示已发送状态', () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const { rerender } = render(
<CustomerReplyPanel
tasks={[replyTask]}
canEdit
canConfirm
canSendChannel
onSave={vi.fn().mockResolvedValue(undefined)}
onConfirm={onConfirm}
onSendChannel={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '确认已发送' }))
expect(onConfirm).toHaveBeenCalledWith(replyTask, replyTask.final_text)
rerender(
<CustomerReplyPanel
tasks={[{
...replyTask,
status: 'sent',
sent_at: '2026-07-27T08:05:00Z'
}]}
canEdit
canConfirm
canSendChannel
onSave={vi.fn().mockResolvedValue(undefined)}
onConfirm={onConfirm}
onSendChannel={vi.fn()}
/>
)
expect(screen.getByText('客户回复已确认发送')).toBeInTheDocument()
})
it('只允许把未修改的当前版本交给渠道发送', () => {
const onSendChannel = vi.fn()
render(
<CustomerReplyPanel
tasks={[replyTask]}
canEdit
canConfirm
canSendChannel
onSave={vi.fn().mockResolvedValue(undefined)}
onConfirm={vi.fn().mockResolvedValue(undefined)}
onSendChannel={onSendChannel}
/>
)
fireEvent.click(screen.getByRole('button', { name: '通过渠道发送' }))
expect(onSendChannel).toHaveBeenCalledWith(replyTask)
fireEvent.change(screen.getByLabelText('最终回复内容'), {
target: { value: '尚未保存的新版本' }
})
expect(screen.getByRole('button', { name: '通过渠道发送' })).toBeDisabled()
expect(screen.getByText('请先保存修订,再通过渠道发送')).toBeInTheDocument()
})
it('渠道审批后冻结内容并等待送达回执', () => {
render(
<CustomerReplyPanel
tasks={[{
...replyTask,
status: 'approved',
approved_by_id: 3,
approved_at: '2026-07-27T08:04:00Z'
}]}
canEdit
canConfirm
canSendChannel
onSave={vi.fn().mockResolvedValue(undefined)}
onConfirm={vi.fn().mockResolvedValue(undefined)}
onSendChannel={vi.fn()}
/>
)
expect(screen.getByText('渠道投递中')).toBeInTheDocument()
expect(screen.getByText('回复版本已冻结,正在等待渠道送达')).toBeInTheDocument()
expect(screen.getByLabelText('最终回复内容')).toBeDisabled()
expect(screen.queryByRole('button', { name: '确认已发送' })).not.toBeInTheDocument()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorkbenchPage } from './WorkbenchPage'
const mocks = vi.hoisted(() => ({
listTickets: vi.fn(),
listWorkItems: vi.fn(),
getWorkItemMetrics: vi.fn()
}))
vi.mock('@/features/agent-workspace/AgentWorkspaceProvider', () => ({
useAgentPageContext: vi.fn()
}))
vi.mock('@/features/tickets/api', () => ({
listTickets: mocks.listTickets
}))
vi.mock('@/features/workitems/api', () => ({
listWorkItems: mocks.listWorkItems,
getWorkItemMetrics: mocks.getWorkItemMetrics
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('WorkbenchPage 任务 SLA', () => {
it('展示任务类型、截止状态和 SLA 汇总', async () => {
mocks.listTickets.mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
total_pages: 0
})
mocks.listWorkItems.mockResolvedValue([
{
public_id: '018f6f5e-b407-7fc2-9c56-2f34e6e1c111',
task_type: 'support_analysis_failed',
status: 'active',
object_type: 'ticket',
object_id: '7',
ticket_id: 7,
ticket_no: 'TK-0007',
title: '接管支持分析',
description: '需要人工处理',
payload: { error_message: '模型调用失败' },
assignee_id: 3,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z',
completed_at: null,
due_at: '2026-07-27T12:00:00Z',
sla_status: 'overdue'
}
])
mocks.getWorkItemMetrics.mockResolvedValue({
active_count: 3,
overdue_count: 1,
due_soon_count: 1,
completed_within_sla_count: 5,
completed_overdue_count: 2,
by_task_type: { support_analysis_failed: 1 }
})
render(<WorkbenchPage />)
expect(await screen.findByText('分析接管')).toBeInTheDocument()
expect(screen.getByText('模型调用失败')).toBeInTheDocument()
expect(screen.getAllByText('已超时')).toHaveLength(1)
expect(screen.getByText('活动 3')).toBeInTheDocument()
expect(screen.getByText('即将超时 1')).toBeInTheDocument()
expect(screen.getByText('已超时 1')).toBeInTheDocument()
})
})

View File

@@ -8,11 +8,14 @@ import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
import { listTickets } from '@/features/tickets/api'
import type { Ticket } from '@/features/tickets/types'
import { getWorkItemMetrics, listWorkItems } from '@/features/workitems/api'
import type { WorkItem, WorkItemMetrics } from '@/features/workitems/types'
import { formatDateTime } from '@/lib/format'
const columns: AppDataTableColumn<Ticket>[] = [
@@ -23,6 +26,37 @@ const columns: AppDataTableColumn<Ticket>[] = [
{ key: 'created_at', header: '最近更新', cell: (ticket) => <span className="text-xs text-muted-foreground">{formatDateTime(ticket.updated_at)}</span> }
]
const taskColumns: AppDataTableColumn<WorkItem>[] = [
{ key: 'title', header: '待办事项', cell: (item) => <span className="font-medium">{item.title}</span> },
{ key: 'task_type', header: '类型', cell: (item) => <Badge variant={item.task_type === 'ticket_intake_failed' || item.task_type === 'support_analysis_failed' || item.task_type === 'tool_confirmation' ? 'warning' : item.task_type === 'ticket_customer_reply' ? 'info' : 'secondary'}>{workItemTypeLabel(item.task_type)}</Badge> },
{ key: 'target', header: '关联对象', cell: (item) => <span className="font-mono text-xs text-primary">{item.ticket_no ?? (item.payload.draft_public_id ? `受理 ${item.payload.draft_public_id.slice(0, 8)}` : '—')}</span> },
{ key: 'context', header: '处理线索', cell: (item) => <span className="line-clamp-1 text-xs text-muted-foreground">{workItemContext(item)}</span> },
{ key: 'sla', header: 'SLA', cell: (item) => <span className="flex flex-wrap items-center gap-1.5"><Badge variant={item.sla_status === 'overdue' ? 'destructive' : item.sla_status === 'due_soon' ? 'warning' : 'outline'}>{item.sla_status === 'overdue' ? '已超时' : item.sla_status === 'due_soon' ? '即将超时' : '进行中'}</Badge><span className="text-xs text-muted-foreground">{item.due_at ? formatDateTime(item.due_at) : '未设置'}</span></span> }
]
function workItemTypeLabel(taskType: string) {
if (taskType === 'ticket_intake_failed') return '受理接管'
if (taskType === 'support_analysis_failed') return '分析接管'
if (taskType === 'ticket_customer_reply') return '客户回复'
if (taskType === 'ticket_close') return '人工关闭'
if (taskType === 'tool_confirmation') return '工具确认'
return '信息补全'
}
function workItemContext(item: WorkItem) {
if (item.task_type === 'ticket_intake_failed' || item.task_type === 'support_analysis_failed') {
return item.payload.error_message || item.payload.error_code || '需人工接管'
}
if (item.task_type === 'ticket_customer_reply') {
return `回复草稿 v${item.payload.revision ?? 1} 待确认`
}
if (item.task_type === 'ticket_close') return '已有解决结论,等待关闭'
if (item.task_type === 'tool_confirmation') {
return `${item.payload.tool_code ?? '高风险工具'} 等待独立确认`
}
return `${item.payload.missing_fields?.length ?? 0} 项待补`
}
const metricDefinitions = [
{ label: '我的待办', description: '待处理与处理中', icon: Clock3, href: '/tickets?view=assigned_to_me', value: (tickets: Ticket[]) => tickets.filter((item) => item.status === 'pending' || item.status === 'in_progress').length },
{ label: '即将超时', description: '高优先级未完成', icon: AlertTriangle, href: '/tickets?view=high_priority', value: (tickets: Ticket[]) => tickets.filter((item) => (item.priority === 'high' || item.priority === 'urgent') && !['resolved', 'closed'].includes(item.status)).length },
@@ -32,9 +66,19 @@ const metricDefinitions = [
export function WorkbenchPage() {
const [tickets, setTickets] = React.useState<Ticket[]>([])
const [workItems, setWorkItems] = React.useState<WorkItem[]>([])
const [workItemMetrics, setWorkItemMetrics] = React.useState<WorkItemMetrics | null>(null)
const [error, setError] = React.useState('')
React.useEffect(() => { listTickets().then((response) => setTickets(response.items)).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败')) }, [])
React.useEffect(() => {
Promise.all([listTickets(), listWorkItems({ status: 'active' }), getWorkItemMetrics()])
.then(([response, items, taskMetrics]) => {
setTickets(response.items)
setWorkItems(items)
setWorkItemMetrics(taskMetrics)
})
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, [])
const metrics = React.useMemo(() => metricDefinitions.map((metric) => ({ ...metric, count: metric.value(tickets) })), [tickets])
const agentContext = React.useMemo(() => ({ routeKey: 'workbench', entityType: 'workbench', title: '今日支持工作台', status: error ? '数据加载异常' : '已同步页面数据', metadata: Object.fromEntries(metrics.map((item) => [item.label, String(item.count)])) }), [error, metrics])
@@ -53,6 +97,20 @@ export function WorkbenchPage() {
)})}
</div>
{error ? <AppEmptyState title="无法加载工作台数据" description={error} /> : null}
<AppSection title="任务中心" description="待补全、失败接管、客户回复、人工关闭和高风险工具确认统一进入任务中心;首期采集 SLA,不自动升级。" action={workItemMetrics ? <div className="flex flex-wrap gap-2"><Badge variant="outline">活动 {workItemMetrics.active_count}</Badge><Badge variant={workItemMetrics.due_soon_count ? 'warning' : 'outline'}>即将超时 {workItemMetrics.due_soon_count}</Badge><Badge variant={workItemMetrics.overdue_count ? 'destructive' : 'outline'}>已超时 {workItemMetrics.overdue_count}</Badge></div> : null}>
<AppDataTable
columns={taskColumns}
rows={workItems.slice(0, 8)}
getRowKey={(item) => item.public_id}
emptyTitle="暂无活动任务"
emptyDescription="需要人工接管或补全的事项会显示在这里。"
onRowClick={(item) => {
if (item.ticket_id) window.location.hash = `/tickets/${item.ticket_id}`
else if (item.payload.draft_public_id) window.location.hash = `/tickets/new?draft=${encodeURIComponent(item.payload.draft_public_id)}`
else if (item.task_type === 'tool_confirmation') window.location.hash = '/tools'
}}
/>
</AppSection>
<AppSection title="最近处理" description="按更新时间展示最近工单" action={<Button variant="ghost" onClick={() => (window.location.hash = '/tickets')}>查看全部<ArrowRight data-icon="inline-end" /></Button>}>
<AppDataTable columns={columns} rows={tickets.slice(0, 8)} getRowKey={(ticket) => ticket.id} emptyTitle="暂无工单" emptyDescription="创建第一张工单后,最近动态会显示在这里。" onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)} />
</AppSection>

View File

@@ -0,0 +1,20 @@
import { apiRequest } from '@/lib/api'
import type { WorkItem, WorkItemMetrics } from './types'
export function listWorkItems(params: {
status?: string
task_type?: string
ticket_id?: number
} = {}) {
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value))
})
const suffix = query.toString() ? `?${query}` : ''
return apiRequest<WorkItem[]>(`/tasks${suffix}`)
}
export function getWorkItemMetrics() {
return apiRequest<WorkItemMetrics>('/tasks/metrics')
}

View File

@@ -0,0 +1,42 @@
export type WorkItem = {
public_id: string
task_type: 'ticket_missing_info' | 'ticket_intake_failed' | 'ticket_customer_reply' | string
status: 'active' | 'completed' | 'cancelled'
object_type: string
object_id: string
ticket_id: number | null
ticket_no: string | null
title: string
description: string
payload: {
missing_fields?: string[]
questions?: string[]
draft_public_id?: string
agent_run_public_id?: string
error_code?: string
error_message?: string
revision?: number
reply_task_id?: number
support_analysis_id?: number | null
resolved_at?: string
tool_call_public_id?: string
tool_code?: string
risk_level?: string
requested_by_id?: number
}
assignee_id: number | null
created_at: string
updated_at: string
completed_at: string | null
due_at: string | null
sla_status: 'on_track' | 'due_soon' | 'overdue' | 'completed' | 'completed_overdue'
}
export type WorkItemMetrics = {
active_count: number
overdue_count: number
due_soon_count: number
completed_within_sla_count: number
completed_overdue_count: number
by_task_type: Record<string, number>
}