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

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