mirror of
https://git.boos.lat/aibo/BossChat.git
synced 2026-10-31 07:36:11 +00:00
230 lines
7.8 KiB
TypeScript
230 lines
7.8 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { zodResolver } from "@hookform/resolvers/zod"
|
||
import { Resolver, useForm } from "react-hook-form"
|
||
import { toast } from "sonner"
|
||
import { z } from "zod/v4"
|
||
import { SearchIcon } from "lucide-react"
|
||
|
||
import { OptionCombobox } from "@/components/option-combobox"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog"
|
||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||
import { Input } from "@/components/ui/input"
|
||
import { addTicketRelation, fetchTickets, type TicketItem } from "@/lib/api/ticket"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
const relationOptions = [
|
||
{ value: "duplicate", label: "重复工单" },
|
||
{ value: "related", label: "相关工单" },
|
||
{ value: "parent", label: "父工单" },
|
||
{ value: "child", label: "子工单" },
|
||
]
|
||
|
||
const schema = z.object({
|
||
relationType: z.string().trim().min(1, "请选择关联类型"),
|
||
relatedTicketId: z.number().int().positive("请选择关联工单"),
|
||
})
|
||
|
||
type FormValues = z.infer<typeof schema>
|
||
|
||
const resolver = zodResolver(schema as never) as Resolver<
|
||
z.input<typeof schema>,
|
||
undefined,
|
||
z.output<typeof schema>
|
||
>
|
||
|
||
type TicketRelationDialogProps = {
|
||
open: boolean
|
||
ticketId: number | null
|
||
onOpenChange: (open: boolean) => void
|
||
onSuccess?: () => Promise<void> | void
|
||
}
|
||
|
||
export function TicketRelationDialog({
|
||
open,
|
||
ticketId,
|
||
onOpenChange,
|
||
onSuccess,
|
||
}: TicketRelationDialogProps) {
|
||
const [keyword, setKeyword] = useState("")
|
||
const [searching, setSearching] = useState(false)
|
||
const [searchResults, setSearchResults] = useState<TicketItem[]>([])
|
||
|
||
const form = useForm<
|
||
z.input<typeof schema>,
|
||
undefined,
|
||
z.output<typeof schema>
|
||
>({
|
||
resolver,
|
||
defaultValues: {
|
||
relationType: "related",
|
||
relatedTicketId: 0,
|
||
},
|
||
})
|
||
|
||
const {
|
||
handleSubmit,
|
||
setValue,
|
||
reset,
|
||
watch,
|
||
formState: { errors, isSubmitting },
|
||
} = form
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
reset({ relationType: "related", relatedTicketId: 0 })
|
||
setKeyword("")
|
||
setSearchResults([])
|
||
}
|
||
}, [open, reset])
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
return
|
||
}
|
||
const trimmedKeyword = keyword.trim()
|
||
if (trimmedKeyword.length < 2) {
|
||
setSearchResults([])
|
||
return
|
||
}
|
||
const timer = window.setTimeout(async () => {
|
||
setSearching(true)
|
||
try {
|
||
const data = await fetchTickets({
|
||
keyword: trimmedKeyword,
|
||
page: 1,
|
||
limit: 8,
|
||
})
|
||
const results = Array.isArray(data.results) ? data.results : []
|
||
setSearchResults(results.filter((item) => item.id !== ticketId))
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "搜索工单失败")
|
||
} finally {
|
||
setSearching(false)
|
||
}
|
||
}, 250)
|
||
return () => window.clearTimeout(timer)
|
||
}, [keyword, open, ticketId])
|
||
|
||
const selectedTicketId = watch("relatedTicketId")
|
||
const selectedTicket =
|
||
searchResults.find((item) => item.id === selectedTicketId) ?? null
|
||
|
||
async function onFormSubmit(values: FormValues) {
|
||
if (!ticketId) {
|
||
toast.error("工单不存在")
|
||
return
|
||
}
|
||
try {
|
||
await addTicketRelation({
|
||
ticketId,
|
||
relationType: values.relationType,
|
||
relatedTicketId: values.relatedTicketId,
|
||
})
|
||
toast.success("关联工单已添加")
|
||
onOpenChange(false)
|
||
await onSuccess?.()
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "添加关联工单失败")
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||
<DialogHeader className="px-6 pt-6">
|
||
<DialogTitle>新增关联工单</DialogTitle>
|
||
</DialogHeader>
|
||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||
<div className="space-y-4 p-6">
|
||
<Field data-invalid={!!errors.relationType}>
|
||
<FieldLabel>关联类型</FieldLabel>
|
||
<FieldContent>
|
||
<OptionCombobox
|
||
value={watch("relationType")}
|
||
options={relationOptions}
|
||
placeholder="请选择关联类型"
|
||
onChange={(value) => setValue("relationType", value, { shouldValidate: true })}
|
||
/>
|
||
<FieldError errors={[errors.relationType]} />
|
||
</FieldContent>
|
||
</Field>
|
||
<Field data-invalid={!!errors.relatedTicketId}>
|
||
<FieldLabel>搜索并选择工单</FieldLabel>
|
||
<FieldContent>
|
||
<div className="space-y-3">
|
||
<div className="relative">
|
||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
className="pl-9"
|
||
value={keyword}
|
||
placeholder="输入工单号或标题,至少 2 个字"
|
||
onChange={(event) => {
|
||
setKeyword(event.target.value)
|
||
setValue("relatedTicketId", 0, { shouldValidate: true })
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="max-h-64 overflow-y-auto rounded-lg border">
|
||
{searching ? (
|
||
<div className="p-3 text-sm text-muted-foreground">搜索中...</div>
|
||
) : searchResults.length > 0 ? (
|
||
searchResults.map((item) => {
|
||
const active = item.id === selectedTicketId
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={cn(
|
||
"flex w-full flex-col items-start gap-1 border-b px-3 py-3 text-left last:border-b-0",
|
||
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/40",
|
||
)}
|
||
onClick={() => setValue("relatedTicketId", item.id, { shouldValidate: true })}
|
||
>
|
||
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
|
||
<div className="line-clamp-1 text-sm font-medium">{item.title}</div>
|
||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||
<span>状态:{item.status}</span>
|
||
<span>处理人:{item.currentAssigneeName || "未指派"}</span>
|
||
</div>
|
||
</button>
|
||
)
|
||
})
|
||
) : (
|
||
<div className="p-3 text-sm text-muted-foreground">
|
||
{keyword.trim().length < 2 ? "输入至少 2 个字开始搜索" : "未找到匹配工单"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{selectedTicket ? (
|
||
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
|
||
已选中:{selectedTicket.ticketNo} / {selectedTicket.title}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<FieldError errors={[errors.relatedTicketId]} />
|
||
</FieldContent>
|
||
</Field>
|
||
</div>
|
||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||
取消
|
||
</Button>
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
{isSubmitting ? "提交中..." : "确认添加"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|