feat: rebuild support workspace frontend
This commit is contained in:
@@ -30,8 +30,21 @@ export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps
|
||||
}
|
||||
|
||||
return (
|
||||
<AppSection title="评论与协作">
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<AppSection
|
||||
variant="card"
|
||||
title="评论与协作"
|
||||
description="选择内容的可见范围后再保存。"
|
||||
footer={
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">{commentMode === 'public' ? '用户可以看到此回复' : '仅内部成员可见'}</span>
|
||||
<Button type="submit" variant="secondary" disabled={!content.trim()}>
|
||||
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<Select value={commentMode} onValueChange={setCommentMode}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="选择回复类型" />
|
||||
@@ -50,12 +63,8 @@ export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps
|
||||
/>
|
||||
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
|
||||
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="secondary">
|
||||
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppSection>
|
||||
</AppSection>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { createTicket } from './api'
|
||||
import { TICKET_CATEGORIES } from './ticketCategories'
|
||||
@@ -60,9 +61,11 @@ export function TicketCreatePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppPage maxWidth="6xl" contentClassName="grid gap-5">
|
||||
<AppPageHeader title="创建工单" />
|
||||
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
|
||||
<AppPage
|
||||
contentClassName="grid gap-5"
|
||||
header={<AppPageHeader title="创建工单" description="按提出方、问题描述和影响范围记录完整上下文" actions={<><Button type="button" variant="ghost" onClick={() => (window.location.hash = '/tickets')}>取消</Button><Button type="button" variant="outline" onClick={() => toast('暂存入口为视觉预留,当前不会保存数据')}>暂存</Button></>} />}
|
||||
>
|
||||
<form id="ticket-create-form" className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
@@ -72,72 +75,7 @@ export function TicketCreatePage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AppSection title="基础信息">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="问题分类" required>
|
||||
<Select
|
||||
value={form.category_code}
|
||||
onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择问题分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TICKET_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category.code} value={category.code}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField label="工单标题" htmlFor="ticket-title" required>
|
||||
<Input
|
||||
id="ticket-title"
|
||||
required
|
||||
placeholder="例如:查询客户采集进度"
|
||||
value={form.title}
|
||||
onChange={(event) => updateField('title', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="问题信息">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="问题表现" htmlFor="ticket-problem-performance" required>
|
||||
<Input
|
||||
id="ticket-problem-performance"
|
||||
required
|
||||
placeholder="例如:采集进度长时间未更新"
|
||||
value={form.problem_performance}
|
||||
onChange={(event) => updateField('problem_performance', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
|
||||
<Input
|
||||
id="ticket-problem-object"
|
||||
required
|
||||
placeholder="例如:客户名称、账号、机构或任务编号"
|
||||
value={form.problem_object}
|
||||
onChange={(event) => updateField('problem_object', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<AppField label="问题描述" htmlFor="ticket-description" required>
|
||||
<Textarea
|
||||
id="ticket-description"
|
||||
required
|
||||
placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。"
|
||||
value={form.description}
|
||||
onChange={(event) => updateField('description', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="提出信息">
|
||||
<AppSection variant="card" title="提出方与对象" description="先确认由谁提出,以及具体影响到哪个业务对象。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="提出人" htmlFor="ticket-submitter-name" required>
|
||||
<Input
|
||||
@@ -157,26 +95,33 @@ export function TicketCreatePage() {
|
||||
onChange={(event) => updateField('submitter_org', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
|
||||
<Input
|
||||
id="ticket-problem-object"
|
||||
required
|
||||
placeholder="客户、账号、机构或任务编号"
|
||||
value={form.problem_object}
|
||||
onChange={(event) => updateField('problem_object', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="环境" required>
|
||||
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}><SelectTrigger className="w-full"><SelectValue placeholder="请选择问题环境" /></SelectTrigger><SelectContent><SelectGroup>{ENVIRONMENT_OPTIONS.map((environment) => <SelectItem key={environment} value={environment}>{environment}</SelectItem>)}</SelectGroup></SelectContent></Select>
|
||||
</AppField>
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="影响与环境">
|
||||
<AppSection variant="card" title="问题描述" description="描述用户看到的现象、发生时间与期望结果。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="优先级" required>
|
||||
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择优先级" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="urgent">紧急</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField label="工单标题" htmlFor="ticket-title" required><Input id="ticket-title" required placeholder="例如:查询客户采集进度" value={form.title} onChange={(event) => updateField('title', event.target.value)} /></AppField>
|
||||
<AppField label="问题表现" htmlFor="ticket-problem-performance" required><Input id="ticket-problem-performance" required placeholder="例如:采集进度长时间未更新" value={form.problem_performance} onChange={(event) => updateField('problem_performance', event.target.value)} /></AppField>
|
||||
</div>
|
||||
<AppField label="问题描述" htmlFor="ticket-description" required><Textarea id="ticket-description" required placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。" value={form.description} onChange={(event) => updateField('description', event.target.value)} /></AppField>
|
||||
</AppSection>
|
||||
|
||||
<AppSection variant="card" title="分类与影响" description="用于确定处理队列、响应顺序和影响范围。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AppField label="问题分类" required><Select value={form.category_code} onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}><SelectTrigger className="w-full"><SelectValue placeholder="请选择问题分类" /></SelectTrigger><SelectContent><SelectGroup>{TICKET_CATEGORIES.map((category) => <SelectItem key={category.code} value={category.code}>{category.label}</SelectItem>)}</SelectGroup></SelectContent></Select></AppField>
|
||||
<AppField label="优先级" required><Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}><SelectTrigger className="w-full"><SelectValue placeholder="请选择优先级" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="low">低</SelectItem><SelectItem value="medium">中</SelectItem><SelectItem value="high">高</SelectItem><SelectItem value="urgent">紧急</SelectItem></SelectGroup></SelectContent></Select></AppField>
|
||||
<AppField label="影响范围" htmlFor="ticket-impact-scope">
|
||||
<Input
|
||||
id="ticket-impact-scope"
|
||||
@@ -195,41 +140,32 @@ export function TicketCreatePage() {
|
||||
onChange={(event) => updateField('related_system', event.target.value)}
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="环境" required>
|
||||
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择问题环境" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{ENVIRONMENT_OPTIONS.map((environment) => (
|
||||
<SelectItem key={environment} value={environment}>
|
||||
{environment}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection title="提交确认">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 size-4" />
|
||||
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Send data-icon="inline-start" />
|
||||
{submitting ? '提交中...' : '提交工单'}
|
||||
</Button>
|
||||
<AppSection
|
||||
variant="card"
|
||||
title="提交确认"
|
||||
description="提交后工单将进入对应的支持处理队列。"
|
||||
footer={
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">确认无误后提交</span>
|
||||
<Button className="hidden md:inline-flex" type="submit" disabled={submitting}>
|
||||
<Send data-icon="inline-start" />
|
||||
{submitting ? '提交中...' : '提交工单'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 size-4" />
|
||||
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
|
||||
</div>
|
||||
</AppSection>
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-4">
|
||||
<AppSection title="建单提示">
|
||||
<AppSection variant="card" title="建单提示">
|
||||
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex gap-2">
|
||||
<Lightbulb className="mt-0.5 size-4" />
|
||||
@@ -246,16 +182,10 @@ export function TicketCreatePage() {
|
||||
</div>
|
||||
</AppSection>
|
||||
|
||||
<AppSection
|
||||
title="AI 解决方案"
|
||||
action={<Badge variant="ai">即将接入</Badge>}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
后续阶段将由 AI 识别问题后自动回填,当前不作为人工必填项。
|
||||
</p>
|
||||
</AppSection>
|
||||
<AppSection variant="card" title="Agent 辅助" action={<Badge variant="ai">演示</Badge>}><p className="text-sm text-muted-foreground">右侧助手会根据当前页面字段提供排查结构,但不会提交或保存工单。</p></AppSection>
|
||||
</div>
|
||||
</form>
|
||||
<div className="sticky bottom-3 rounded-lg border bg-card/95 p-3 shadow-lg backdrop-blur md:hidden"><Button form="ticket-create-form" type="submit" className="w-full" disabled={submitting}><Send data-icon="inline-start" />{submitting ? '提交中...' : '提交工单'}</Button></div>
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { Bot, CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
|
||||
import { CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
|
||||
@@ -10,8 +10,8 @@ import { AppSection } from '@/components/app/AppSection'
|
||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
|
||||
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
||||
import { selectDefaultTicketSolution } from '@/features/solutions/solutionSelection'
|
||||
import type { TicketSolution } from '@/features/solutions/types'
|
||||
@@ -33,14 +33,6 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
|
||||
closed: { label: '关闭工单', icon: XCircle }
|
||||
}
|
||||
|
||||
const STATUS_ACTION_HINTS: Partial<Record<TicketStatus, string>> = {
|
||||
pending: '补充信息完成后进入支持队列。',
|
||||
in_progress: '确认接手并开始技术排查。',
|
||||
pending_confirm: '已给出处理结果,等待业务侧确认。',
|
||||
resolved: '业务确认问题已解决,保留后续关闭入口。',
|
||||
closed: '关闭代表流程结束,不再继续处理。'
|
||||
}
|
||||
|
||||
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
||||
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
||||
return '取消工单'
|
||||
@@ -67,6 +59,11 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
||||
const [transitionError, setTransitionError] = React.useState('')
|
||||
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
|
||||
const agentContext = React.useMemo(() => ticket ? ({
|
||||
routeKey: 'ticket-detail', entityType: 'ticket', entityId: ticket.ticket_no, title: ticket.title, status: ticket.status,
|
||||
metadata: { 优先级: ticket.priority, 关联系统: ticket.related_system || '未填写', 问题对象: ticket.problem_object || '未填写' }
|
||||
}) : null, [ticket])
|
||||
useAgentPageContext(agentContext)
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshTicket()
|
||||
@@ -115,7 +112,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppPage maxWidth="6xl">
|
||||
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
|
||||
<AppEmptyState title="无法加载工单" description={error} />
|
||||
</AppPage>
|
||||
)
|
||||
@@ -123,7 +120,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<AppPage maxWidth="6xl">
|
||||
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
|
||||
<AppEmptyState title="正在加载工单" />
|
||||
</AppPage>
|
||||
)
|
||||
@@ -141,10 +138,12 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
: ''
|
||||
|
||||
return (
|
||||
<AppPage maxWidth="6xl">
|
||||
<AppPageHeader
|
||||
<AppPage
|
||||
maxWidth="6xl"
|
||||
header={<AppPageHeader
|
||||
title={ticket.title}
|
||||
description={ticket.ticket_no}
|
||||
breadcrumbLabel={ticket.ticket_no}
|
||||
meta={
|
||||
<>
|
||||
<AppStatusBadge status={ticket.status} />
|
||||
@@ -169,38 +168,9 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
/>}
|
||||
>
|
||||
<TicketOverviewCard ticket={ticket} />
|
||||
{nextStatuses.length ? (
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
|
||||
{nextStatuses.map((status) => {
|
||||
const action = STATUS_ACTIONS[status]
|
||||
const Icon = action?.icon ?? CircleDot
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className="flex min-h-20 items-start gap-3 rounded-md border border-border bg-background p-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={() => openTransition(status)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{getStatusActionLabel(ticket.status, status)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
{STATUS_ACTION_HINTS[status] ?? '记录本次处理动作并推进流程。'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<AppDetailLayout
|
||||
side={
|
||||
<>
|
||||
@@ -213,20 +183,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
refreshEvents()
|
||||
}}
|
||||
/>
|
||||
<AppSection title="AI 支持分析" action={<Badge variant="ai">后续接入</Badge>}>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Bot className="size-4" />
|
||||
智能排查占位
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-sm text-muted-foreground">后续阶段将在这里展示排查建议、相似工单和引用来源。</p>
|
||||
</div>
|
||||
</AppSection>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AppSection title="问题描述">
|
||||
<AppSection variant="card" title="问题描述">
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">问题分类</p>
|
||||
@@ -242,7 +202,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
</div>
|
||||
</div>
|
||||
</AppSection>
|
||||
<AppSection title="解决方案">
|
||||
<AppSection variant="card" title="解决方案">
|
||||
{ticket.resolution ? (
|
||||
<div className="grid gap-3">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.resolution}</p>
|
||||
@@ -289,8 +249,12 @@ function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
<Card className="gap-0 py-0">
|
||||
<CardHeader className="border-b bg-muted/30 py-3.5">
|
||||
<CardTitle className="text-sm font-semibold">工单概览</CardTitle>
|
||||
<CardDescription className="text-xs">提出方、责任人与业务上下文</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0">
|
||||
@@ -299,11 +263,11 @@ function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock3 className="size-4" />
|
||||
创建于 {formatDateTime(ticket.created_at)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between bg-muted/45 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2"><Clock3 className="size-4" />创建于 {formatDateTime(ticket.created_at)}</span>
|
||||
<span className="font-mono">{ticket.ticket_no}</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { ArrowRight, Clock3, Plus } from 'lucide-react'
|
||||
import { ArrowRight, Clock3, Filter, Plus } from 'lucide-react'
|
||||
|
||||
import { useCurrentRoute } from '@/app/route-meta'
|
||||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppPage } from '@/components/app/AppPage'
|
||||
@@ -19,7 +20,9 @@ import {
|
||||
PaginationPrevious
|
||||
} from '@/components/ui/pagination'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -31,6 +34,24 @@ import type { Ticket, TicketListResponse, TicketListView } from './types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function getInitialView(): TicketListView {
|
||||
const query = window.location.hash.split('?')[1] || ''
|
||||
const value = new URLSearchParams(query).get('view')
|
||||
return viewTabs.some((item) => item.value === value) ? value as TicketListView : 'all'
|
||||
}
|
||||
|
||||
function getInitialStatusGroup() {
|
||||
const query = window.location.hash.split('?')[1] || ''
|
||||
const value = new URLSearchParams(query).get('status_group')
|
||||
return value === 'active' || value === 'completed' ? value : ''
|
||||
}
|
||||
|
||||
function getInitialResolvedWithinDays() {
|
||||
const query = window.location.hash.split('?')[1] || ''
|
||||
const value = Number(new URLSearchParams(query).get('resolved_within_days'))
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function formatTicketTableDate(value: string) {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
@@ -146,6 +167,7 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
||||
]
|
||||
|
||||
export function TicketListPage() {
|
||||
const { route } = useCurrentRoute()
|
||||
const [result, setResult] = React.useState<TicketListResponse>({
|
||||
items: [],
|
||||
page: 1,
|
||||
@@ -153,8 +175,10 @@ export function TicketListPage() {
|
||||
total: 0,
|
||||
total_pages: 0
|
||||
})
|
||||
const [view, setView] = React.useState<TicketListView>('all')
|
||||
const [view, setView] = React.useState<TicketListView>(getInitialView)
|
||||
const [status, setStatus] = React.useState('all')
|
||||
const [statusGroup, setStatusGroup] = React.useState<'active' | 'completed' | ''>(getInitialStatusGroup)
|
||||
const [resolvedWithinDays, setResolvedWithinDays] = React.useState<number | undefined>(getInitialResolvedWithinDays)
|
||||
const [categoryCode, setCategoryCode] = React.useState('all')
|
||||
const [priority, setPriority] = React.useState('all')
|
||||
const [relatedSystem, setRelatedSystem] = React.useState('')
|
||||
@@ -162,6 +186,24 @@ export function TicketListPage() {
|
||||
const [error, setError] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [page, setPage] = React.useState(1)
|
||||
const [filterOpen, setFilterOpen] = React.useState(false)
|
||||
|
||||
const agentContext = React.useMemo(() => ({
|
||||
routeKey: 'tickets', entityType: 'ticket-list', title: `工单中心 · ${result.total} 条`, status: loading ? '加载中' : '已同步页面数据',
|
||||
metadata: { 当前页: String(result.page), 待办数: String(result.items.filter((ticket) => ticket.status === 'pending').length) }
|
||||
}), [loading, result])
|
||||
useAgentPageContext(agentContext)
|
||||
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(route.split('?')[1] || '')
|
||||
const nextView = params.get('view')
|
||||
const nextStatusGroup = params.get('status_group')
|
||||
const nextResolvedWithinDays = Number(params.get('resolved_within_days'))
|
||||
setView(viewTabs.some((item) => item.value === nextView) ? nextView as TicketListView : 'all')
|
||||
setStatusGroup(nextStatusGroup === 'active' || nextStatusGroup === 'completed' ? nextStatusGroup : '')
|
||||
setResolvedWithinDays(Number.isFinite(nextResolvedWithinDays) && nextResolvedWithinDays > 0 ? nextResolvedWithinDays : undefined)
|
||||
setPage(1)
|
||||
}, [route])
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -170,6 +212,8 @@ export function TicketListPage() {
|
||||
page_size: PAGE_SIZE,
|
||||
view,
|
||||
status: status === 'all' ? '' : status,
|
||||
status_group: statusGroup,
|
||||
resolved_within_days: resolvedWithinDays,
|
||||
category_code: categoryCode === 'all' ? '' : categoryCode,
|
||||
priority: priority === 'all' ? '' : priority,
|
||||
related_system: relatedSystem,
|
||||
@@ -181,10 +225,12 @@ export function TicketListPage() {
|
||||
})
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [categoryCode, page, priority, q, relatedSystem, status, view])
|
||||
}, [categoryCode, page, priority, q, relatedSystem, resolvedWithinDays, status, statusGroup, view])
|
||||
|
||||
function resetFilters() {
|
||||
setStatus('all')
|
||||
setStatusGroup('')
|
||||
setResolvedWithinDays(undefined)
|
||||
setCategoryCode('all')
|
||||
setPriority('all')
|
||||
setRelatedSystem('')
|
||||
@@ -227,11 +273,12 @@ export function TicketListPage() {
|
||||
setPage(target)
|
||||
}
|
||||
|
||||
const hasActiveFilters = status !== 'all' || categoryCode !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
|
||||
const hasActiveFilters = status !== 'all' || statusGroup !== '' || Boolean(resolvedWithinDays) || categoryCode !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
|
||||
|
||||
return (
|
||||
<AppPage contentClassName="gap-5">
|
||||
<AppPageHeader
|
||||
<AppPage
|
||||
contentClassName="gap-5"
|
||||
header={<AppPageHeader
|
||||
title="工单中心"
|
||||
meta={hasActiveFilters ? <Badge variant="info">已筛选</Badge> : <Badge variant="outline">全部范围</Badge>}
|
||||
actions={
|
||||
@@ -240,7 +287,8 @@ export function TicketListPage() {
|
||||
新建工单
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
/>}
|
||||
>
|
||||
|
||||
<TicketMetricCards tickets={result.items} />
|
||||
|
||||
@@ -262,19 +310,13 @@ export function TicketListPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TicketFilterBar
|
||||
q={q}
|
||||
status={status}
|
||||
categoryCode={categoryCode}
|
||||
priority={priority}
|
||||
relatedSystem={relatedSystem}
|
||||
onQChange={updateQuery}
|
||||
onStatusChange={updateStatus}
|
||||
onCategoryCodeChange={updateCategoryCode}
|
||||
onPriorityChange={updatePriority}
|
||||
onRelatedSystemChange={updateRelatedSystem}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
<div className="hidden md:block"><TicketFilterBar q={q} status={status} categoryCode={categoryCode} priority={priority} relatedSystem={relatedSystem} onQChange={updateQuery} onStatusChange={updateStatus} onCategoryCodeChange={updateCategoryCode} onPriorityChange={updatePriority} onRelatedSystemChange={updateRelatedSystem} onReset={resetFilters} /></div>
|
||||
<div className="md:hidden">
|
||||
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<SheetTrigger asChild><Button variant="outline" className="w-full"><Filter data-icon="inline-start" />筛选工单{hasActiveFilters ? <Badge variant="info">已启用</Badge> : null}</Button></SheetTrigger>
|
||||
<SheetContent side="right" className="w-[92vw] sm:max-w-md"><SheetHeader><SheetTitle>筛选工单</SheetTitle><SheetDescription>筛选条件与桌面端保持一致。</SheetDescription></SheetHeader><div className="overflow-y-auto px-4 pb-6"><TicketFilterBar q={q} status={status} categoryCode={categoryCode} priority={priority} relatedSystem={relatedSystem} onQChange={updateQuery} onStatusChange={updateStatus} onCategoryCodeChange={updateCategoryCode} onPriorityChange={updatePriority} onRelatedSystemChange={updateRelatedSystem} onReset={resetFilters} /></div></SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{error ? <AppEmptyState title="无法加载工单" description={error} /> : null}
|
||||
{loading && !error ? (
|
||||
@@ -287,7 +329,8 @@ export function TicketListPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
{!error && !loading ? (
|
||||
<AppDataTable
|
||||
<>
|
||||
<div className="hidden md:block"><AppDataTable
|
||||
columns={columns}
|
||||
rows={result.items}
|
||||
getRowKey={(ticket) => ticket.id}
|
||||
@@ -331,8 +374,23 @@ export function TicketListPage() {
|
||||
}
|
||||
tableClassName="table-fixed min-w-[980px]"
|
||||
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
|
||||
/>
|
||||
/></div>
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{result.items.length ? result.items.map((ticket) => <TicketMobileCard key={ticket.id} ticket={ticket} onClick={() => (window.location.hash = `/tickets/${ticket.id}`)} />) : <AppEmptyState title="暂无工单" description="调整筛选条件,或创建第一张工单后再查看。" />}
|
||||
<Card><CardContent className="grid gap-3 p-3"><p className="text-center text-xs text-muted-foreground">共 {result.total} 条 · 第 {result.page} / {Math.max(result.total_pages, 1)} 页</p><div className="grid grid-cols-2 gap-2"><Button variant="outline" disabled={result.page <= 1} onClick={() => goToPage(result.page - 1)}>上一页</Button><Button variant="outline" disabled={result.page >= result.total_pages} onClick={() => goToPage(result.page + 1)}>下一页</Button></div></CardContent></Card>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketMobileCard({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="rounded-lg border bg-card p-4 text-left shadow-sm outline-none transition-colors hover:border-primary/30 focus-visible:ring-2 focus-visible:ring-ring/40" onClick={onClick}>
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="font-mono text-[11px] text-primary">{ticket.ticket_no}</p><h2 className="mt-1 line-clamp-2 text-sm font-semibold">{ticket.title}</h2></div><AppPriorityBadge priority={ticket.priority} /></div>
|
||||
<p className="mt-3 line-clamp-2 text-xs leading-5 text-muted-foreground">{ticket.problem_performance || ticket.description || '暂无问题描述'}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2"><AppStatusBadge status={ticket.status} /><Badge variant="secondary">{getTicketCategoryLabel(ticket.category_code)}</Badge><span className="ml-auto text-[11px] text-muted-foreground">{formatTicketTableDate(ticket.updated_at)}</span></div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
import type { Ticket } from './types'
|
||||
|
||||
@@ -11,11 +10,11 @@ type TicketMetaPanelProps = {
|
||||
|
||||
export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>处理信息</CardTitle>
|
||||
<Card className="gap-0 py-0">
|
||||
<CardHeader className="border-b bg-muted/30 py-3.5">
|
||||
<CardTitle className="text-sm font-semibold">处理信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid gap-3">
|
||||
<AppField label="运行环境">
|
||||
<span className="text-sm text-foreground">{ticket.environment || '未填写'}</span>
|
||||
@@ -23,13 +22,12 @@ export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
|
||||
<AppField label="影响范围">
|
||||
<span className="text-sm text-foreground">{ticket.impact_scope || '未填写'}</span>
|
||||
</AppField>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">SLA</span>
|
||||
<Badge variant="outline">后续接入</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between bg-muted/45 px-4 py-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">SLA</span>
|
||||
<Badge variant="outline">后续接入</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,23 +39,23 @@ const metricConfig = [
|
||||
|
||||
export function TicketMetricCards({ tickets }: TicketMetricCardsProps) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3 xl:grid-cols-4">
|
||||
{metricConfig.map((metric) => {
|
||||
const Icon = metric.icon
|
||||
return (
|
||||
<Card key={metric.label} className="relative">
|
||||
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start">
|
||||
<Card key={metric.label} className="relative gap-2 py-3">
|
||||
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start px-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-sm">{metric.label}</CardTitle>
|
||||
<Badge variant="outline" className="mt-2">{metric.badge}</Badge>
|
||||
<Badge variant="outline" className="mt-1 hidden sm:inline-flex">{metric.badge}</Badge>
|
||||
</div>
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<p className="text-3xl font-semibold tabular-nums text-foreground">{metric.value(tickets)}</p>
|
||||
<p className="text-xs text-muted-foreground">当前列表范围</p>
|
||||
<CardContent className="grid gap-1 px-3">
|
||||
<p className="text-2xl font-semibold tabular-nums text-foreground">{metric.value(tickets)}</p>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">当前列表范围</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ type TicketStatusTimelineProps = {
|
||||
|
||||
export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) {
|
||||
return (
|
||||
<AppSection title="处理记录">
|
||||
<AppSection variant="card" title="处理记录">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3 rounded-md border border-border bg-background p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -71,6 +71,8 @@ export type TicketListParams = {
|
||||
related_system?: string
|
||||
assignee_id?: number
|
||||
view?: TicketListView
|
||||
status_group?: 'active' | 'completed' | ''
|
||||
resolved_within_days?: number
|
||||
}
|
||||
|
||||
export type TicketInput = {
|
||||
|
||||
Reference in New Issue
Block a user