feat: redesign ticket detail workspace
This commit is contained in:
@@ -11,8 +11,8 @@ describe('RouteMeta', () => {
|
|||||||
expect(keys.some((key) => key.includes('concept'))).toBe(false)
|
expect(keys.some((key) => key.includes('concept'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('核心业务默认停靠 Agent,治理页按需打开', () => {
|
it('工单详情与治理页按需打开 Agent,其余核心业务保持停靠', () => {
|
||||||
expect(matchRoute('/tickets/42').meta.agent).toBe('docked')
|
expect(matchRoute('/tickets/42').meta.agent).toBe('on-demand')
|
||||||
expect(matchRoute('/knowledge').meta.agent).toBe('docked')
|
expect(matchRoute('/knowledge').meta.agent).toBe('docked')
|
||||||
expect(matchRoute('/system/users').meta.agent).toBe('on-demand')
|
expect(matchRoute('/system/users').meta.agent).toBe('on-demand')
|
||||||
expect(matchRoute('/modelhub/providers').meta.agent).toBe('on-demand')
|
expect(matchRoute('/modelhub/providers').meta.agent).toBe('on-demand')
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const routeMeta: RouteMeta[] = [
|
|||||||
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
|
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
|
||||||
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
|
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
|
||||||
{ key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
|
{ key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
|
||||||
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
|
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'on-demand', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
|
||||||
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
|
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
|
||||||
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
|
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
|
||||||
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
|
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
|
||||||
|
|||||||
580
frontend/src/features/tickets/SupportAnalysisWorkspace.tsx
Normal file
580
frontend/src/features/tickets/SupportAnalysisWorkspace.tsx
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
BrainCircuit,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
CircleDashed,
|
||||||
|
ExternalLink,
|
||||||
|
History,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSupportAnalysisViewModel,
|
||||||
|
type GroupedSupportReferences,
|
||||||
|
type SupportRecommendation
|
||||||
|
} from './supportAnalysisViewModel'
|
||||||
|
import type {
|
||||||
|
CustomerReplyTask,
|
||||||
|
Ticket,
|
||||||
|
TicketSupportAnalysis
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const REFERENCE_GROUPS: Array<{
|
||||||
|
key: keyof GroupedSupportReferences
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ key: 'knowledge', label: '正式知识' },
|
||||||
|
{ key: 'solution', label: '已发布方案' },
|
||||||
|
{ key: 'historical_ticket', label: '历史工单' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '分析中',
|
||||||
|
succeeded: '成功',
|
||||||
|
completed: '成功',
|
||||||
|
failed: '失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection(id: string) {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeText(scope: Record<string, unknown>) {
|
||||||
|
const entries = Object.entries(scope).filter(([, value]) => {
|
||||||
|
if (Array.isArray(value)) return value.length > 0
|
||||||
|
return value !== null && value !== undefined && value !== ''
|
||||||
|
})
|
||||||
|
if (entries.length === 0) return '未限制适用环境'
|
||||||
|
return entries
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join('、') : String(value)}`)
|
||||||
|
.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupportReferenceList({
|
||||||
|
groups,
|
||||||
|
emptyText
|
||||||
|
}: {
|
||||||
|
groups: GroupedSupportReferences
|
||||||
|
emptyText: string
|
||||||
|
}) {
|
||||||
|
const count = REFERENCE_GROUPS.reduce((total, group) => total + groups[group.key].length, 0)
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{emptyText}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{REFERENCE_GROUPS.map((group) => {
|
||||||
|
const references = groups[group.key]
|
||||||
|
if (references.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section key={group.key} className="grid gap-2" aria-label={group.label}>
|
||||||
|
<h5 className="text-xs font-medium text-muted-foreground">
|
||||||
|
{group.label} · {references.length}
|
||||||
|
</h5>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{references.map((reference) => (
|
||||||
|
<a
|
||||||
|
key={`${reference.source_type}-${reference.rank}-${reference.source_id}`}
|
||||||
|
href={
|
||||||
|
reference.source_type === 'knowledge'
|
||||||
|
? '#/knowledge'
|
||||||
|
: reference.source_type === 'solution'
|
||||||
|
? '#/solutions'
|
||||||
|
: `#/tickets/${reference.source_id}`
|
||||||
|
}
|
||||||
|
className="group grid gap-2 rounded-lg border p-3 outline-none transition-colors hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">#{reference.rank}</Badge>
|
||||||
|
<span className="min-w-0 flex-1 text-sm font-medium">{reference.title}</span>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
匹配 {Math.round(reference.score * 100)}%
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="size-4 text-muted-foreground group-hover:text-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
{reference.excerpt || '暂无摘要'}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{scopeText(reference.applicable_scope)}</span>
|
||||||
|
<span>版本 {reference.source_version || '未标注'}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationCard({
|
||||||
|
recommendation,
|
||||||
|
busy,
|
||||||
|
readOnly,
|
||||||
|
onRecommend,
|
||||||
|
onAdopt
|
||||||
|
}: {
|
||||||
|
recommendation: SupportRecommendation
|
||||||
|
busy: boolean
|
||||||
|
readOnly: boolean
|
||||||
|
onRecommend: (solutionId: number) => void
|
||||||
|
onAdopt: (solutionId: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 rounded-xl border bg-muted/20 p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h5 className="font-medium">{recommendation.title}</h5>
|
||||||
|
<Badge variant="info">匹配 {Math.round(recommendation.score * 100)}%</Badge>
|
||||||
|
<Badge variant={recommendation.autoResolveEligible ? 'success' : 'warning'}>
|
||||||
|
{recommendation.autoResolveEligible ? <Check /> : <AlertTriangle />}
|
||||||
|
{recommendation.autoResolveEligible ? '允许自动化' : '需要人工确认'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{scopeText(recommendation.applicableScope)}
|
||||||
|
</p>
|
||||||
|
{recommendation.riskLabels.length ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{recommendation.riskLabels.map((label) => (
|
||||||
|
<Badge key={label} variant="outline">
|
||||||
|
<AlertTriangle />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
冻结依据 #{recommendation.reference.rank}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a href="#/solutions">
|
||||||
|
查看方案
|
||||||
|
<ExternalLink data-icon="inline-end" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{!readOnly ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onRecommend(recommendation.solutionId)}
|
||||||
|
>
|
||||||
|
<Link2 data-icon="inline-start" />
|
||||||
|
标记推荐
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onAdopt(recommendation.solutionId)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 data-icon="inline-start" />
|
||||||
|
采纳并解决
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportAnalysisWorkspace({
|
||||||
|
ticket,
|
||||||
|
items,
|
||||||
|
replyTasks,
|
||||||
|
error,
|
||||||
|
starting,
|
||||||
|
solutionBusy,
|
||||||
|
onStart,
|
||||||
|
onRecommendSolution,
|
||||||
|
onAdoptSolution
|
||||||
|
}: {
|
||||||
|
ticket: Ticket
|
||||||
|
items: TicketSupportAnalysis[]
|
||||||
|
replyTasks: CustomerReplyTask[]
|
||||||
|
error: string
|
||||||
|
starting: boolean
|
||||||
|
solutionBusy: boolean
|
||||||
|
onStart: () => void
|
||||||
|
onRecommendSolution: (solutionId: number) => void
|
||||||
|
onAdoptSolution: (solutionId: number) => void
|
||||||
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = React.useState(items[0] ? String(items[0].id) : '')
|
||||||
|
const [allReferencesOpen, setAllReferencesOpen] = React.useState(false)
|
||||||
|
const [policyOpen, setPolicyOpen] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!items.length) {
|
||||||
|
setSelectedId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!items.some((item) => String(item.id) === selectedId)) {
|
||||||
|
setSelectedId(String(items[0].id))
|
||||||
|
}
|
||||||
|
}, [items, selectedId])
|
||||||
|
|
||||||
|
const selected = items.find((item) => String(item.id) === selectedId) ?? items[0] ?? null
|
||||||
|
const view = buildSupportAnalysisViewModel(ticket, selected, replyTasks)
|
||||||
|
const active = view.displayState === 'queued' || view.displayState === 'running'
|
||||||
|
const readOnly = view.displayState === 'read_only'
|
||||||
|
const output = selected?.final_output
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="support-analysis-title" className="grid gap-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 id="support-analysis-title" className="flex items-center gap-2 font-semibold">
|
||||||
|
<BrainCircuit className="size-5 text-primary" />
|
||||||
|
支持分析
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
尚未运行支持分析。基于已发布知识、方案与已解决历史工单生成可追溯的排查建议。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onStart} disabled={starting || readOnly}>
|
||||||
|
<BrainCircuit data-icon="inline-start" />
|
||||||
|
{starting ? '正在启动' : '开始分析'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||||
|
<Badge variant="outline">问题描述已就绪</Badge>
|
||||||
|
<Badge variant="outline">正式依据由运行时冻结</Badge>
|
||||||
|
<span>分析前不会展示空的结论、步骤和引用区域。</span>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<X />
|
||||||
|
<AlertTitle>支持分析未启动</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="support-analysis-title" className="grid gap-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 id="support-analysis-title" className="flex items-center gap-2 font-semibold">
|
||||||
|
<BrainCircuit className="size-5 text-primary" />
|
||||||
|
支持分析
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
selected.status === 'failed'
|
||||||
|
? 'destructive'
|
||||||
|
: active
|
||||||
|
? 'info'
|
||||||
|
: 'success'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? <CircleDashed className="animate-spin" /> : null}
|
||||||
|
{STATUS_LABELS[selected.status] ?? selected.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">Agent v{selected.agent_version}</Badge>
|
||||||
|
<Badge variant="outline">{selected.references.length} 条冻结依据</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{selected.started_at
|
||||||
|
? `开始于 ${formatDateTime(selected.started_at)}`
|
||||||
|
: `创建于 ${formatDateTime(selected.created_at)}`}
|
||||||
|
{readOnly ? ' · 工单已结束,分析结果只读保留' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<Select value={String(selected.id)} onValueChange={setSelectedId}>
|
||||||
|
<SelectTrigger className="w-full sm:w-64" aria-label="选择历史分析版本">
|
||||||
|
<History className="size-4" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={String(item.id)}>
|
||||||
|
{formatDateTime(item.created_at)} · v{item.agent_version} ·{' '}
|
||||||
|
{STATUS_LABELS[item.status] ?? item.status}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
{!readOnly ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={selected.status === 'failed' ? 'default' : 'outline'}
|
||||||
|
onClick={onStart}
|
||||||
|
disabled={starting || active}
|
||||||
|
>
|
||||||
|
<RefreshCw data-icon="inline-start" />
|
||||||
|
{starting
|
||||||
|
? '正在启动'
|
||||||
|
: active
|
||||||
|
? '分析进行中'
|
||||||
|
: selected.status === 'failed'
|
||||||
|
? '重新分析'
|
||||||
|
: '再次分析'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a href={`#/agent-runs/${selected.run_public_id}`}>
|
||||||
|
运行轨迹
|
||||||
|
<ExternalLink data-icon="inline-end" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<X />
|
||||||
|
<AlertTitle>支持分析未启动</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{active ? (
|
||||||
|
<Alert>
|
||||||
|
<RefreshCw className="animate-spin" />
|
||||||
|
<AlertTitle>{selected.progress_message || '正在执行支持分析'}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
已冻结 {selected.references.length} 条候选依据。运行期间不可重复启动,可从运行轨迹查看详细进度。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected.status === 'failed' ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle />
|
||||||
|
<AlertTitle>本次分析需要人工接管</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{selected.error_message || '模型、检索或结构化输出未完成。原有历史成功结果仍可通过上方版本选择器查看。'}
|
||||||
|
</AlertDescription>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => scrollToSection('activity-section')}
|
||||||
|
>
|
||||||
|
继续人工排查
|
||||||
|
<ArrowRight data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{output ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
分析结论
|
||||||
|
</p>
|
||||||
|
<p className="max-w-4xl whitespace-pre-wrap text-base leading-7">
|
||||||
|
{output.analysis_summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{output.troubleshooting_steps.length ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
建议排查顺序
|
||||||
|
</p>
|
||||||
|
<ol className="grid gap-2">
|
||||||
|
{output.troubleshooting_steps.map((step, index) => (
|
||||||
|
<li
|
||||||
|
key={`${index}-${step}`}
|
||||||
|
className="grid grid-cols-[2.25rem_minmax(0,1fr)] gap-3 rounded-lg border-l-2 border-primary/40 bg-muted/20 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm leading-6">{step}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
排查步骤为只读建议;实际执行结果请记录到下方统一活动流。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">Agent 推荐方案</h4>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
仅展示本次输出明确列入 candidate_solution_ids 的冻结方案。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
view.automation.status === 'auto_resolved'
|
||||||
|
? 'success'
|
||||||
|
: view.automation.status === 'eligible'
|
||||||
|
? 'info'
|
||||||
|
: 'warning'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{view.automation.status === 'manual_recommendation' ? (
|
||||||
|
<AlertTriangle />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 />
|
||||||
|
)}
|
||||||
|
{view.automation.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{view.automation.primaryReason}</p>
|
||||||
|
{view.recommendations.length ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{view.recommendations.map((recommendation) => (
|
||||||
|
<RecommendationCard
|
||||||
|
key={recommendation.solutionId}
|
||||||
|
recommendation={recommendation}
|
||||||
|
busy={solutionBusy}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onRecommend={onRecommendSolution}
|
||||||
|
onAdopt={onAdoptSolution}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
本次分析未明确推荐可复用方案,请继续按排查步骤人工处理。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view.automation.checks.length ? (
|
||||||
|
<Collapsible open={policyOpen} onOpenChange={setPolicyOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="w-fit">
|
||||||
|
策略判断
|
||||||
|
<ChevronDown
|
||||||
|
data-icon="inline-end"
|
||||||
|
className={cn('transition-transform', policyOpen && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-2">
|
||||||
|
<div className="grid gap-2 rounded-lg border bg-muted/20 p-3 sm:grid-cols-2">
|
||||||
|
{view.automation.checks.map((check) => (
|
||||||
|
<div key={check.key} className="flex items-center gap-2 text-sm">
|
||||||
|
{check.passed ? (
|
||||||
|
<Check className="size-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<X className="size-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
<span>{check.label}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{check.passed ? '通过' : '未通过'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{view.formalReplyTask ? '已生成客户回复任务' : '正在生成正式回复任务'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{view.formalReplyTask
|
||||||
|
? '正式任务是唯一可编辑、发送和确认送达的回复来源。'
|
||||||
|
: output.customer_reply_draft || '分析结果已完成,等待回复任务同步。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{view.formalReplyTask ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => scrollToSection('customer-reply-section')}
|
||||||
|
>
|
||||||
|
前往编辑回复
|
||||||
|
<ArrowRight data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h4 className="font-medium">正式引用依据</h4>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<Link2 />
|
||||||
|
{view.citedCount}/{selected.references.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<SupportReferenceList
|
||||||
|
groups={view.citedReferences}
|
||||||
|
emptyText="本次输出没有正式引用,仅供人工排查,不允许自动复用。"
|
||||||
|
/>
|
||||||
|
<Collapsible open={allReferencesOpen} onOpenChange={setAllReferencesOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="w-fit">
|
||||||
|
查看全部冻结依据
|
||||||
|
<ChevronDown
|
||||||
|
data-icon="inline-end"
|
||||||
|
className={cn('transition-transform', allReferencesOpen && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-3">
|
||||||
|
<SupportReferenceList
|
||||||
|
groups={view.allReferences}
|
||||||
|
emptyText="本次运行没有冻结检索依据。"
|
||||||
|
/>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
frontend/src/features/tickets/TicketActivityPanel.test.ts
Normal file
54
frontend/src/features/tickets/TicketActivityPanel.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { normalizeTicketActivities } from './TicketActivityPanel'
|
||||||
|
import type { TicketComment, TicketEvent } from './types'
|
||||||
|
|
||||||
|
describe('normalizeTicketActivities', () => {
|
||||||
|
it('合并事件和评论并按时间倒序排列', () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
event_type: 'status_changed',
|
||||||
|
from_value: { status: 'pending' },
|
||||||
|
to_value: { status: 'in_progress' },
|
||||||
|
remark: '开始处理',
|
||||||
|
operator: { id: 1, username: 'agent', display_name: '支持人员' },
|
||||||
|
source: 'human',
|
||||||
|
agent_run_id: null,
|
||||||
|
metadata: {},
|
||||||
|
created_at: '2026-07-27T08:00:00Z'
|
||||||
|
}
|
||||||
|
] satisfies TicketEvent[]
|
||||||
|
const comments = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: '已向客户同步排查进度',
|
||||||
|
comment_type: 'user_reply',
|
||||||
|
visibility: 'public',
|
||||||
|
created_by: { id: 1, username: 'agent', display_name: '支持人员' },
|
||||||
|
created_at: '2026-07-27T08:05:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: '内部排查记录',
|
||||||
|
comment_type: 'internal_note',
|
||||||
|
visibility: 'internal',
|
||||||
|
created_by: null,
|
||||||
|
created_at: '2026-07-27T07:55:00Z'
|
||||||
|
}
|
||||||
|
] satisfies TicketComment[]
|
||||||
|
|
||||||
|
const activities = normalizeTicketActivities(events, comments)
|
||||||
|
|
||||||
|
expect(activities.map((item) => item.id)).toEqual([
|
||||||
|
'comment-2',
|
||||||
|
'event-1',
|
||||||
|
'comment-3'
|
||||||
|
])
|
||||||
|
expect(activities.map((item) => item.audience)).toEqual([
|
||||||
|
'public',
|
||||||
|
'system',
|
||||||
|
'internal'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
319
frontend/src/features/tickets/TicketActivityPanel.tsx
Normal file
319
frontend/src/features/tickets/TicketActivityPanel.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Eye,
|
||||||
|
LockKeyhole,
|
||||||
|
MessageSquareText,
|
||||||
|
Send,
|
||||||
|
Settings2,
|
||||||
|
UserRound
|
||||||
|
} 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
|
import { addTicketComment, listTicketComments } from './api'
|
||||||
|
import type { TicketComment, TicketEvent, TicketUser } from './types'
|
||||||
|
|
||||||
|
export type TicketActivityItem = {
|
||||||
|
id: string
|
||||||
|
kind: 'comment' | 'event'
|
||||||
|
audience: 'internal' | 'public' | 'system'
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
actor: TicketUser | null
|
||||||
|
source: TicketEvent['source'] | 'comment'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityFilter = 'all' | 'internal' | 'public' | 'system'
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
|
created: '创建了工单',
|
||||||
|
status_changed: '更新了工单状态',
|
||||||
|
field_updated: '修改了工单字段',
|
||||||
|
attachment_added: '添加了材料',
|
||||||
|
ai_intake_created: 'AI 受理创建正式工单',
|
||||||
|
support_analysis_started: '启动支持分析',
|
||||||
|
support_analysis_completed: '支持分析完成',
|
||||||
|
support_analysis_failed: '支持分析失败',
|
||||||
|
customer_reply_task_created: '生成客户回复任务',
|
||||||
|
customer_reply_updated: '修订客户回复',
|
||||||
|
customer_reply_sent: '确认客户回复已发送',
|
||||||
|
solution_linked: '关联了解决方案'
|
||||||
|
}
|
||||||
|
|
||||||
|
function actorName(actor: TicketUser | null) {
|
||||||
|
return actor?.display_name || actor?.username || '系统'
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueSummary(value: Record<string, unknown>) {
|
||||||
|
const status = typeof value.status === 'string' ? value.status : ''
|
||||||
|
const title = typeof value.title === 'string' ? value.title : ''
|
||||||
|
const field = typeof value.field === 'string' ? value.field : ''
|
||||||
|
return [field, status, title].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一事件和评论的前端呈现模型,不改变后端两类记录各自的审计语义。
|
||||||
|
*/
|
||||||
|
export function normalizeTicketActivities(
|
||||||
|
events: TicketEvent[],
|
||||||
|
comments: TicketComment[]
|
||||||
|
): TicketActivityItem[] {
|
||||||
|
const eventItems: TicketActivityItem[] = events.map((event) => ({
|
||||||
|
id: `event-${event.id}`,
|
||||||
|
kind: 'event',
|
||||||
|
audience: 'system',
|
||||||
|
title: EVENT_LABELS[event.event_type] ?? event.event_type.replace(/_/g, ' '),
|
||||||
|
description:
|
||||||
|
event.remark
|
||||||
|
|| valueSummary(event.to_value)
|
||||||
|
|| (event.agent_run_id ? `Agent 运行 #${event.agent_run_id}` : ''),
|
||||||
|
actor: event.operator,
|
||||||
|
source: event.source,
|
||||||
|
createdAt: event.created_at
|
||||||
|
}))
|
||||||
|
const commentItems: TicketActivityItem[] = comments.map((comment) => ({
|
||||||
|
id: `comment-${comment.id}`,
|
||||||
|
kind: 'comment',
|
||||||
|
audience: comment.visibility === 'public' ? 'public' : 'internal',
|
||||||
|
title: comment.visibility === 'public' ? '客户可见记录' : '内部备注',
|
||||||
|
description: comment.content,
|
||||||
|
actor: comment.created_by,
|
||||||
|
source: 'comment',
|
||||||
|
createdAt: comment.created_at
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...eventItems, ...commentItems].sort(
|
||||||
|
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketActivityPanel({
|
||||||
|
ticketId,
|
||||||
|
events,
|
||||||
|
canComment,
|
||||||
|
onCommentAdded
|
||||||
|
}: {
|
||||||
|
ticketId: number
|
||||||
|
events: TicketEvent[]
|
||||||
|
canComment: boolean
|
||||||
|
onCommentAdded?: () => void
|
||||||
|
}) {
|
||||||
|
const [comments, setComments] = React.useState<TicketComment[]>([])
|
||||||
|
const [content, setContent] = React.useState('')
|
||||||
|
const [commentMode, setCommentMode] = React.useState<'internal' | 'public'>('internal')
|
||||||
|
const [filter, setFilter] = React.useState<ActivityFilter>('all')
|
||||||
|
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])
|
||||||
|
|
||||||
|
const activities = React.useMemo(
|
||||||
|
() => normalizeTicketActivities(events, comments),
|
||||||
|
[comments, events]
|
||||||
|
)
|
||||||
|
const visibleActivities =
|
||||||
|
filter === 'all'
|
||||||
|
? activities
|
||||||
|
: activities.filter((item) => item.audience === filter)
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
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) => [comment, ...current])
|
||||||
|
setContent('')
|
||||||
|
onCommentAdded?.()
|
||||||
|
toast.success(commentMode === 'public' ? '客户可见记录已保存' : '内部备注已保存')
|
||||||
|
} catch (reason) {
|
||||||
|
const message = reason instanceof Error ? reason.message : '活动记录保存失败'
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="activity-section" aria-labelledby="activity-title" className="grid gap-4">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 id="activity-title" className="font-semibold">
|
||||||
|
活动与沟通 · {activities.length}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
状态、字段、材料、Agent 运行与沟通记录按时间统一归档。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={filter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) setFilter(value as ActivityFilter)
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="筛选活动"
|
||||||
|
className="max-w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="all">全部</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="internal">内部</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="public">客户可见</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="system">系统</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>活动记录暂不可用</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canComment ? (
|
||||||
|
<form className="grid gap-3 rounded-xl border bg-muted/20 p-3" 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 ? '正在保存' : '保存记录'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-3 text-sm text-muted-foreground">正在加载活动记录…</p>
|
||||||
|
) : visibleActivities.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
当前筛选下暂无活动。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="relative grid gap-0 border-l ml-2" aria-label="工单活动时间线">
|
||||||
|
{visibleActivities.map((item) => {
|
||||||
|
const publicItem = item.audience === 'public'
|
||||||
|
const internalItem = item.audience === 'internal'
|
||||||
|
const Icon =
|
||||||
|
item.source === 'agent'
|
||||||
|
? Bot
|
||||||
|
: item.kind === 'comment'
|
||||||
|
? publicItem
|
||||||
|
? Eye
|
||||||
|
: LockKeyhole
|
||||||
|
: item.source === 'human'
|
||||||
|
? UserRound
|
||||||
|
: Settings2
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="relative grid gap-2 border-b py-4 pl-6 last:border-b-0">
|
||||||
|
<span className="absolute -left-3 top-4 flex size-6 items-center justify-center rounded-full border bg-background">
|
||||||
|
<Icon className="size-3.5 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{item.title}</span>
|
||||||
|
{item.kind === 'comment' ? (
|
||||||
|
<Badge variant={publicItem ? 'info' : 'secondary'}>
|
||||||
|
{publicItem ? <Eye /> : <LockKeyhole />}
|
||||||
|
{publicItem ? '客户可见' : '仅内部'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">系统记录</Badge>
|
||||||
|
)}
|
||||||
|
<time
|
||||||
|
className="ml-auto text-xs tabular-nums text-muted-foreground"
|
||||||
|
dateTime={item.createdAt}
|
||||||
|
>
|
||||||
|
{formatDateTime(item.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{actorName(item.actor)}
|
||||||
|
{internalItem ? ' · 内部记录' : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,15 @@ import * as React from 'react'
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { CustomerReplyPanel, SimilarTicketsPanel, SupportAnalysisPanel } from './TicketDetailPage'
|
import {
|
||||||
|
CustomerReplyPanel,
|
||||||
|
getTicketCurrentTask,
|
||||||
|
getTicketDossierStageIndex,
|
||||||
|
getTicketPrimaryAction,
|
||||||
|
SimilarTicketsPanel,
|
||||||
|
SupportAnalysisPanel
|
||||||
|
} from './TicketDetailPage'
|
||||||
|
import { SupportAnalysisWorkspace } from './SupportAnalysisWorkspace'
|
||||||
import type {
|
import type {
|
||||||
CustomerReplyTask,
|
CustomerReplyTask,
|
||||||
Ticket,
|
Ticket,
|
||||||
@@ -123,12 +131,12 @@ describe('SupportAnalysisPanel', () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByText('尚未运行支持分析')).toBeInTheDocument()
|
expect(screen.getByText(/尚未运行支持分析/)).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '开始分析' }))
|
fireEvent.click(screen.getByRole('button', { name: '开始分析' }))
|
||||||
expect(onStart).toHaveBeenCalledOnce()
|
expect(onStart).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('展示结构化建议、回复草稿和按排名冻结的引用', () => {
|
it('按处理价值展示结论、推荐方案、策略和正式引用', () => {
|
||||||
render(
|
render(
|
||||||
<SupportAnalysisPanel
|
<SupportAnalysisPanel
|
||||||
ticket={activeTicket}
|
ticket={activeTicket}
|
||||||
@@ -142,11 +150,11 @@ describe('SupportAnalysisPanel', () => {
|
|||||||
expect(screen.getByText('现象与连接池耗尽的已发布方案一致。')).toBeInTheDocument()
|
expect(screen.getByText('现象与连接池耗尽的已发布方案一致。')).toBeInTheDocument()
|
||||||
expect(screen.getByText('检查连接池占用')).toBeInTheDocument()
|
expect(screen.getByText('检查连接池占用')).toBeInTheDocument()
|
||||||
expect(screen.getByText('我们正在检查订单服务连接池状态。')).toBeInTheDocument()
|
expect(screen.getByText('我们正在检查订单服务连接池状态。')).toBeInTheDocument()
|
||||||
expect(screen.getByText('策略降级为人工处理')).toBeInTheDocument()
|
expect(screen.getByText('需要人工处理')).toBeInTheDocument()
|
||||||
expect(screen.getByText(/Agent 未建议自动解决/)).toBeInTheDocument()
|
expect(screen.getByText(/Agent 未建议自动解决/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('订单连接池恢复方案')).toBeInTheDocument()
|
expect(screen.getAllByText('订单连接池恢复方案')).toHaveLength(2)
|
||||||
expect(screen.getByText('91%')).toBeInTheDocument()
|
expect(screen.getAllByText(/91%/).length).toBeGreaterThan(0)
|
||||||
expect(screen.getByRole('link', { name: /查看运行轨迹/ })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: /运行轨迹/ })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'#/agent-runs/31ca95a8-e8d5-40d4-9304-17a178d25422'
|
'#/agent-runs/31ca95a8-e8d5-40d4-9304-17a178d25422'
|
||||||
)
|
)
|
||||||
@@ -164,7 +172,68 @@ describe('SupportAnalysisPanel', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: '分析进行中' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '分析进行中' })).toBeDisabled()
|
||||||
expect(screen.getByText(/不会修改工单状态或覆盖人工字段/)).toBeInTheDocument()
|
expect(screen.getByText(/运行期间不可重复启动/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('工单连续卷宗派生状态', () => {
|
||||||
|
it.each([
|
||||||
|
['pending_info', 0, '立即补全受理信息'],
|
||||||
|
['pending', 0, '先分派处理人'],
|
||||||
|
['in_progress', 1, '完成支持分析并记录排查结果'],
|
||||||
|
['pending_confirm', 2, '编辑回复并完成客户确认'],
|
||||||
|
['resolved', 3, '确认解决结论并沉淀知识'],
|
||||||
|
['closed', 4, '复核关闭记录与知识资产']
|
||||||
|
] as const)('为 %s 显示阶段 %s 和当前任务', (status, index, title) => {
|
||||||
|
const ticket = { ...activeTicket, status, assignee: null } as Ticket
|
||||||
|
expect(getTicketDossierStageIndex(status)).toBe(index)
|
||||||
|
expect(getTicketCurrentTask(ticket, []).title).toBe(title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('每个状态只派生一个主操作', () => {
|
||||||
|
expect(
|
||||||
|
getTicketPrimaryAction(
|
||||||
|
{ ...activeTicket, status: 'pending_info' } as Ticket,
|
||||||
|
[],
|
||||||
|
['pending']
|
||||||
|
)
|
||||||
|
).toEqual({ kind: 'edit' })
|
||||||
|
expect(
|
||||||
|
getTicketPrimaryAction(activeTicket, [], ['pending_confirm', 'resolved'])
|
||||||
|
).toEqual({ kind: 'analyze' })
|
||||||
|
expect(
|
||||||
|
getTicketPrimaryAction(
|
||||||
|
{
|
||||||
|
...activeTicket,
|
||||||
|
status: 'pending',
|
||||||
|
assignee: { id: 3, username: 'support', display_name: '支持人员' }
|
||||||
|
} as Ticket,
|
||||||
|
[],
|
||||||
|
['in_progress', 'closed']
|
||||||
|
)
|
||||||
|
).toEqual({ kind: 'transition', status: 'in_progress' })
|
||||||
|
expect(
|
||||||
|
getTicketPrimaryAction(
|
||||||
|
{ ...activeTicket, status: 'pending_confirm' } as Ticket,
|
||||||
|
[completedAnalysis],
|
||||||
|
['in_progress', 'resolved']
|
||||||
|
)
|
||||||
|
).toEqual({ kind: 'transition', status: 'resolved' })
|
||||||
|
expect(
|
||||||
|
getTicketPrimaryAction(
|
||||||
|
{ ...activeTicket, status: 'pending_confirm' } as Ticket,
|
||||||
|
[completedAnalysis],
|
||||||
|
['resolved'],
|
||||||
|
[replyTask]
|
||||||
|
)
|
||||||
|
).toEqual({ kind: 'reply' })
|
||||||
|
expect(
|
||||||
|
getTicketPrimaryAction(
|
||||||
|
activeTicket,
|
||||||
|
[{ ...completedAnalysis, status: 'running' }],
|
||||||
|
['pending_confirm']
|
||||||
|
)
|
||||||
|
).toEqual({ kind: 'analyze' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -195,6 +264,67 @@ const replyTask: CustomerReplyTask = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('SupportAnalysisWorkspace', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
ticket: activeTicket,
|
||||||
|
error: '',
|
||||||
|
starting: false,
|
||||||
|
solutionBusy: false,
|
||||||
|
onStart: vi.fn(),
|
||||||
|
onRecommendSolution: vi.fn(),
|
||||||
|
onAdoptSolution: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('正式回复任务存在时不再重复展示 Agent 草稿', () => {
|
||||||
|
render(
|
||||||
|
<SupportAnalysisWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
items={[completedAnalysis]}
|
||||||
|
replyTasks={[replyTask]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('已生成客户回复任务')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /前往编辑回复/ })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(replyTask.draft_text)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('最新分析失败时保留历史选择器和人工排查入口', () => {
|
||||||
|
const failed = {
|
||||||
|
...completedAnalysis,
|
||||||
|
id: 10,
|
||||||
|
status: 'failed',
|
||||||
|
final_output: null,
|
||||||
|
error_message: '模型服务不可用',
|
||||||
|
created_at: '2026-07-27T09:00:00Z'
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<SupportAnalysisWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
items={[failed, completedAnalysis]}
|
||||||
|
replyTasks={[]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('combobox', { name: '选择历史分析版本' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('本次分析需要人工接管')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /继续人工排查/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工单结束后分析结果只读并隐藏重新分析和方案执行', () => {
|
||||||
|
render(
|
||||||
|
<SupportAnalysisWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
ticket={{ ...activeTicket, status: 'resolved' } as Ticket}
|
||||||
|
items={[completedAnalysis]}
|
||||||
|
replyTasks={[replyTask]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/工单已结束,分析结果只读保留/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '再次分析' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '标记推荐' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '采纳并解决' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('CustomerReplyPanel', () => {
|
describe('CustomerReplyPanel', () => {
|
||||||
it('允许人工修订并携带当前版本保存', () => {
|
it('允许人工修订并携带当前版本保存', () => {
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
161
frontend/src/features/tickets/supportAnalysisViewModel.test.ts
Normal file
161
frontend/src/features/tickets/supportAnalysisViewModel.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { buildSupportAnalysisViewModel } from './supportAnalysisViewModel'
|
||||||
|
import type {
|
||||||
|
CustomerReplyTask,
|
||||||
|
Ticket,
|
||||||
|
TicketSupportAnalysis,
|
||||||
|
TicketStatus
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
function ticket(status: TicketStatus = 'in_progress') {
|
||||||
|
return { id: 7, status } as Ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
function analysis(
|
||||||
|
status: string = 'succeeded',
|
||||||
|
overrides: Partial<TicketSupportAnalysis> = {}
|
||||||
|
): TicketSupportAnalysis {
|
||||||
|
return {
|
||||||
|
id: 9,
|
||||||
|
ticket_id: 7,
|
||||||
|
run_public_id: '31ca95a8-e8d5-40d4-9304-17a178d25422',
|
||||||
|
agent_version: 3,
|
||||||
|
status,
|
||||||
|
progress_stage: 'completed',
|
||||||
|
progress_message: '',
|
||||||
|
query: '登录回调失败',
|
||||||
|
retrieval_policy: {},
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
source_type: 'solution',
|
||||||
|
source_id: 12,
|
||||||
|
source_version: 'v3',
|
||||||
|
title: '认证回调恢复方案',
|
||||||
|
excerpt: '核对 state 参数与会话写入。',
|
||||||
|
score: 0.91,
|
||||||
|
applicable_scope: { environment: '生产' },
|
||||||
|
auto_resolve_policy: {
|
||||||
|
allow_auto_resolve: false,
|
||||||
|
requires_human_action: true,
|
||||||
|
requires_external_confirmation: false,
|
||||||
|
requires_production_change: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
source_type: 'solution',
|
||||||
|
source_id: 13,
|
||||||
|
source_version: 'v1',
|
||||||
|
title: '未被推荐的候选方案',
|
||||||
|
excerpt: '仅作为检索候选。',
|
||||||
|
score: 0.82,
|
||||||
|
applicable_scope: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 3,
|
||||||
|
source_type: 'knowledge',
|
||||||
|
source_id: 21,
|
||||||
|
source_version: 'v5',
|
||||||
|
title: 'OIDC 回调检查清单',
|
||||||
|
excerpt: '检查回调参数。',
|
||||||
|
score: 0.88,
|
||||||
|
applicable_scope: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
final_output: {
|
||||||
|
analysis_summary: '优先检查认证回调状态。',
|
||||||
|
troubleshooting_steps: ['核对 state 参数'],
|
||||||
|
candidate_solution_ids: [12, 999],
|
||||||
|
customer_reply_draft: '正在排查认证回调。',
|
||||||
|
citations: [1, 3, 99],
|
||||||
|
auto_resolve_recommended: false
|
||||||
|
},
|
||||||
|
automation_decision: {
|
||||||
|
status: 'manual_recommendation',
|
||||||
|
checks: {
|
||||||
|
global_policy_enabled: true,
|
||||||
|
risk_allowed: false
|
||||||
|
},
|
||||||
|
reasons: ['requires_production_change']
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSupportAnalysisViewModel', () => {
|
||||||
|
it.each([
|
||||||
|
[null, 'in_progress', 'not_started'],
|
||||||
|
['queued', 'in_progress', 'queued'],
|
||||||
|
['running', 'in_progress', 'running'],
|
||||||
|
['failed', 'in_progress', 'failed'],
|
||||||
|
['succeeded', 'in_progress', 'succeeded'],
|
||||||
|
['succeeded', 'resolved', 'read_only'],
|
||||||
|
['failed', 'closed', 'read_only']
|
||||||
|
] as const)('将 %s / %s 派生为 %s', (runStatus, ticketStatus, expected) => {
|
||||||
|
const view = buildSupportAnalysisViewModel(
|
||||||
|
ticket(ticketStatus),
|
||||||
|
runStatus ? analysis(runStatus) : null,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
expect(view.displayState).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('推荐方案只来自 candidate_solution_ids 且必须存在于冻结方案引用', () => {
|
||||||
|
const view = buildSupportAnalysisViewModel(ticket(), analysis(), [])
|
||||||
|
expect(view.recommendations.map((item) => item.solutionId)).toEqual([12])
|
||||||
|
expect(view.recommendations[0].riskLabels).toEqual(['需要人工操作', '涉及生产变更'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('正式引用只接受 citations 指定的冻结排名并按来源分组', () => {
|
||||||
|
const view = buildSupportAnalysisViewModel(ticket(), analysis(), [])
|
||||||
|
expect(view.citedCount).toBe(2)
|
||||||
|
expect(view.citedReferences.solution.map((item) => item.rank)).toEqual([1])
|
||||||
|
expect(view.citedReferences.knowledge.map((item) => item.rank)).toEqual([3])
|
||||||
|
expect(view.citedReferences.historical_ticket).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('把自动化检查转换为带文字的人工处理结论', () => {
|
||||||
|
const view = buildSupportAnalysisViewModel(ticket(), analysis(), [])
|
||||||
|
expect(view.automation.label).toBe('需要人工处理')
|
||||||
|
expect(view.automation.primaryReason).toBe('涉及生产变更')
|
||||||
|
expect(view.automation.checks).toEqual([
|
||||||
|
{ key: 'global_policy_enabled', label: '全局自动解决策略', passed: true },
|
||||||
|
{ key: 'risk_allowed', label: '风险检查', passed: false }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['eligible', '可自动解决'],
|
||||||
|
['auto_resolved', '已自动解决']
|
||||||
|
] as const)('展示 %s 自动化结果', (status, label) => {
|
||||||
|
const view = buildSupportAnalysisViewModel(
|
||||||
|
ticket(),
|
||||||
|
analysis('succeeded', {
|
||||||
|
automation_decision: {
|
||||||
|
status,
|
||||||
|
checks: { global_policy_enabled: true },
|
||||||
|
reasons: [],
|
||||||
|
applied: status === 'auto_resolved'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
expect(view.automation.label).toBe(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('关联当前分析生成的正式客户回复任务', () => {
|
||||||
|
const task = { id: 15, support_analysis_id: 9 } as CustomerReplyTask
|
||||||
|
const unrelated = { id: 16, support_analysis_id: 8 } as CustomerReplyTask
|
||||||
|
const view = buildSupportAnalysisViewModel(ticket(), analysis(), [unrelated, task])
|
||||||
|
expect(view.formalReplyTask).toBe(task)
|
||||||
|
})
|
||||||
|
})
|
||||||
194
frontend/src/features/tickets/supportAnalysisViewModel.ts
Normal file
194
frontend/src/features/tickets/supportAnalysisViewModel.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import type {
|
||||||
|
CustomerReplyTask,
|
||||||
|
SupportAnalysisReference,
|
||||||
|
Ticket,
|
||||||
|
TicketSupportAnalysis
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export type SupportAnalysisDisplayState =
|
||||||
|
| 'not_started'
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'failed'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'read_only'
|
||||||
|
|
||||||
|
export type SupportRecommendation = {
|
||||||
|
solutionId: number
|
||||||
|
title: string
|
||||||
|
score: number
|
||||||
|
applicableScope: Record<string, unknown>
|
||||||
|
riskLabels: string[]
|
||||||
|
autoResolveEligible: boolean
|
||||||
|
reference: SupportAnalysisReference
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedSupportReferences = {
|
||||||
|
knowledge: SupportAnalysisReference[]
|
||||||
|
solution: SupportAnalysisReference[]
|
||||||
|
historical_ticket: SupportAnalysisReference[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SupportAnalysisViewModel = {
|
||||||
|
analysis: TicketSupportAnalysis | null
|
||||||
|
displayState: SupportAnalysisDisplayState
|
||||||
|
recommendations: SupportRecommendation[]
|
||||||
|
citedReferences: GroupedSupportReferences
|
||||||
|
allReferences: GroupedSupportReferences
|
||||||
|
citedCount: number
|
||||||
|
formalReplyTask: CustomerReplyTask | null
|
||||||
|
automation: {
|
||||||
|
status: 'eligible' | 'manual_recommendation' | 'auto_resolved' | 'unavailable'
|
||||||
|
label: string
|
||||||
|
primaryReason: string
|
||||||
|
checks: Array<{ key: string; label: string; passed: boolean }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHECK_LABELS: Record<string, string> = {
|
||||||
|
global_policy_enabled: '全局自动解决策略',
|
||||||
|
threshold_satisfied: '匹配度阈值',
|
||||||
|
score_threshold_met: '匹配度阈值',
|
||||||
|
scope_satisfied: '适用范围',
|
||||||
|
scope_matched: '适用范围',
|
||||||
|
permission_satisfied: '操作权限',
|
||||||
|
permission_allowed: '操作权限',
|
||||||
|
version_satisfied: '方案版本',
|
||||||
|
version_published: '方案版本',
|
||||||
|
risk_satisfied: '风险检查',
|
||||||
|
risk_allowed: '风险检查',
|
||||||
|
allow_auto_resolve: '方案允许自动解决',
|
||||||
|
no_human_action: '无需人工操作',
|
||||||
|
no_external_confirmation: '无需外部确认',
|
||||||
|
no_production_change: '不涉及生产变更'
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASON_LABELS: Record<string, string> = {
|
||||||
|
agent_not_recommended: 'Agent 未建议自动解决',
|
||||||
|
global_policy_disabled: '全局策略未启用',
|
||||||
|
below_threshold: '匹配度不足',
|
||||||
|
score_below_threshold: '匹配度不足',
|
||||||
|
scope_mismatch: '方案适用范围不匹配',
|
||||||
|
permission_denied: '当前账号无自动解决权限',
|
||||||
|
version_not_published: '方案版本未发布',
|
||||||
|
requires_human_action: '方案要求人工操作',
|
||||||
|
requires_external_confirmation: '需要外部确认',
|
||||||
|
requires_production_change: '涉及生产变更',
|
||||||
|
risk_check_failed: '风险检查未通过'
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyGroups(): GroupedSupportReferences {
|
||||||
|
return { knowledge: [], solution: [], historical_ticket: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupSupportReferences(
|
||||||
|
references: SupportAnalysisReference[]
|
||||||
|
): GroupedSupportReferences {
|
||||||
|
return references.reduce((groups, reference) => {
|
||||||
|
groups[reference.source_type].push(reference)
|
||||||
|
return groups
|
||||||
|
}, emptyGroups())
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayState(
|
||||||
|
ticket: Ticket,
|
||||||
|
analysis: TicketSupportAnalysis | null
|
||||||
|
): SupportAnalysisDisplayState {
|
||||||
|
if (!analysis) return 'not_started'
|
||||||
|
const ended = ticket.status === 'resolved' || ticket.status === 'closed'
|
||||||
|
if (ended) return 'read_only'
|
||||||
|
if (analysis.status === 'queued') return 'queued'
|
||||||
|
if (analysis.status === 'running') return 'running'
|
||||||
|
if (analysis.status === 'failed') return 'failed'
|
||||||
|
return 'succeeded'
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendationFromReference(
|
||||||
|
reference: SupportAnalysisReference
|
||||||
|
): SupportRecommendation {
|
||||||
|
const policy = reference.auto_resolve_policy
|
||||||
|
const riskLabels = [
|
||||||
|
policy?.requires_human_action ? '需要人工操作' : '',
|
||||||
|
policy?.requires_external_confirmation ? '需要外部确认' : '',
|
||||||
|
policy?.requires_production_change ? '涉及生产变更' : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return {
|
||||||
|
solutionId: reference.source_id,
|
||||||
|
title: reference.title,
|
||||||
|
score: reference.score,
|
||||||
|
applicableScope: reference.applicable_scope,
|
||||||
|
riskLabels,
|
||||||
|
autoResolveEligible: Boolean(
|
||||||
|
policy?.allow_auto_resolve
|
||||||
|
&& !policy.requires_human_action
|
||||||
|
&& !policy.requires_external_confirmation
|
||||||
|
&& !policy.requires_production_change
|
||||||
|
),
|
||||||
|
reference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationView(analysis: TicketSupportAnalysis | null) {
|
||||||
|
const decision = analysis?.automation_decision
|
||||||
|
const status: SupportAnalysisViewModel['automation']['status'] =
|
||||||
|
decision?.status ?? 'unavailable'
|
||||||
|
const labels = {
|
||||||
|
eligible: '可自动解决',
|
||||||
|
auto_resolved: '已自动解决',
|
||||||
|
manual_recommendation: '需要人工处理',
|
||||||
|
unavailable: '尚未完成策略判断'
|
||||||
|
} as const
|
||||||
|
const firstReason = decision?.reasons?.[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
label: labels[status],
|
||||||
|
primaryReason: firstReason
|
||||||
|
? REASON_LABELS[firstReason] ?? firstReason
|
||||||
|
: status === 'eligible'
|
||||||
|
? '全部自动化检查已通过'
|
||||||
|
: status === 'auto_resolved'
|
||||||
|
? '平台已按受控策略应用方案'
|
||||||
|
: '等待分析结果与策略检查',
|
||||||
|
checks: Object.entries(decision?.checks ?? {}).map(([key, passed]) => ({
|
||||||
|
key,
|
||||||
|
label: CHECK_LABELS[key] ?? key,
|
||||||
|
passed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将一次冻结的支持分析快照转换为页面可消费模型。
|
||||||
|
*
|
||||||
|
* 推荐方案只认 candidate_solution_ids,正式引用只认 citations 中的冻结排名;
|
||||||
|
* 这样前端不会把检索候选误表述为 Agent 已推荐或已引用的依据。
|
||||||
|
*/
|
||||||
|
export function buildSupportAnalysisViewModel(
|
||||||
|
ticket: Ticket,
|
||||||
|
analysis: TicketSupportAnalysis | null,
|
||||||
|
replyTasks: CustomerReplyTask[]
|
||||||
|
): SupportAnalysisViewModel {
|
||||||
|
const candidateIds = new Set(analysis?.final_output?.candidate_solution_ids ?? [])
|
||||||
|
const citedRanks = new Set(analysis?.final_output?.citations ?? [])
|
||||||
|
const references = analysis?.references ?? []
|
||||||
|
const citedReferences = references.filter((reference) => citedRanks.has(reference.rank))
|
||||||
|
|
||||||
|
return {
|
||||||
|
analysis,
|
||||||
|
displayState: displayState(ticket, analysis),
|
||||||
|
recommendations: references
|
||||||
|
.filter(
|
||||||
|
(reference) =>
|
||||||
|
reference.source_type === 'solution' && candidateIds.has(reference.source_id)
|
||||||
|
)
|
||||||
|
.map(recommendationFromReference),
|
||||||
|
citedReferences: groupSupportReferences(citedReferences),
|
||||||
|
allReferences: groupSupportReferences(references),
|
||||||
|
citedCount: citedReferences.length,
|
||||||
|
formalReplyTask:
|
||||||
|
replyTasks.find((task) => task.support_analysis_id === analysis?.id) ?? null,
|
||||||
|
automation: automationView(analysis)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user