feat: redesign ticket detail workspace

This commit is contained in:
hailin
2026-07-28 17:59:39 +08:00
parent f68a044561
commit 3540755ac4
9 changed files with 2097 additions and 395 deletions

View File

@@ -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')

View File

@@ -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 /> },

View 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>
)
}

View 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'
])
})
})

View 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>
)
}

View File

@@ -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

View 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)
})
})

View 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)
}
}