From a6df96ebb05aa5c1ba133c3edd8c162cbc21ba5f Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 2 Jul 2026 10:11:30 +0800 Subject: [PATCH] feat: add ticket solution closure loop --- backend/apps/solutions/api.py | 20 ++ backend/apps/solutions/schemas.py | 9 + .../apps/solutions/tests/test_solution_api.py | 27 +++ backend/apps/tickets/services.py | 2 +- .../apps/tickets/tests/test_ticket_status.py | 10 + frontend/src/app/router.tsx | 5 + .../features/solutions/SolutionCreatePage.tsx | 199 ++++++++++++++++++ .../features/solutions/SolutionLinkPanel.tsx | 93 ++++++-- .../features/solutions/SolutionListPage.tsx | 11 +- frontend/src/features/solutions/api.ts | 13 +- frontend/src/features/solutions/types.ts | 20 ++ .../src/features/tickets/TicketComments.tsx | 48 ++--- .../src/features/tickets/TicketCreatePage.tsx | 18 +- .../src/features/tickets/TicketDetailPage.tsx | 18 +- .../src/features/tickets/TicketListPage.tsx | 1 - .../src/features/tickets/TicketMetaPanel.tsx | 3 +- .../features/tickets/TicketStatusTimeline.tsx | 2 +- .../tickets/TicketTransitionDialog.tsx | 4 +- 18 files changed, 435 insertions(+), 68 deletions(-) create mode 100644 frontend/src/features/solutions/SolutionCreatePage.tsx diff --git a/backend/apps/solutions/api.py b/backend/apps/solutions/api.py index f96937c..dad248d 100644 --- a/backend/apps/solutions/api.py +++ b/backend/apps/solutions/api.py @@ -7,6 +7,7 @@ from apps.solutions.schemas import ( SolutionOut, TicketSolutionLinkIn, TicketSolutionLinkOut, + TicketSolutionOut, ) from apps.tickets.models import Ticket @@ -38,6 +39,25 @@ def list_solutions(request, status: str | None = None): return queryset[:100] +@router.get("/tickets/{ticket_id}", response=list[TicketSolutionOut]) +def list_ticket_solutions(request, ticket_id: int): + require_permission(request, "solution.view") + queryset = TicketSolutionLink.objects.select_related("solution", "ticket").filter( + ticket_id=ticket_id + ) + return [ + { + "id": link.id, + "ticket_id": link.ticket_id, + "solution_id": link.solution_id, + "link_type": link.link_type, + "solution": link.solution, + "created_at": link.created_at, + } + for link in queryset + ] + + @router.get("/{solution_id}", response=SolutionOut) def get_solution(request, solution_id: int): require_permission(request, "solution.view") diff --git a/backend/apps/solutions/schemas.py b/backend/apps/solutions/schemas.py index 3e2f432..1840d18 100644 --- a/backend/apps/solutions/schemas.py +++ b/backend/apps/solutions/schemas.py @@ -42,3 +42,12 @@ class TicketSolutionLinkOut(Schema): link_type: str solution_usage_count: int created_at: datetime + + +class TicketSolutionOut(Schema): + id: int + ticket_id: int + solution_id: int + link_type: str + solution: SolutionOut + created_at: datetime diff --git a/backend/apps/solutions/tests/test_solution_api.py b/backend/apps/solutions/tests/test_solution_api.py index 7f4950d..8ef55f7 100644 --- a/backend/apps/solutions/tests/test_solution_api.py +++ b/backend/apps/solutions/tests/test_solution_api.py @@ -39,3 +39,30 @@ def test_link_solution_to_ticket_increments_usage(admin_client, db): assert response.status_code == 200 assert response.json()["link_type"] == "adopted" assert response.json()["solution_usage_count"] == 1 + + +def test_list_ticket_solutions_returns_linked_solution_details(admin_client, db): + ticket = Ticket.objects.create(ticket_no="TK202607020001", title="A", description="A") + solution = admin_client.post( + "/api/solutions", + data={ + "title": "Restart callback worker", + "problem_signature": "Callback timeout", + "root_cause": "Worker queue blocked", + "solution": "Restart worker and verify queue depth.", + "status": "published", + }, + content_type="application/json", + ).json() + admin_client.post( + f"/api/solutions/{solution['id']}/link-ticket", + data={"ticket_id": ticket.id, "link_type": "adopted"}, + content_type="application/json", + ) + + response = admin_client.get(f"/api/solutions/tickets/{ticket.id}") + + assert response.status_code == 200 + payload = response.json() + assert payload[0]["link_type"] == "adopted" + assert payload[0]["solution"]["title"] == "Restart callback worker" diff --git a/backend/apps/tickets/services.py b/backend/apps/tickets/services.py index 5e5c434..1d987cb 100644 --- a/backend/apps/tickets/services.py +++ b/backend/apps/tickets/services.py @@ -11,7 +11,7 @@ class TicketStatusError(ValueError): ALLOWED_TRANSITIONS: dict[str, set[str]] = { "pending_info": {"pending", "closed"}, "pending": {"in_progress", "closed"}, - "in_progress": {"pending_confirm", "resolved", "closed"}, + "in_progress": {"pending_confirm"}, "pending_confirm": {"resolved", "in_progress", "closed"}, "resolved": {"closed"}, "closed": set(), diff --git a/backend/apps/tickets/tests/test_ticket_status.py b/backend/apps/tickets/tests/test_ticket_status.py index dc94bf4..f2cc4d1 100644 --- a/backend/apps/tickets/tests/test_ticket_status.py +++ b/backend/apps/tickets/tests/test_ticket_status.py @@ -7,6 +7,16 @@ def test_pending_ticket_can_move_to_in_progress(): validate_status_transition("pending", "in_progress") +def test_in_progress_ticket_must_not_close_directly(): + with pytest.raises(TicketStatusError, match="in_progress -> closed"): + validate_status_transition("in_progress", "closed") + + +def test_in_progress_ticket_must_not_resolve_directly(): + with pytest.raises(TicketStatusError, match="in_progress -> resolved"): + validate_status_transition("in_progress", "resolved") + + def test_resolved_ticket_cannot_move_back_to_in_progress(): with pytest.raises(TicketStatusError, match="resolved -> in_progress"): validate_status_transition("resolved", "in_progress") diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 696b0c7..06479db 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview' +import { SolutionCreatePage } from '@/features/solutions/SolutionCreatePage' import { SolutionListPage } from '@/features/solutions/SolutionListPage' import { AuditLogPage } from '@/features/system/AuditLogPage' import { SystemMenusPage } from '@/features/system/SystemMenusPage' @@ -57,6 +58,10 @@ export function Router() { return } + if (route.startsWith('/solutions/new')) { + return + } + if (route === '/tickets') { return } diff --git a/frontend/src/features/solutions/SolutionCreatePage.tsx b/frontend/src/features/solutions/SolutionCreatePage.tsx new file mode 100644 index 0000000..09cb77a --- /dev/null +++ b/frontend/src/features/solutions/SolutionCreatePage.tsx @@ -0,0 +1,199 @@ +import * as React from 'react' +import { ArrowLeft, Save } from 'lucide-react' + +import { AppField } from '@/components/app/AppField' +import { AppPageHeader } from '@/components/app/AppPageHeader' +import { AppSection } from '@/components/app/AppSection' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { getTicket } from '@/features/tickets/api' + +import { createSolution, linkSolutionToTicket } from './api' +import type { SolutionInput } from './types' + +const initialForm: SolutionInput = { + title: '', + problem_signature: '', + related_system: '', + environment_scope: '', + root_cause: '', + solution: '', + risk_note: '', + status: 'draft' +} + +function getSourceTicketId() { + const route = window.location.hash.replace(/^#/, '') + const query = route.includes('?') ? route.slice(route.indexOf('?') + 1) : '' + const ticketId = new URLSearchParams(query).get('ticketId') + return ticketId ? Number(ticketId) : null +} + +export function SolutionCreatePage() { + const [form, setForm] = React.useState(initialForm) + const [sourceTicketId] = React.useState(getSourceTicketId) + const [error, setError] = React.useState('') + const [submitting, setSubmitting] = React.useState(false) + + React.useEffect(() => { + if (!sourceTicketId) return + getTicket(sourceTicketId) + .then((ticket) => { + setForm((current) => ({ + ...current, + title: ticket.title, + problem_signature: ticket.description, + related_system: ticket.related_system, + environment_scope: ticket.environment + })) + }) + .catch(() => undefined) + }, [sourceTicketId]) + + function updateField(field: Key, value: SolutionInput[Key]) { + setForm((current) => ({ ...current, [field]: value })) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setError('') + setSubmitting(true) + try { + const solution = await createSolution(form) + if (sourceTicketId) { + await linkSolutionToTicket(solution.id, sourceTicketId, 'adopted') + window.location.hash = `/tickets/${sourceTicketId}` + return + } + window.location.hash = '/solutions' + } catch (reason) { + setError(reason instanceof Error ? reason.message : '保存解决方案失败,请检查必填信息。') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ (window.location.hash = sourceTicketId ? `/tickets/${sourceTicketId}` : '/solutions')}> + + 返回 + + } + /> + + {error ? ( + + 保存失败 + {error} + + ) : null} + +
+
+ +
+ + updateField('title', event.target.value)} + /> + + + + + + updateField('related_system', event.target.value)} + /> + + + updateField('environment_scope', event.target.value)} + /> + +
+
+ + + +