feat: rebuild support workspace frontend

This commit is contained in:
hailin
2026-07-16 17:20:45 +08:00
parent fcb8121f0a
commit e90648e129
95 changed files with 4353 additions and 2165 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

@@ -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 = {