feat: add solution library pages
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
||||||
|
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
|
||||||
import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
|
import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
|
||||||
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
|
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
|
||||||
import { TicketListPage } from '@/features/tickets/TicketListPage'
|
import { TicketListPage } from '@/features/tickets/TicketListPage'
|
||||||
@@ -22,6 +23,10 @@ export function Router() {
|
|||||||
return <DesignSystemPreview />
|
return <DesignSystemPreview />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route === '/solutions') {
|
||||||
|
return <SolutionListPage />
|
||||||
|
}
|
||||||
|
|
||||||
if (route === '/tickets/new') {
|
if (route === '/tickets/new') {
|
||||||
return <TicketCreatePage />
|
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 { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
||||||
|
|
||||||
import { getTicket, transitionTicket } from './api'
|
import { getTicket, transitionTicket } from './api'
|
||||||
import { TicketComments } from './TicketComments'
|
import { TicketComments } from './TicketComments'
|
||||||
@@ -87,6 +88,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
</Card>
|
</Card>
|
||||||
<TicketStatusTimeline ticket={ticket} />
|
<TicketStatusTimeline ticket={ticket} />
|
||||||
<TicketComments ticketId={ticket.id} />
|
<TicketComments ticketId={ticket.id} />
|
||||||
|
<SolutionLinkPanel ticketId={ticket.id} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user