feat: add ticket workflow pages

This commit is contained in:
hailin
2026-06-30 18:49:41 +08:00
parent c80c286227
commit d40fdad6e9
13 changed files with 467 additions and 8 deletions

View File

@@ -1,6 +1,9 @@
import * as React from 'react'
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
import { TicketListPage } from '@/features/tickets/TicketListPage'
function getRoute() {
return window.location.hash.replace(/^#/, '') || '/tickets'
@@ -19,12 +22,14 @@ export function Router() {
return <DesignSystemPreview />
}
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto max-w-6xl">
<p className="text-sm text-muted-foreground">Route</p>
<h1 className="mt-2 text-xl font-semibold">{route}</h1>
</div>
</main>
)
if (route === '/tickets/new') {
return <TicketCreatePage />
}
const ticketMatch = route.match(/^\/tickets\/(\d+)$/)
if (ticketMatch) {
return <TicketDetailPage ticketId={Number(ticketMatch[1])} />
}
return <TicketListPage />
}

View File

@@ -0,0 +1,13 @@
type AppEmptyStateProps = {
title: string
description?: string
}
export function AppEmptyState({ title, description }: AppEmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-border bg-surface px-4 py-8 text-center">
<p className="text-sm font-medium text-foreground">{title}</p>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import type * as React from 'react'
type AppFieldProps = {
label: string
children: React.ReactNode
}
export function AppField({ label, children }: AppFieldProps) {
return (
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
)
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
import { cn } from '@/lib/cn'
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
'h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
)
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
import { cn } from '@/lib/cn'
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
'h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-foreground outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
)
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
import { cn } from '@/lib/cn'
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
'min-h-28 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
)
}

View File

@@ -0,0 +1,39 @@
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { addTicketComment } from './api'
export function TicketComments({ ticketId }: { ticketId: number }) {
const [content, setContent] = React.useState('')
const [lastComment, setLastComment] = React.useState('')
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!content.trim()) return
const comment = await addTicketComment(ticketId, content)
setLastComment(comment.content)
setContent('')
}
return (
<Card>
<CardHeader>
<h2 className="text-sm font-medium">Comments</h2>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={handleSubmit}>
<Textarea value={content} onChange={(event) => setContent(event.target.value)} />
{lastComment ? <p className="text-sm text-muted-foreground">Latest: {lastComment}</p> : null}
<div className="flex justify-end">
<Button type="submit" variant="secondary">
Add Comment
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,88 @@
import * as React from 'react'
import { AppField } from '@/components/app/AppField'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createTicket } from './api'
import type { TicketInput } from './types'
const initialForm: TicketInput = {
title: '',
description: '',
priority: 'medium',
impact_scope: '',
environment: '',
related_system: ''
}
export function TicketCreatePage() {
const [form, setForm] = React.useState<TicketInput>(initialForm)
const [error, setError] = React.useState('')
function updateField<Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) {
setForm((current) => ({ ...current, [field]: value }))
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError('')
try {
const ticket = await createTicket(form)
window.location.hash = `/tickets/${ticket.id}`
} catch (reason) {
setError(reason instanceof Error ? reason.message : '创建失败')
}
}
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto max-w-4xl">
<AppPageHeader title="Create Ticket" description="Record a confirmed support request." />
<Card className="mt-4">
<CardContent>
<form className="grid gap-4" onSubmit={handleSubmit}>
<AppField label="Title">
<Input required value={form.title} onChange={(event) => updateField('title', event.target.value)} />
</AppField>
<AppField label="Description">
<Textarea
required
value={form.description}
onChange={(event) => updateField('description', event.target.value)}
/>
</AppField>
<div className="grid gap-4 md:grid-cols-2">
<AppField label="Priority">
<Select value={form.priority} onChange={(event) => updateField('priority', event.target.value as TicketInput['priority'])}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</Select>
</AppField>
<AppField label="Related System">
<Input value={form.related_system} onChange={(event) => updateField('related_system', event.target.value)} />
</AppField>
<AppField label="Impact Scope">
<Input value={form.impact_scope} onChange={(event) => updateField('impact_scope', event.target.value)} />
</AppField>
<AppField label="Environment">
<Input value={form.environment} onChange={(event) => updateField('environment', event.target.value)} />
</AppField>
</div>
{error ? <p className="text-sm text-danger">{error}</p> : null}
<div className="flex justify-end">
<Button type="submit">Create</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,93 @@
import * as React from 'react'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { getTicket, transitionTicket } from './api'
import { TicketComments } from './TicketComments'
import { TicketStatusTimeline } from './TicketStatusTimeline'
import type { Ticket, TicketStatus } from './types'
const NEXT_STATUSES: TicketStatus[] = ['in_progress', 'pending_confirm', 'resolved', 'closed']
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const [ticket, setTicket] = React.useState<Ticket | null>(null)
const [error, setError] = React.useState('')
React.useEffect(() => {
getTicket(ticketId)
.then(setTicket)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, [ticketId])
async function handleTransition(status: TicketStatus) {
if (!ticket) return
const updated = await transitionTicket(ticket.id, status, `Move to ${status}`)
setTicket(updated)
}
if (error) {
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<AppEmptyState title="Unable to load ticket" description={error} />
</main>
)
}
if (!ticket) {
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<AppEmptyState title="Loading ticket" />
</main>
)
}
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<AppPageHeader
title={ticket.title}
description={ticket.ticket_no}
actions={
<div className="flex flex-wrap gap-2">
{NEXT_STATUSES.map((status) => (
<Button key={status} variant="secondary" onClick={() => void handleTransition(status)}>
{status}
</Button>
))}
</div>
}
/>
<Card>
<CardContent className="grid gap-4 md:grid-cols-3">
<AppField label="Status">
<AppStatusBadge status={ticket.status} />
</AppField>
<AppField label="Priority">
<AppPriorityBadge priority={ticket.priority} />
</AppField>
<AppField label="System">
<span className="text-sm">{ticket.related_system || '-'}</span>
</AppField>
<AppField label="Environment">
<span className="text-sm">{ticket.environment || '-'}</span>
</AppField>
<AppField label="Impact">
<span className="text-sm">{ticket.impact_scope || '-'}</span>
</AppField>
<AppField label="Description">
<span className="text-sm">{ticket.description}</span>
</AppField>
</CardContent>
</Card>
<TicketStatusTimeline ticket={ticket} />
<TicketComments ticketId={ticket.id} />
</div>
</main>
)
}

