feat: add ticket solution closure loop

This commit is contained in:
hailin
2026-07-02 10:11:30 +08:00
parent 0ea4a3c853
commit a6df96ebb0
18 changed files with 435 additions and 68 deletions

View 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>
)
}