"use client" import { CheckIcon, TagIcon } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import type { Resolver } from "react-hook-form" import { Controller, useForm } from "react-hook-form" import { z } from "zod/v4" import { OptionCombobox } from "@/components/option-combobox" import { ProjectDialog } from "@/components/project-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Field, FieldContent, FieldError, FieldGroup, FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Textarea } from "@/components/ui/textarea" import { fetchAgentProfilesAll, fetchAgentTeamsAll, fetchTagsAll, type AdminAgentProfile, type AdminAgentTeam, type TagTree, } from "@/lib/api/admin" import { fetchTicketPriorityConfigsAll, type TicketPriorityConfig, } from "@/lib/api/ticket-config" import { fetchTicketDetail, type CreateTicketPayload, type TicketItem, type UpdateTicketPayload, } from "@/lib/api/ticket" type EditDialogProps = { open: boolean saving: boolean itemId: number | null initialValues?: Partial fixedConversationId?: number fixedCustomerId?: number titleOverride?: string descriptionOverride?: string onOpenChange: (open: boolean) => void onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise } const ticketFormSchema = z.object({ title: z.string().trim().min(1, "标题不能为空"), description: z.string().trim(), tagIds: z.array(z.string().trim()).default([]), priority: z.string().trim().min(1, "请选择优先级"), severity: z.enum(["1", "2", "3"], { message: "请选择严重度" }), currentTeamId: z.string().trim(), currentAssigneeId: z.string().trim(), dueAt: z.string().trim(), }) type EditForm = z.infer const editFormResolver = zodResolver(ticketFormSchema as never) as Resolver< z.input, undefined, z.output > const emptyForm: EditForm = { title: "", description: "", tagIds: [], priority: "", severity: "1", currentTeamId: "", currentAssigneeId: "", dueAt: "", } function buildForm(item: TicketItem | null): EditForm { if (!item) { return emptyForm } return { title: item.title ?? "", description: item.description ?? "", tagIds: (item.tags ?? []).map((tag) => String(tag.id)), priority: item.priority ? String(item.priority) : "", severity: String(item.severity || 1) as EditForm["severity"], currentTeamId: item.currentTeamId ? String(item.currentTeamId) : "", currentAssigneeId: item.currentAssigneeId ? String(item.currentAssigneeId) : "", dueAt: item.dueAt ? item.dueAt.replace(" ", "T").slice(0, 16) : "", } } function buildInitialForm(initialValues?: Partial): EditForm { return { title: initialValues?.title?.trim() ?? "", description: initialValues?.description?.trim() ?? "", tagIds: (initialValues?.tagIds ?? []).map((tagId) => String(tagId)), priority: initialValues?.priority ? String(initialValues.priority) : "", severity: String(initialValues?.severity ?? 1) as EditForm["severity"], currentTeamId: initialValues?.currentTeamId ? String(initialValues.currentTeamId) : "", currentAssigneeId: initialValues?.currentAssigneeId ? String(initialValues.currentAssigneeId) : "", dueAt: initialValues?.dueAt ? initialValues.dueAt.replace(" ", "T").slice(0, 16) : "", } } function buildPayload(form: EditForm): CreateTicketPayload { return { title: form.title.trim(), description: form.description.trim(), tagIds: form.tagIds.length > 0 ? form.tagIds.map((tagId) => Number(tagId)) : undefined, priority: Number(form.priority), severity: Number(form.severity), currentTeamId: form.currentTeamId ? Number(form.currentTeamId) : undefined, currentAssigneeId: form.currentAssigneeId ? Number(form.currentAssigneeId) : undefined, dueAt: form.dueAt ? `${form.dueAt.replace("T", " ")}:00` : undefined, } } type FlatTagNode = TagTree & { depth: number path: string } function flattenTagTree(nodes: TagTree[], depth = 0, parentPath = ""): FlatTagNode[] { const result: FlatTagNode[] = [] nodes.forEach((item) => { const path = parentPath ? `${parentPath} / ${item.name}` : item.name result.push({ ...item, depth, path }) if (item.children.length > 0) { result.push(...flattenTagTree(item.children, depth + 1, path)) } }) return result } type TicketTagSelectorProps = { value?: string[] onChange: (value: string[]) => void availableTags: TagTree[] } function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelectorProps) { const selectedValues = value ?? [] const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags]) const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues]) const selectedTags = useMemo( () => flatTags.filter((tag) => selectedTagIDs.has(String(tag.id))), [flatTags, selectedTagIDs], ) function handleToggle(tagID: string) { if (selectedTagIDs.has(tagID)) { onChange(selectedValues.filter((item) => item !== tagID)) return } onChange(selectedValues.concat(tagID)) } return (
} > {selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"} 暂无可用标签 {flatTags.map((tag) => { const checked = selectedTagIDs.has(String(tag.id)) return ( handleToggle(String(tag.id))} > {tag.name} ) })} {selectedTags.length > 0 ? (
{selectedTags.map((tag) => ( {tag.path} ))}
) : null}
) } export function EditDialog({ open, saving, itemId, initialValues, fixedConversationId, fixedCustomerId, titleOverride, descriptionOverride, onOpenChange, onSubmit, }: EditDialogProps) { if (!open) { return null } return ( ) } type TicketEditDialogBodyProps = EditDialogProps function TicketEditDialogBody({ open, saving, itemId, initialValues, fixedConversationId, fixedCustomerId, titleOverride, descriptionOverride, onOpenChange, onSubmit, }: TicketEditDialogBodyProps) { const formId = "ticket-edit-form" const [loading, setLoading] = useState(false) const [tags, setTags] = useState([]) const [priorities, setPriorities] = useState([]) const [teams, setTeams] = useState([]) const [agents, setAgents] = useState([]) const form = useForm< z.input, undefined, z.output >({ resolver: editFormResolver, defaultValues: emptyForm, }) const { register, control, handleSubmit, reset, formState: { errors }, } = form useEffect(() => { async function loadDetail() { if (!itemId) { reset(buildInitialForm(initialValues)) return } setLoading(true) try { const data = await fetchTicketDetail(itemId) reset(buildForm(data.ticket)) } finally { setLoading(false) } } void loadDetail() }, [initialValues, itemId, reset]) useEffect(() => { if (!open) { return } void (async () => { const [tagData, priorityData, teamData, agentData] = await Promise.all([ fetchTagsAll(), fetchTicketPriorityConfigsAll(), fetchAgentTeamsAll(), fetchAgentProfilesAll(), ]) setTags(Array.isArray(tagData) ? tagData : []) setPriorities(Array.isArray(priorityData) ? priorityData : []) setTeams(Array.isArray(teamData) ? teamData : []) setAgents(Array.isArray(agentData) ? agentData : []) })() }, [open]) const priorityOptions = priorities.map((priority) => ({ value: String(priority.id), label: priority.name, })) const teamOptions = [{ value: "", label: "不指定团队" }].concat( teams.map((team) => ({ value: String(team.id), label: team.name, })), ) const agentOptions = [{ value: "", label: "不指定处理人" }].concat( agents.map((agent) => ({ value: String(agent.userId), label: agent.displayName || agent.nickname || agent.username || `客服#${agent.userId}`, })), ) async function onFormSubmit(values: EditForm) { const payload = buildPayload(values) if (itemId) { await onSubmit({ ticketId: itemId, ...payload, }) return } await onSubmit({ ...payload, source: fixedConversationId ? "conversation" : "manual", conversationId: fixedConversationId, customerId: fixedCustomerId, }) } return ( } > {loading ? (
加载中...
) : (
标题 描述