feat: add ticket solution closure loop
This commit is contained in:
@@ -7,6 +7,7 @@ from apps.solutions.schemas import (
|
|||||||
SolutionOut,
|
SolutionOut,
|
||||||
TicketSolutionLinkIn,
|
TicketSolutionLinkIn,
|
||||||
TicketSolutionLinkOut,
|
TicketSolutionLinkOut,
|
||||||
|
TicketSolutionOut,
|
||||||
)
|
)
|
||||||
from apps.tickets.models import Ticket
|
from apps.tickets.models import Ticket
|
||||||
|
|
||||||
@@ -38,6 +39,25 @@ def list_solutions(request, status: str | None = None):
|
|||||||
return queryset[:100]
|
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)
|
@router.get("/{solution_id}", response=SolutionOut)
|
||||||
def get_solution(request, solution_id: int):
|
def get_solution(request, solution_id: int):
|
||||||
require_permission(request, "solution.view")
|
require_permission(request, "solution.view")
|
||||||
|
|||||||
@@ -42,3 +42,12 @@ class TicketSolutionLinkOut(Schema):
|
|||||||
link_type: str
|
link_type: str
|
||||||
solution_usage_count: int
|
solution_usage_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSolutionOut(Schema):
|
||||||
|
id: int
|
||||||
|
ticket_id: int
|
||||||
|
solution_id: int
|
||||||
|
link_type: str
|
||||||
|
solution: SolutionOut
|
||||||
|
created_at: datetime
|
||||||
|
|||||||
@@ -39,3 +39,30 @@ def test_link_solution_to_ticket_increments_usage(admin_client, db):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["link_type"] == "adopted"
|
assert response.json()["link_type"] == "adopted"
|
||||||
assert response.json()["solution_usage_count"] == 1
|
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"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class TicketStatusError(ValueError):
|
|||||||
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
||||||
"pending_info": {"pending", "closed"},
|
"pending_info": {"pending", "closed"},
|
||||||
"pending": {"in_progress", "closed"},
|
"pending": {"in_progress", "closed"},
|
||||||
"in_progress": {"pending_confirm", "resolved", "closed"},
|
"in_progress": {"pending_confirm"},
|
||||||
"pending_confirm": {"resolved", "in_progress", "closed"},
|
"pending_confirm": {"resolved", "in_progress", "closed"},
|
||||||
"resolved": {"closed"},
|
"resolved": {"closed"},
|
||||||
"closed": set(),
|
"closed": set(),
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ def test_pending_ticket_can_move_to_in_progress():
|
|||||||
validate_status_transition("pending", "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():
|
def test_resolved_ticket_cannot_move_back_to_in_progress():
|
||||||
with pytest.raises(TicketStatusError, match="resolved -> in_progress"):
|
with pytest.raises(TicketStatusError, match="resolved -> in_progress"):
|
||||||
validate_status_transition("resolved", "in_progress")
|
validate_status_transition("resolved", "in_progress")
|
||||||
|
|||||||
@@ -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 { SolutionCreatePage } from '@/features/solutions/SolutionCreatePage'
|
||||||
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
|
import { SolutionListPage } from '@/features/solutions/SolutionListPage'
|
||||||
import { AuditLogPage } from '@/features/system/AuditLogPage'
|
import { AuditLogPage } from '@/features/system/AuditLogPage'
|
||||||
import { SystemMenusPage } from '@/features/system/SystemMenusPage'
|
import { SystemMenusPage } from '@/features/system/SystemMenusPage'
|
||||||
@@ -57,6 +58,10 @@ export function Router() {
|
|||||||
return <SolutionListPage />
|
return <SolutionListPage />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.startsWith('/solutions/new')) {
|
||||||
|
return <SolutionCreatePage />
|
||||||
|
}
|
||||||
|
|
||||||
if (route === '/tickets') {
|
if (route === '/tickets') {
|
||||||
return <TicketListPage />
|
return <TicketListPage />
|
||||||
}
|
}
|
||||||
|
|||||||
199
frontend/src/features/solutions/SolutionCreatePage.tsx
Normal file
199
frontend/src/features/solutions/SolutionCreatePage.tsx
Normal file
@@ -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<SolutionInput>(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<Key extends keyof SolutionInput>(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 (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-5">
|
||||||
|
<AppPageHeader
|
||||||
|
title="新建解决方案"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => (window.location.hash = sourceTicketId ? `/tickets/${sourceTicketId}` : '/solutions')}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>保存失败</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]" onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<AppSection title="基础信息">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<AppField label="方案标题">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
placeholder="例如:认证回调阻塞处理方案"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => updateField('title', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="状态">
|
||||||
|
<Select value={form.status} onValueChange={(value) => updateField('status', value)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">草稿</SelectItem>
|
||||||
|
<SelectItem value="published">发布</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="关联系统">
|
||||||
|
<Input
|
||||||
|
placeholder="例如:CRM、认证中心、支付系统"
|
||||||
|
value={form.related_system}
|
||||||
|
onChange={(event) => updateField('related_system', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="环境范围">
|
||||||
|
<Input
|
||||||
|
placeholder="例如:生产环境 / Chrome"
|
||||||
|
value={form.environment_scope}
|
||||||
|
onChange={(event) => updateField('environment_scope', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="问题特征">
|
||||||
|
<AppField label="问题特征">
|
||||||
|
<Textarea
|
||||||
|
required
|
||||||
|
placeholder="描述可复用的问题表现、报错特征、触发条件。"
|
||||||
|
value={form.problem_signature}
|
||||||
|
onChange={(event) => updateField('problem_signature', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="根因与方案">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<AppField label="根因分析">
|
||||||
|
<Textarea
|
||||||
|
required
|
||||||
|
placeholder="说明问题的根因或判断依据。"
|
||||||
|
value={form.root_cause}
|
||||||
|
onChange={(event) => updateField('root_cause', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="处理方案">
|
||||||
|
<Textarea
|
||||||
|
required
|
||||||
|
placeholder="写清楚处理步骤、验证方式和回滚注意事项。"
|
||||||
|
value={form.solution}
|
||||||
|
onChange={(event) => updateField('solution', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="风险提示">
|
||||||
|
<AppField label="风险提示">
|
||||||
|
<Textarea
|
||||||
|
placeholder="可选:说明操作风险、影响范围或上线前确认事项。"
|
||||||
|
value={form.risk_note}
|
||||||
|
onChange={(event) => updateField('risk_note', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</AppSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid content-start gap-4">
|
||||||
|
<AppSection title="提交">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{sourceTicketId ? (
|
||||||
|
<p className="text-sm text-muted-foreground">保存后会自动关联到当前工单,并标记为已采纳方案。</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{submitting ? '保存中...' : '保存方案'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,48 +1,97 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
|
||||||
import { AppSection } from '@/components/app/AppSection'
|
import { AppSection } from '@/components/app/AppSection'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
import { linkSolutionToTicket, listSolutions } from './api'
|
import { linkSolutionToTicket, listSolutions, listTicketSolutions } from './api'
|
||||||
import type { Solution } from './types'
|
import type { Solution, TicketSolution } from './types'
|
||||||
|
|
||||||
|
const LINK_TYPE_LABELS: Record<string, string> = {
|
||||||
|
referenced: '推荐',
|
||||||
|
adopted: '已采纳'
|
||||||
|
}
|
||||||
|
|
||||||
export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
export function SolutionLinkPanel({ ticketId }: { ticketId: number }) {
|
||||||
const [solutions, setSolutions] = React.useState<Solution[]>([])
|
const [solutions, setSolutions] = React.useState<Solution[]>([])
|
||||||
|
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
|
||||||
const [message, setMessage] = React.useState('')
|
const [message, setMessage] = React.useState('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
function refresh() {
|
||||||
listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([]))
|
listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([]))
|
||||||
}, [])
|
listTicketSolutions(ticketId).then(setLinkedSolutions).catch(() => setLinkedSolutions([]))
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [ticketId])
|
||||||
|
|
||||||
async function handleLink(solution: Solution, linkType: string) {
|
async function handleLink(solution: Solution, linkType: string) {
|
||||||
await linkSolutionToTicket(solution.id, ticketId, linkType)
|
await linkSolutionToTicket(solution.id, ticketId, linkType)
|
||||||
setMessage(`已将「${solution.title}」标记为${linkType === 'adopted' ? '已采纳' : '推荐方案'}`)
|
setMessage(`已将「${solution.title}」标记为${linkType === 'adopted' ? '已采纳' : '推荐方案'}`)
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkedSolutionIds = new Set(linkedSolutions.map((link) => link.solution_id))
|
||||||
|
const availableSolutions = solutions.filter((solution) => !linkedSolutionIds.has(solution.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSection title="关联解决方案" description="从已发布方案中选择推荐或采纳项。">
|
<AppSection
|
||||||
<div className="grid gap-3">
|
title="关联解决方案"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => (window.location.hash = `/solutions/new?ticketId=${ticketId}`)}
|
||||||
|
>
|
||||||
|
沉淀为方案
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
{message ? <p className="text-sm text-muted-foreground">{message}</p> : null}
|
{message ? <p className="text-sm text-muted-foreground">{message}</p> : null}
|
||||||
{solutions.length === 0 ? <AppEmptyState title="暂无关联方案" description="发布解决方案后,可在这里推荐或采纳。" /> : null}
|
|
||||||
{solutions.map((solution) => (
|
<div className="grid gap-2">
|
||||||
<div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}>
|
{linkedSolutions.length === 0 ? <p className="text-sm text-muted-foreground">暂无已关联方案</p> : null}
|
||||||
<div>
|
{linkedSolutions.map((link) => (
|
||||||
|
<div className="grid gap-2 rounded-md border border-border bg-muted/30 p-3" key={link.id}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm font-medium">{solution.title}</p>
|
<p className="text-sm font-medium">{link.solution.title}</p>
|
||||||
<Badge variant="info">引用 {solution.usage_count}</Badge>
|
<Badge variant={link.link_type === 'adopted' ? 'success' : 'info'}>
|
||||||
|
{LINK_TYPE_LABELS[link.link_type] ?? link.link_type}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{solution.problem_signature}</p>
|
<p className="line-clamp-2 text-sm text-muted-foreground">{link.solution.solution}</p>
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="secondary" onClick={() => void handleLink(solution, 'referenced')}>
|
|
||||||
推荐方案
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void handleLink(solution, 'adopted')}>已采纳</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableSolutions.length > 0 ? (
|
||||||
|
<div className="grid gap-2 border-t border-border pt-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">可关联方案</p>
|
||||||
|
{availableSolutions.map((solution) => (
|
||||||
|
<div className="grid gap-2 rounded-md border border-border p-3" key={solution.id}>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium">{solution.title}</p>
|
||||||
|
<Badge variant="info">引用 {solution.usage_count}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">{solution.problem_signature}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => void handleLink(solution, 'referenced')}>
|
||||||
|
推荐方案
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void handleLink(solution, 'adopted')}>已采纳</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">暂无可关联的已发布方案。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||||
@@ -74,7 +75,15 @@ export function SolutionListPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
<div className="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
<AppPageHeader title="解决方案库" description="沉淀可复用的问题特征、排查步骤和处理方案。" />
|
<AppPageHeader
|
||||||
|
title="解决方案库"
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => (window.location.hash = '/solutions/new')}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建方案
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiRequest } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
import type { Solution, TicketSolutionLink } from './types'
|
import type { Solution, SolutionInput, TicketSolution, TicketSolutionLink } from './types'
|
||||||
|
|
||||||
export function listSolutions(params: { status?: string } = {}) {
|
export function listSolutions(params: { status?: string } = {}) {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@@ -9,6 +9,17 @@ export function listSolutions(params: { status?: string } = {}) {
|
|||||||
return apiRequest<Solution[]>(`/solutions${suffix}`)
|
return apiRequest<Solution[]>(`/solutions${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSolution(payload: SolutionInput) {
|
||||||
|
return apiRequest<Solution>('/solutions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTicketSolutions(ticketId: number) {
|
||||||
|
return apiRequest<TicketSolution[]>(`/solutions/tickets/${ticketId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function linkSolutionToTicket(solutionId: number, ticketId: number, linkType: string) {
|
export function linkSolutionToTicket(solutionId: number, ticketId: number, linkType: string) {
|
||||||
return apiRequest<TicketSolutionLink>(`/solutions/${solutionId}/link-ticket`, {
|
return apiRequest<TicketSolutionLink>(`/solutions/${solutionId}/link-ticket`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ export type Solution = {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SolutionInput = {
|
||||||
|
title: string
|
||||||
|
problem_signature: string
|
||||||
|
related_system: string
|
||||||
|
environment_scope: string
|
||||||
|
root_cause: string
|
||||||
|
solution: string
|
||||||
|
risk_note: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
export type TicketSolutionLink = {
|
export type TicketSolutionLink = {
|
||||||
id: number
|
id: number
|
||||||
ticket_id: number
|
ticket_id: number
|
||||||
@@ -21,3 +32,12 @@ export type TicketSolutionLink = {
|
|||||||
solution_usage_count: number
|
solution_usage_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TicketSolution = {
|
||||||
|
id: number
|
||||||
|
ticket_id: number
|
||||||
|
solution_id: number
|
||||||
|
link_type: string
|
||||||
|
solution: Solution
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,30 +24,30 @@ export function TicketComments({ ticketId }: { ticketId: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppSection title="评论与协作" description="记录内部沟通、排查过程和用户可见回复。">
|
<AppSection title="评论与协作">
|
||||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||||
<Select value={commentMode} onValueChange={setCommentMode}>
|
<Select value={commentMode} onValueChange={setCommentMode}>
|
||||||
<SelectTrigger className="w-full sm:w-48">
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
<SelectValue placeholder="选择回复类型" />
|
<SelectValue placeholder="选择回复类型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="internal">内部备注</SelectItem>
|
<SelectItem value="internal">内部备注</SelectItem>
|
||||||
<SelectItem value="public">用户可见回复</SelectItem>
|
<SelectItem value="public">用户可见回复</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'}
|
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
|
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
|
||||||
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" variant="secondary">
|
<Button type="submit" variant="secondary">
|
||||||
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,7 @@ export function TicketCreatePage() {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
<div className="mx-auto grid max-w-6xl gap-5">
|
<div className="mx-auto grid max-w-6xl gap-5">
|
||||||
<AppPageHeader
|
<AppPageHeader title="创建工单" />
|
||||||
title="创建工单"
|
|
||||||
description="记录明确的技术支持请求,便于后续分派、排查和复用方案。"
|
|
||||||
/>
|
|
||||||
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
|
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -63,7 +60,7 @@ export function TicketCreatePage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AppSection title="基础信息" description="先说明这张工单要解决什么问题。">
|
<AppSection title="基础信息">
|
||||||
<AppField label="工单标题">
|
<AppField label="工单标题">
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
@@ -74,7 +71,7 @@ export function TicketCreatePage() {
|
|||||||
</AppField>
|
</AppField>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection title="影响与优先级" description="帮助支持团队判断响应顺序和处理风险。">
|
<AppSection title="影响与优先级">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<AppField label="优先级">
|
<AppField label="优先级">
|
||||||
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
||||||
@@ -99,7 +96,7 @@ export function TicketCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection title="环境与系统" description="补充问题发生的系统域和运行环境。">
|
<AppSection title="环境与系统">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<AppField label="关联系统">
|
<AppField label="关联系统">
|
||||||
<Input
|
<Input
|
||||||
@@ -118,7 +115,7 @@ export function TicketCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection title="问题描述" description="请描述现象、出现时间、复现步骤和已尝试的处理方式。">
|
<AppSection title="问题描述">
|
||||||
<AppField label="详细描述">
|
<AppField label="详细描述">
|
||||||
<Textarea
|
<Textarea
|
||||||
required
|
required
|
||||||
@@ -129,7 +126,7 @@ export function TicketCreatePage() {
|
|||||||
</AppField>
|
</AppField>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection title="提交确认" description="提交后工单会进入待处理队列。">
|
<AppSection title="提交确认">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="mt-0.5 size-4" />
|
<CheckCircle2 className="mt-0.5 size-4" />
|
||||||
@@ -144,7 +141,7 @@ export function TicketCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid content-start gap-4">
|
<div className="grid content-start gap-4">
|
||||||
<AppSection title="建单提示" description="高质量工单能减少来回确认。">
|
<AppSection title="建单提示">
|
||||||
<div className="grid gap-3 text-sm text-muted-foreground">
|
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Lightbulb className="mt-0.5 size-4" />
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
@@ -163,7 +160,6 @@ export function TicketCreatePage() {
|
|||||||
|
|
||||||
<AppSection
|
<AppSection
|
||||||
title="AI 建单辅助"
|
title="AI 建单辅助"
|
||||||
description="后续阶段能力预留"
|
|
||||||
action={<Badge variant="ai"><Bot className="size-3" /> 即将接入</Badge>}
|
action={<Badge variant="ai"><Bot className="size-3" /> 即将接入</Badge>}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type { Ticket, TicketStatus } from './types'
|
|||||||
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
||||||
pending_info: ['pending', 'closed'],
|
pending_info: ['pending', 'closed'],
|
||||||
pending: ['in_progress', 'closed'],
|
pending: ['in_progress', 'closed'],
|
||||||
in_progress: ['pending_confirm', 'resolved', 'closed'],
|
in_progress: ['pending_confirm'],
|
||||||
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||||
resolved: ['closed'],
|
resolved: ['closed'],
|
||||||
closed: []
|
closed: []
|
||||||
@@ -36,10 +36,18 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
|
|||||||
closed: { label: '关闭工单', icon: XCircle }
|
closed: { label: '关闭工单', icon: XCircle }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
||||||
|
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
||||||
|
return '取消工单'
|
||||||
|
}
|
||||||
|
return STATUS_ACTIONS[targetStatus]?.label ?? '调整状态'
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
||||||
|
const [targetActionLabel, setTargetActionLabel] = React.useState('')
|
||||||
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
||||||
const [transitionError, setTransitionError] = React.useState('')
|
const [transitionError, setTransitionError] = React.useState('')
|
||||||
|
|
||||||
@@ -51,6 +59,9 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
|
|
||||||
function openTransition(status: TicketStatus) {
|
function openTransition(status: TicketStatus) {
|
||||||
setTargetStatus(status)
|
setTargetStatus(status)
|
||||||
|
if (ticket) {
|
||||||
|
setTargetActionLabel(getStatusActionLabel(ticket.status, status))
|
||||||
|
}
|
||||||
setTransitionError('')
|
setTransitionError('')
|
||||||
setTransitionDialogOpen(true)
|
setTransitionDialogOpen(true)
|
||||||
}
|
}
|
||||||
@@ -107,7 +118,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
onClick={() => openTransition(status)}
|
onClick={() => openTransition(status)}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon className="size-4" /> : null}
|
{Icon ? <Icon className="size-4" /> : null}
|
||||||
{action?.label ?? '调整状态'}
|
{getStatusActionLabel(ticket.status, status)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -132,7 +143,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSection title="问题描述" description="当前工单的核心问题、影响场景和处理上下文。">
|
<AppSection title="问题描述">
|
||||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
<TicketStatusTimeline ticket={ticket} />
|
<TicketStatusTimeline ticket={ticket} />
|
||||||
@@ -142,6 +153,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
<TicketTransitionDialog
|
<TicketTransitionDialog
|
||||||
open={transitionDialogOpen}
|
open={transitionDialogOpen}
|
||||||
status={targetStatus}
|
status={targetStatus}
|
||||||
|
actionLabel={targetActionLabel}
|
||||||
onOpenChange={setTransitionDialogOpen}
|
onOpenChange={setTransitionDialogOpen}
|
||||||
onSubmit={handleTransition}
|
onSubmit={handleTransition}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ export function TicketListPage() {
|
|||||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||||
<AppPageHeader
|
<AppPageHeader
|
||||||
title="工单中心"
|
title="工单中心"
|
||||||
description="集中查看、筛选和处理企业技术支持工单。"
|
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => (window.location.hash = '/tickets/new')}>
|
<Button onClick={() => (window.location.hash = '/tickets/new')}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppField } from '@/components/app/AppField'
|
|||||||
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
import type { Ticket } from './types'
|
import type { Ticket } from './types'
|
||||||
@@ -20,7 +20,6 @@ export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>工单属性</CardTitle>
|
<CardTitle>工单属性</CardTitle>
|
||||||
<CardDescription>当前处理状态和关键上下文。</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const EVENT_LABELS = [
|
|||||||
|
|
||||||
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
|
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
|
||||||
return (
|
return (
|
||||||
<AppSection title="处理记录" description="记录状态流转、协作说明和关键处理节点。">
|
<AppSection title="处理记录">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
|
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const STATUS_ACTION_LABELS: Partial<Record<TicketStatus, string>> = {
|
|||||||
type TicketTransitionDialogProps = {
|
type TicketTransitionDialogProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
status: TicketStatus | null
|
status: TicketStatus | null
|
||||||
|
actionLabel?: string
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSubmit: (status: TicketStatus, remark: string) => Promise<void>
|
onSubmit: (status: TicketStatus, remark: string) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -30,12 +31,13 @@ type TicketTransitionDialogProps = {
|
|||||||
export function TicketTransitionDialog({
|
export function TicketTransitionDialog({
|
||||||
open,
|
open,
|
||||||
status,
|
status,
|
||||||
|
actionLabel: customActionLabel,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit
|
onSubmit
|
||||||
}: TicketTransitionDialogProps) {
|
}: TicketTransitionDialogProps) {
|
||||||
const [remark, setRemark] = React.useState('')
|
const [remark, setRemark] = React.useState('')
|
||||||
const [submitting, setSubmitting] = React.useState(false)
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
const actionLabel = status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态'
|
const actionLabel = customActionLabel ?? (status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|||||||
Reference in New Issue
Block a user