581 lines
21 KiB
TypeScript
581 lines
21 KiB
TypeScript
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>
|
||
)
|
||
}
|