261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import * as React from 'react'
|
||
import { Save } from 'lucide-react'
|
||
|
||
import { AppField } from '@/components/app/AppField'
|
||
import { AppPage } from '@/components/app/AppPage'
|
||
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 { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Switch } from '@/components/ui/switch'
|
||
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',
|
||
allow_auto_resolve: false,
|
||
requires_human_action: false,
|
||
requires_external_confirmation: false,
|
||
requires_production_change: false
|
||
}
|
||
|
||
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, 'referenced')
|
||
window.location.hash = `/tickets/${sourceTicketId}`
|
||
return
|
||
}
|
||
window.location.hash = '/solutions'
|
||
} catch (reason) {
|
||
setError(reason instanceof Error ? reason.message : '保存解决方案失败,请检查必填信息。')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<AppPage
|
||
maxWidth="6xl"
|
||
contentClassName="grid gap-5"
|
||
header={<AppPageHeader
|
||
title="新建解决方案"
|
||
/>}
|
||
>
|
||
|
||
{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="方案标题" htmlFor="solution-title" required>
|
||
<Input
|
||
id="solution-title"
|
||
required
|
||
placeholder="例如:认证回调阻塞处理方案"
|
||
value={form.title}
|
||
onChange={(event) => updateField('title', event.target.value)}
|
||
/>
|
||
</AppField>
|
||
<AppField label="关联系统" htmlFor="solution-related-system">
|
||
<Input
|
||
id="solution-related-system"
|
||
placeholder="例如:CRM、认证中心、支付系统"
|
||
value={form.related_system}
|
||
onChange={(event) => updateField('related_system', event.target.value)}
|
||
/>
|
||
</AppField>
|
||
<AppField label="环境范围" htmlFor="solution-environment-scope">
|
||
<Input
|
||
id="solution-environment-scope"
|
||
placeholder="例如:生产环境 / Chrome"
|
||
value={form.environment_scope}
|
||
onChange={(event) => updateField('environment_scope', event.target.value)}
|
||
/>
|
||
</AppField>
|
||
</div>
|
||
</AppSection>
|
||
|
||
<AppSection title="问题特征">
|
||
<AppField label="问题特征" htmlFor="solution-problem-signature" required>
|
||
<Textarea
|
||
id="solution-problem-signature"
|
||
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="根因分析" htmlFor="solution-root-cause" required>
|
||
<Textarea
|
||
id="solution-root-cause"
|
||
required
|
||
placeholder="说明问题的根因或判断依据。"
|
||
value={form.root_cause}
|
||
onChange={(event) => updateField('root_cause', event.target.value)}
|
||
/>
|
||
</AppField>
|
||
<AppField label="处理方案" htmlFor="solution-content" required>
|
||
<Textarea
|
||
id="solution-content"
|
||
required
|
||
placeholder="写清楚处理步骤、验证方式和回滚注意事项。"
|
||
value={form.solution}
|
||
onChange={(event) => updateField('solution', event.target.value)}
|
||
/>
|
||
</AppField>
|
||
</div>
|
||
</AppSection>
|
||
|
||
<AppSection title="风险提示">
|
||
<div className="grid gap-5">
|
||
<AppField label="风险提示" htmlFor="solution-risk-note">
|
||
<Textarea
|
||
id="solution-risk-note"
|
||
placeholder="可选:说明操作风险、影响范围或上线前确认事项。"
|
||
value={form.risk_note}
|
||
onChange={(event) => updateField('risk_note', event.target.value)}
|
||
/>
|
||
</AppField>
|
||
<FieldSet>
|
||
<FieldLegend>自动化边界</FieldLegend>
|
||
<FieldGroup>
|
||
<PolicySwitch
|
||
id="allow-auto-resolve"
|
||
label="允许自动解决"
|
||
description="仅代表该方案具备自动复用资格,仍需同时满足范围、阈值、权限和全局策略。"
|
||
checked={form.allow_auto_resolve}
|
||
onCheckedChange={(checked) => updateField('allow_auto_resolve', checked)}
|
||
/>
|
||
<PolicySwitch
|
||
id="requires-human-action"
|
||
label="需要人工操作"
|
||
description="勾选后该方案只能作为人工建议,不能自动解决。"
|
||
checked={form.requires_human_action}
|
||
onCheckedChange={(checked) => updateField('requires_human_action', checked)}
|
||
/>
|
||
<PolicySwitch
|
||
id="requires-external-confirmation"
|
||
label="需要外部确认"
|
||
description="需要客户、第三方或外部系统确认时禁止自动解决。"
|
||
checked={form.requires_external_confirmation}
|
||
onCheckedChange={(checked) =>
|
||
updateField('requires_external_confirmation', checked)}
|
||
/>
|
||
<PolicySwitch
|
||
id="requires-production-change"
|
||
label="涉及生产变更"
|
||
description="任何生产变更都必须人工处理和确认。"
|
||
checked={form.requires_production_change}
|
||
onCheckedChange={(checked) =>
|
||
updateField('requires_production_change', checked)}
|
||
/>
|
||
</FieldGroup>
|
||
</FieldSet>
|
||
</div>
|
||
</AppSection>
|
||
</div>
|
||
|
||
<div className="grid content-start gap-4">
|
||
<AppSection title="提交">
|
||
<div className="grid gap-3">
|
||
<p className="text-sm text-muted-foreground">
|
||
方案固定保存为草稿,需在知识治理中心提交并由另一名审核人发布。
|
||
</p>
|
||
{sourceTicketId ? (
|
||
<p className="text-sm text-muted-foreground">保存后会关联到当前工单,审核发布后可由处理人正式采纳。</p>
|
||
) : null}
|
||
<Button type="submit" disabled={submitting}>
|
||
<Save data-icon="inline-start" />
|
||
{submitting ? '保存中...' : '保存方案'}
|
||
</Button>
|
||
</div>
|
||
</AppSection>
|
||
</div>
|
||
</form>
|
||
</AppPage>
|
||
)
|
||
}
|
||
|
||
function PolicySwitch({
|
||
id,
|
||
label,
|
||
description,
|
||
checked,
|
||
onCheckedChange
|
||
}: {
|
||
id: string
|
||
label: string
|
||
description: string
|
||
checked: boolean
|
||
onCheckedChange: (checked: boolean) => void
|
||
}) {
|
||
return (
|
||
<Field orientation="horizontal">
|
||
<FieldContent>
|
||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||
<FieldDescription>{description}</FieldDescription>
|
||
</FieldContent>
|
||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||
</Field>
|
||
)
|
||
}
|