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)
|
||||
})
|
||||
|
||||
it('核心业务默认停靠 Agent,治理页按需打开', () => {
|
||||
expect(matchRoute('/tickets/42').meta.agent).toBe('docked')
|
||||
it('工单详情与治理页按需打开 Agent,其余核心业务保持停靠', () => {
|
||||
expect(matchRoute('/tickets/42').meta.agent).toBe('on-demand')
|
||||
expect(matchRoute('/knowledge').meta.agent).toBe('docked')
|
||||
expect(matchRoute('/system/users').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: '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-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: '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 /> },
|
||||
|
||||
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 { 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 {
|
||||
CustomerReplyTask,
|
||||
Ticket,
|
||||
@@ -123,12 +131,12 @@ describe('SupportAnalysisPanel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('尚未运行支持分析')).toBeInTheDocument()
|
||||
expect(screen.getByText(/尚未运行支持分析/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始分析' }))
|
||||
expect(onStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('展示结构化建议、回复草稿和按排名冻结的引用', () => {
|
||||
it('按处理价值展示结论、推荐方案、策略和正式引用', () => {
|
||||
render(
|
||||
<SupportAnalysisPanel
|
||||
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(/Agent 未建议自动解决/)).toBeInTheDocument()
|
||||
expect(screen.getByText('订单连接池恢复方案')).toBeInTheDocument()
|
||||
expect(screen.getByText('91%')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /查看运行轨迹/ })).toHaveAttribute(
|
||||
expect(screen.getAllByText('订单连接池恢复方案')).toHaveLength(2)
|
||||
expect(screen.getAllByText(/91%/).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('link', { name: /运行轨迹/ })).toHaveAttribute(
|
||||
'href',
|
||||
'#/agent-runs/31ca95a8-e8d5-40d4-9304-17a178d25422'
|
||||
)
|
||||
@@ -164,7 +172,68 @@ describe('SupportAnalysisPanel', () => {
|
||||
)
|
||||
|
||||
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', () => {
|
||||
it('允许人工修订并携带当前版本保存', () => {
|
||||
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