feat: add solution library pages
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
|
||||
48
frontend/src/features/solutions/SolutionLinkPanel.tsx
Normal file
48
frontend/src/features/solutions/SolutionLinkPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/features/solutions/SolutionListPage.tsx
Normal file
47
frontend/src/features/solutions/SolutionListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/features/solutions/api.ts
Normal file
17
frontend/src/features/solutions/api.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
23
frontend/src/features/solutions/types.ts
Normal file
23
frontend/src/features/solutions/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user