Files
ai-ticket-platform/frontend/src/features/tickets/SupportAnalysisWorkspace.tsx
2026-07-28 17:59:39 +08:00

581 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}