feat: add intelligent support workflow
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
303
frontend/src/features/tickets/TicketDetailPage.test.tsx
Normal file
303
frontend/src/features/tickets/TicketDetailPage.test.tsx
Normal 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
76
frontend/src/features/workbench/WorkbenchPage.test.tsx
Normal file
76
frontend/src/features/workbench/WorkbenchPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
20
frontend/src/features/workitems/api.ts
Normal file
20
frontend/src/features/workitems/api.ts
Normal 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')
|
||||
}
|
||||
42
frontend/src/features/workitems/types.ts
Normal file
42
frontend/src/features/workitems/types.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user