diff --git a/frontend/src/app/route-meta.test.tsx b/frontend/src/app/route-meta.test.tsx index d5c1a78..3241a6c 100644 --- a/frontend/src/app/route-meta.test.tsx +++ b/frontend/src/app/route-meta.test.tsx @@ -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') diff --git a/frontend/src/app/route-meta.tsx b/frontend/src/app/route-meta.tsx index e1aa764..f1fd8ad 100644 --- a/frontend/src/app/route-meta.tsx +++ b/frontend/src/app/route-meta.tsx @@ -58,7 +58,7 @@ export const routeMeta: RouteMeta[] = [ { key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => }, { key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => }, { key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => }, - { key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => }, + { key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'on-demand', render: (match) => }, { key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => }, { key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, { key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => }, diff --git a/frontend/src/features/tickets/SupportAnalysisWorkspace.tsx b/frontend/src/features/tickets/SupportAnalysisWorkspace.tsx new file mode 100644 index 0000000..5ff676c --- /dev/null +++ b/frontend/src/features/tickets/SupportAnalysisWorkspace.tsx @@ -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 = { + queued: '排队中', + running: '分析中', + succeeded: '成功', + completed: '成功', + failed: '失败' +} + +function scrollToSection(id: string) { + document.getElementById(id)?.scrollIntoView({ block: 'start' }) +} + +function scopeText(scope: Record) { + 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 ( +
+ + {emptyText} +
+ ) + } + + return ( +
+ {REFERENCE_GROUPS.map((group) => { + const references = groups[group.key] + if (references.length === 0) return null + return ( +
+
+ {group.label} · {references.length} +
+ +
+ ) + })} +
+ ) +} + +function RecommendationCard({ + recommendation, + busy, + readOnly, + onRecommend, + onAdopt +}: { + recommendation: SupportRecommendation + busy: boolean + readOnly: boolean + onRecommend: (solutionId: number) => void + onAdopt: (solutionId: number) => void +}) { + return ( +
+
+
+
+
{recommendation.title}
+ 匹配 {Math.round(recommendation.score * 100)}% + + {recommendation.autoResolveEligible ? : } + {recommendation.autoResolveEligible ? '允许自动化' : '需要人工确认'} + +
+

+ {scopeText(recommendation.applicableScope)} +

+ {recommendation.riskLabels.length ? ( +
+ {recommendation.riskLabels.map((label) => ( + + + {label} + + ))} +
+ ) : null} +
+ + 冻结依据 #{recommendation.reference.rank} + +
+
+ + {!readOnly ? ( + <> + + + + ) : null} +
+
+ ) +} + +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 ( +
+
+
+

+ + 支持分析 +

+

+ 尚未运行支持分析。基于已发布知识、方案与已解决历史工单生成可追溯的排查建议。 +

+
+ +
+
+ 问题描述已就绪 + 正式依据由运行时冻结 + 分析前不会展示空的结论、步骤和引用区域。 +
+ {error ? ( + + + 支持分析未启动 + {error} + + ) : null} +
+ ) + } + + return ( +
+
+
+
+

+ + 支持分析 +

+ + {active ? : null} + {STATUS_LABELS[selected.status] ?? selected.status} + + Agent v{selected.agent_version} + {selected.references.length} 条冻结依据 +
+

+ {selected.started_at + ? `开始于 ${formatDateTime(selected.started_at)}` + : `创建于 ${formatDateTime(selected.created_at)}`} + {readOnly ? ' · 工单已结束,分析结果只读保留' : ''} +

+
+
+ {items.length > 1 ? ( + + ) : null} + {!readOnly ? ( + + ) : null} + +
+
+ + {error ? ( + + + 支持分析未启动 + {error} + + ) : null} + + {active ? ( + + + {selected.progress_message || '正在执行支持分析'} + + 已冻结 {selected.references.length} 条候选依据。运行期间不可重复启动,可从运行轨迹查看详细进度。 + + + ) : null} + + {selected.status === 'failed' ? ( + + + 本次分析需要人工接管 + + {selected.error_message || '模型、检索或结构化输出未完成。原有历史成功结果仍可通过上方版本选择器查看。'} + + + + ) : null} + + {output ? ( + <> +
+

+ 分析结论 +

+

+ {output.analysis_summary} +

+
+ + {output.troubleshooting_steps.length ? ( +
+

+ 建议排查顺序 +

+
    + {output.troubleshooting_steps.map((step, index) => ( +
  1. + + {String(index + 1).padStart(2, '0')} + + {step} +
  2. + ))} +
+

+ 排查步骤为只读建议;实际执行结果请记录到下方统一活动流。 +

+
+ ) : null} + + + +
+
+
+

Agent 推荐方案

+

+ 仅展示本次输出明确列入 candidate_solution_ids 的冻结方案。 +

+
+ + {view.automation.status === 'manual_recommendation' ? ( + + ) : ( + + )} + {view.automation.label} + +
+

{view.automation.primaryReason}

+ {view.recommendations.length ? ( +
+ {view.recommendations.map((recommendation) => ( + + ))} +
+ ) : ( +

+ 本次分析未明确推荐可复用方案,请继续按排查步骤人工处理。 +

+ )} + {view.automation.checks.length ? ( + + + + + +
+ {view.automation.checks.map((check) => ( +
+ {check.passed ? ( + + ) : ( + + )} + {check.label} + + {check.passed ? '通过' : '未通过'} + +
+ ))} +
+
+
+ ) : null} +
+ +
+
+

+ {view.formalReplyTask ? '已生成客户回复任务' : '正在生成正式回复任务'} +

+

+ {view.formalReplyTask + ? '正式任务是唯一可编辑、发送和确认送达的回复来源。' + : output.customer_reply_draft || '分析结果已完成,等待回复任务同步。'} +

+
+ {view.formalReplyTask ? ( + + ) : null} +
+ +
+
+

正式引用依据

+ + + {view.citedCount}/{selected.references.length} + +
+ + + + + + + + + +
+ + ) : null} +
+ ) +} diff --git a/frontend/src/features/tickets/TicketActivityPanel.test.ts b/frontend/src/features/tickets/TicketActivityPanel.test.ts new file mode 100644 index 0000000..7f5647c --- /dev/null +++ b/frontend/src/features/tickets/TicketActivityPanel.test.ts @@ -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' + ]) + }) +}) diff --git a/frontend/src/features/tickets/TicketActivityPanel.tsx b/frontend/src/features/tickets/TicketActivityPanel.tsx new file mode 100644 index 0000000..0760a40 --- /dev/null +++ b/frontend/src/features/tickets/TicketActivityPanel.tsx @@ -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 = { + 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) { + 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([]) + const [content, setContent] = React.useState('') + const [commentMode, setCommentMode] = React.useState<'internal' | 'public'>('internal') + const [filter, setFilter] = React.useState('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 ( +
+
+
+

+ 活动与沟通 · {activities.length} +

+

+ 状态、字段、材料、Agent 运行与沟通记录按时间统一归档。 +

+
+ { + if (value) setFilter(value as ActivityFilter) + }} + variant="outline" + size="sm" + aria-label="筛选活动" + className="max-w-full overflow-x-auto" + > + 全部 + 内部 + 客户可见 + 系统 + +
+ + {error ? ( + + 活动记录暂不可用 + {error} + + ) : null} + + {canComment ? ( +
+
+ + + {commentMode === 'public' + ? '只记录客户可见内容;实际发送仍由客户回复任务完成。' + : '只有内部支持成员可以看到。'} + +
+