diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index eb33387..ccfc86a 100644
--- a/frontend/src/app/router.tsx
+++ b/frontend/src/app/router.tsx
@@ -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
}
- return (
-
-
-
- )
+ if (route === '/tickets/new') {
+ return
+ }
+
+ const ticketMatch = route.match(/^\/tickets\/(\d+)$/)
+ if (ticketMatch) {
+ return
+ }
+
+ return
}
diff --git a/frontend/src/components/app/AppEmptyState.tsx b/frontend/src/components/app/AppEmptyState.tsx
new file mode 100644
index 0000000..c7933ce
--- /dev/null
+++ b/frontend/src/components/app/AppEmptyState.tsx
@@ -0,0 +1,13 @@
+type AppEmptyStateProps = {
+ title: string
+ description?: string
+}
+
+export function AppEmptyState({ title, description }: AppEmptyStateProps) {
+ return (
+
+
{title}
+ {description ?
{description}
: null}
+
+ )
+}
diff --git a/frontend/src/components/app/AppField.tsx b/frontend/src/components/app/AppField.tsx
new file mode 100644
index 0000000..2a160cc
--- /dev/null
+++ b/frontend/src/components/app/AppField.tsx
@@ -0,0 +1,15 @@
+import type * as React from 'react'
+
+type AppFieldProps = {
+ label: string
+ children: React.ReactNode
+}
+
+export function AppField({ label, children }: AppFieldProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..e326526
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/cn'
+
+export function Input({ className, ...props }: React.InputHTMLAttributes) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..02230ab
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/cn'
+
+export function Select({ className, ...props }: React.SelectHTMLAttributes) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..ad2a3ec
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/cn'
+
+export function Textarea({ className, ...props }: React.TextareaHTMLAttributes) {
+ return (
+
+ )
+}
diff --git a/frontend/src/features/tickets/TicketComments.tsx b/frontend/src/features/tickets/TicketComments.tsx
new file mode 100644
index 0000000..f358513
--- /dev/null
+++ b/frontend/src/features/tickets/TicketComments.tsx
@@ -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 (
+
+
+ Comments
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/tickets/TicketCreatePage.tsx b/frontend/src/features/tickets/TicketCreatePage.tsx
new file mode 100644
index 0000000..1078ed4
--- /dev/null
+++ b/frontend/src/features/tickets/TicketCreatePage.tsx
@@ -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(initialForm)
+ const [error, setError] = React.useState('')
+
+ function updateField(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 (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/tickets/TicketDetailPage.tsx b/frontend/src/features/tickets/TicketDetailPage.tsx
new file mode 100644
index 0000000..7c522d7
--- /dev/null
+++ b/frontend/src/features/tickets/TicketDetailPage.tsx
@@ -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(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 (
+
+
+
+ )
+ }
+
+ if (!ticket) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {NEXT_STATUSES.map((status) => (
+
+ ))}
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {ticket.related_system || '-'}
+
+
+ {ticket.environment || '-'}
+
+
+ {ticket.impact_scope || '-'}
+
+
+ {ticket.description}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/tickets/TicketListPage.tsx b/frontend/src/features/tickets/TicketListPage.tsx
new file mode 100644
index 0000000..55b1601
--- /dev/null
+++ b/frontend/src/features/tickets/TicketListPage.tsx
@@ -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([])
+ 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 (
+
+
+
+ )
+}
diff --git a/frontend/src/features/tickets/TicketStatusTimeline.tsx b/frontend/src/features/tickets/TicketStatusTimeline.tsx
new file mode 100644
index 0000000..57377d0
--- /dev/null
+++ b/frontend/src/features/tickets/TicketStatusTimeline.tsx
@@ -0,0 +1,16 @@
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+
+import type { Ticket } from './types'
+
+export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
+ return (
+
+
+ Timeline
+
+
+ Current status: {ticket.status}
+
+
+ )
+}
diff --git a/frontend/src/features/tickets/api.ts b/frontend/src/features/tickets/api.ts
new file mode 100644
index 0000000..faf1388
--- /dev/null
+++ b/frontend/src/features/tickets/api.ts
@@ -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(`/tickets${suffix}`)
+}
+
+export function createTicket(payload: TicketInput) {
+ return apiRequest('/tickets', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ })
+}
+
+export function getTicket(ticketId: number) {
+ return apiRequest(`/tickets/${ticketId}`)
+}
+
+export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
+ return apiRequest(`/tickets/${ticketId}/status`, {
+ method: 'POST',
+ body: JSON.stringify({ status, remark })
+ })
+}
+
+export function addTicketComment(ticketId: number, content: string) {
+ return apiRequest(`/tickets/${ticketId}/comments`, {
+ method: 'POST',
+ body: JSON.stringify({ content, comment_type: 'internal_note', visibility: 'internal' })
+ })
+}
diff --git a/frontend/src/features/tickets/types.ts b/frontend/src/features/tickets/types.ts
new file mode 100644
index 0000000..dfb6281
--- /dev/null
+++ b/frontend/src/features/tickets/types.ts
@@ -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
+}