feat: add solution library pages

This commit is contained in:
hailin
2026-06-30 18:50:45 +08:00
parent d40fdad6e9
commit 97c35cc131
6 changed files with 142 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
import { TicketListPage } from '@/features/tickets/TicketListPage'
@@ -22,6 +23,10 @@ export function Router() {
return <DesignSystemPreview />
}
if (route === '/solutions') {
return <SolutionListPage />
}
if (route === '/tickets/new') {
return <TicketCreatePage />
}

View File

@@ -0,0 +1,48 @@
import * as React from 'react'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { linkSolutionToTicket, listSolutions } from './api'
import type { Solution } from './types'
export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
const [solutions, setSolutions] = React.useState<Solution[]>([])
const [message, setMessage] = React.useState('')
React.useEffect(() => {
listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([]))
}, [])
async function handleLink(solution: Solution, linkType: string) {
await linkSolutionToTicket(solution.id, ticketId, linkType)
setMessage(`${solution.title} linked as ${linkType}`)
}
return (
<Card>
<CardHeader>
<h2 className="text-sm font-medium">Reusable Solutions</h2>
</CardHeader>
<CardContent className="grid gap-3">
{message ? <p className="text-sm text-muted-foreground">{message}</p> : null}
{solutions.length === 0 ? <AppEmptyState title="No published solutions" /> : null}
{solutions.map((solution) => (
<div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}>
<div>
<p className="text-sm font-medium">{solution.title}</p>
<p className="text-sm text-muted-foreground">{solution.problem_signature}</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => void handleLink(solution, 'referenced')}>
Reference
</Button>
<Button onClick={() => void handleLink(solution, 'adopted')}>Adopt</Button>
</div>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import * as React from 'react'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { listSolutions } from './api'
import type { Solution } from './types'
export function SolutionListPage() {
const [solutions, setSolutions] = React.useState<Solution[]>([])
const [error, setError] = React.useState('')
React.useEffect(() => {
listSolutions()
.then(setSolutions)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, [])
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="Solutions" description="Reusable support solutions linked to ticket handling." />
{error ? <AppEmptyState title="Unable to load solutions" description={error} /> : null}
{!error && solutions.length === 0 ? <AppEmptyState title="No solutions" description="Published reusable fixes will appear here." /> : null}
<div className="grid gap-3">
{solutions.map((solution) => (
<Card key={solution.id}>
<CardContent className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">{solution.title}</h2>
<div className="flex items-center gap-2">
<Badge variant={solution.status === 'published' ? 'success' : 'default'}>{solution.status}</Badge>
<Badge variant="info">Used {solution.usage_count}</Badge>
</div>
</div>
<p className="text-sm text-muted-foreground">{solution.problem_signature}</p>
<p className="text-sm">{solution.solution}</p>
</CardContent>
</Card>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,17 @@
import { apiRequest } from '@/lib/api'
import type { Solution, TicketSolutionLink } from './types'
export function listSolutions(params: { status?: string } = {}) {
const query = new URLSearchParams()
if (params.status) query.set('status', params.status)
const suffix = query.toString() ? `?${query}` : ''
return apiRequest<Solution[]>(`/solutions${suffix}`)
}
export function linkSolutionToTicket(solutionId: number, ticketId: number, linkType: string) {
return apiRequest<TicketSolutionLink>(`/solutions/${solutionId}/link-ticket`, {
method: 'POST',
body: JSON.stringify({ ticket_id: ticketId, link_type: linkType })
})
}

View File

@@ -0,0 +1,23 @@
export type Solution = {
id: number
title: string
problem_signature: string
related_system: string
environment_scope: string
root_cause: string
solution: string
risk_note: string
status: string
usage_count: number
created_at: string
updated_at: string
}
export type TicketSolutionLink = {
id: number
ticket_id: number
solution_id: number
link_type: string
solution_usage_count: number
created_at: string
}

View File

@@ -7,6 +7,7 @@ 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 { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
import { getTicket, transitionTicket } from './api'
import { TicketComments } from './TicketComments'
@@ -87,6 +88,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
</Card>
<TicketStatusTimeline ticket={ticket} />
<TicketComments ticketId={ticket.id} />
<SolutionLinkPanel ticketId={ticket.id} />
</div>
</main>
)