View File

@@ -0,0 +1,68 @@
import * as React from 'react'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { formatDateTime } from '@/lib/format'
import { listTickets } from './api'
import type { Ticket } from './types'
export function TicketListPage() {
const [tickets, setTickets] = React.useState<Ticket[]>([])
const [status, setStatus] = React.useState('')
const [q, setQ] = React.useState('')
const [error, setError] = React.useState('')
React.useEffect(() => {
listTickets({ status, q })
.then(setTickets)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, [status, q])
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<AppPageHeader
title="Tickets"
description="Search, filter, and review support tickets."
actions={<Button onClick={() => (window.location.hash = '/tickets/new')}>Create Ticket</Button>}
/>
<div className="grid gap-2 md:grid-cols-[1fr_180px]">
<Input onChange={(event) => setQ(event.target.value)} placeholder="Search ticket no or title" value={q} />
<Select onChange={(event) => setStatus(event.target.value)} value={status}>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</Select>
</div>
{error ? <AppEmptyState title="Unable to load tickets" description={error} /> : null}
{!error && tickets.length === 0 ? <AppEmptyState title="No tickets" description="Create the first support ticket." /> : null}
<Card>
<CardContent className="p-0">
{tickets.map((ticket) => (
<a
className="grid gap-3 border-b border-border px-4 py-3 text-sm last:border-b-0 hover:bg-muted md:grid-cols-[150px_1fr_120px_100px_140px]"
href={`#/tickets/${ticket.id}`}
key={ticket.id}
>
<span className="font-medium text-foreground">{ticket.ticket_no}</span>
<span>{ticket.title}</span>
<AppStatusBadge status={ticket.status} />
<AppPriorityBadge priority={ticket.priority} />
<span className="text-muted-foreground">{formatDateTime(ticket.created_at)}</span>
</a>
))}
</CardContent>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,16 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import type { Ticket } from './types'
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
return (
<Card>
<CardHeader>
<h2 className="text-sm font-medium">Timeline</h2>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">Current status: {ticket.status}</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,36 @@
import { apiRequest } from '@/lib/api'
import type { Ticket, TicketComment, TicketInput, TicketStatus } from './types'
export function listTickets(params: { status?: string; q?: string } = {}) {
const query = new URLSearchParams()
if (params.status) query.set('status', params.status)
if (params.q) query.set('q', params.q)
const suffix = query.toString() ? `?${query}` : ''
return apiRequest<Ticket[]>(`/tickets${suffix}`)
}
export function createTicket(payload: TicketInput) {
return apiRequest<Ticket>('/tickets', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function getTicket(ticketId: number) {
return apiRequest<Ticket>(`/tickets/${ticketId}`)
}
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
method: 'POST',
body: JSON.stringify({ status, remark })
})
}
export function addTicketComment(ticketId: number, content: string) {
return apiRequest<TicketComment>(`/tickets/${ticketId}/comments`, {
method: 'POST',
body: JSON.stringify({ content, comment_type: 'internal_note', visibility: 'internal' })
})
}

View File

@@ -0,0 +1,41 @@
export type TicketStatus =
| 'pending_info'
| 'pending'
| 'in_progress'
| 'pending_confirm'
| 'resolved'
| 'closed'
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
export type Ticket = {
id: number
ticket_no: string
title: string
description: string
status: TicketStatus
priority: TicketPriority
impact_scope: string
environment: string
related_system: string
assignee_id: number | null
created_at: string
updated_at: string
}
export type TicketInput = {
title: string
description: string
priority: TicketPriority
impact_scope: string
environment: string
related_system: string
}
export type TicketComment = {
id: number
content: string
comment_type: string
visibility: string
created_at: string
}