From 08fa232fe33352377b597f0f75da1d0db43b93fc Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 2 May 2026 19:40:42 +0800 Subject: [PATCH] refactor(ticket): simplify frontend ticket API and components --- .../_components/conversation-info-panel.tsx | 8 +- web/app/dashboard/ticket-priorities/page.tsx | 616 -------- .../ticket-resolution-codes/page.tsx | 477 ------ web/app/dashboard/ticket-risk/page.tsx | 326 ---- ...create-ticket-from-conversation-dialog.tsx | 7 +- .../dashboard/tickets/_components/edit.tsx | 219 +-- .../_components/ticket-assign-dialog.tsx | 42 +- .../ticket-collaborator-dialog.tsx | 149 -- .../_components/ticket-priority-badge.tsx | 43 - .../_components/ticket-reason-dialog.tsx | 122 -- .../_components/ticket-relation-dialog.tsx | 229 --- .../_components/ticket-reply-dialog.tsx | 244 --- .../tickets/_components/ticket-sla-badge.tsx | 62 - .../_components/ticket-status-badge.tsx | 33 +- .../_components/ticket-status-dialog.tsx | 178 +-- web/app/dashboard/tickets/detail/page.tsx | 1271 ---------------- web/app/dashboard/tickets/page.tsx | 1344 +++-------------- .../customer-link-or-create-dialog.tsx | 13 +- web/lib/api/ticket-config.ts | 139 -- web/lib/api/ticket.ts | 336 +---- web/lib/navigation.tsx | 20 - web/lib/ticket-priority.ts | 19 - 22 files changed, 327 insertions(+), 5570 deletions(-) delete mode 100644 web/app/dashboard/ticket-priorities/page.tsx delete mode 100644 web/app/dashboard/ticket-resolution-codes/page.tsx delete mode 100644 web/app/dashboard/ticket-risk/page.tsx delete mode 100644 web/app/dashboard/tickets/_components/ticket-collaborator-dialog.tsx delete mode 100644 web/app/dashboard/tickets/_components/ticket-priority-badge.tsx delete mode 100644 web/app/dashboard/tickets/_components/ticket-reason-dialog.tsx delete mode 100644 web/app/dashboard/tickets/_components/ticket-relation-dialog.tsx delete mode 100644 web/app/dashboard/tickets/_components/ticket-reply-dialog.tsx delete mode 100644 web/app/dashboard/tickets/_components/ticket-sla-badge.tsx delete mode 100644 web/app/dashboard/tickets/detail/page.tsx delete mode 100644 web/lib/api/ticket-config.ts delete mode 100644 web/lib/ticket-priority.ts diff --git a/web/app/dashboard/conversations/_components/conversation-info-panel.tsx b/web/app/dashboard/conversations/_components/conversation-info-panel.tsx index 7c389cb..99279b8 100644 --- a/web/app/dashboard/conversations/_components/conversation-info-panel.tsx +++ b/web/app/dashboard/conversations/_components/conversation-info-panel.tsx @@ -55,7 +55,6 @@ import { ConversationTagBadges, ConversationTagPicker, } from "./conversation-tag-picker"; -import { TicketPriorityBadge } from "../../tickets/_components/ticket-priority-badge"; import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge"; function contactTypeLabel(contactType: ContactType | string) { @@ -629,9 +628,7 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati {tickets.map((ticket) => (
@@ -643,10 +640,9 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati {ticket.ticketNo}
- +
- {ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"} diff --git a/web/app/dashboard/ticket-priorities/page.tsx b/web/app/dashboard/ticket-priorities/page.tsx deleted file mode 100644 index e625edf..0000000 --- a/web/app/dashboard/ticket-priorities/page.tsx +++ /dev/null @@ -1,616 +0,0 @@ -"use client"; - -import { - closestCenter, - DndContext, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - GripVerticalIcon, - PencilIcon, - PlusIcon, - RefreshCwIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; -import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import { Controller, useForm, type Resolver } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod/v4"; - -import { useConfirm } from "@/components/confirm-provider"; -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 { ButtonGroup } from "@/components/ui/button-group"; -import { - Field, - FieldContent, - FieldError, - FieldLabel, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { - createTicketPriorityConfig, - deleteTicketPriorityConfig, - fetchTicketPriorityConfigs, - updateTicketPriorityConfig, - updateTicketPriorityConfigSort, - type CreateTicketPriorityConfigPayload, - type TicketPriorityConfig, -} from "@/lib/api/ticket-config"; -import { getEnumOptions } from "@/lib/enums"; -import { Status, StatusLabels } from "@/lib/generated/enums"; -import { cn } from "@/lib/utils"; - -const listStatusOptions = [ - { value: "all", label: "全部状态" }, - ...getEnumOptions(StatusLabels) - .filter((item) => Number(item.value) !== Status.Deleted) - .map((item) => ({ value: String(item.value), label: item.label })), -] as const; - -const formSchema = z.object({ - name: z.string().trim().min(1, "优先级名称不能为空"), - firstResponseMinutes: z - .string() - .trim() - .min(1, "首响时长不能为空") - .regex(/^\d+$/, "请输入正整数"), - resolutionMinutes: z - .string() - .trim() - .min(1, "解决时长不能为空") - .regex(/^\d+$/, "请输入正整数"), - status: z.enum([String(Status.Ok), String(Status.Disabled)], { - message: "请选择状态", - }), - remark: z.string().trim(), -}); - -type EditForm = z.infer; - -const resolver = zodResolver(formSchema as never) as Resolver< - z.input, - undefined, - z.output ->; - -const emptyForm: EditForm = { - name: "", - firstResponseMinutes: "30", - resolutionMinutes: "1440", - status: String(Status.Ok), - remark: "", -}; - -function buildForm(item: TicketPriorityConfig | null): EditForm { - if (!item) { - return emptyForm; - } - return { - name: item.name, - firstResponseMinutes: String(item.firstResponseMinutes), - resolutionMinutes: String(item.resolutionMinutes), - status: String(item.status) as EditForm["status"], - remark: item.remark || "", - }; -} - -function buildPayload(form: EditForm): CreateTicketPriorityConfigPayload { - return { - name: form.name.trim(), - firstResponseMinutes: Number(form.firstResponseMinutes), - resolutionMinutes: Number(form.resolutionMinutes), - status: Number(form.status), - remark: form.remark.trim(), - }; -} - -type SortablePriorityRowProps = { - item: TicketPriorityConfig; - disabled: boolean; - onEdit: (item: TicketPriorityConfig) => void; - onDelete: (item: TicketPriorityConfig) => void; -}; - -function SortablePriorityRow({ - item, - disabled, - onEdit, - onDelete, -}: SortablePriorityRowProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: item.id, - disabled, - }); - - const style: CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( - - - - - {item.name} - {item.firstResponseMinutes} 分钟 - {item.resolutionMinutes} 分钟 - - - {item.status === Status.Ok ? "启用" : "停用"} - - - - - - - - - - ); -} - -export default function TicketPrioritiesPage() { - const [keywordInput, setKeywordInput] = useState(""); - const [statusFilterInput, setStatusFilterInput] = useState("all"); - const [keyword, setKeyword] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [sorting, setSorting] = useState(false); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingItem, setEditingItem] = useState( - null, - ); - const [deleting, setDeleting] = useState(false); - const [items, setItems] = useState([]); - const confirm = useConfirm(); - - const sensors = useSensors( - useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), - useSensor(TouchSensor, { - activationConstraint: { delay: 120, tolerance: 8 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const loadData = useCallback(async () => { - setLoading(true); - try { - const data = await fetchTicketPriorityConfigs({ - name: keyword.trim() || undefined, - status: statusFilter === "all" ? undefined : statusFilter, - }); - setItems(Array.isArray(data) ? data : []); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "加载工单优先级失败", - ); - } finally { - setLoading(false); - } - }, [keyword, statusFilter]); - - useEffect(() => { - void loadData(); - }, [loadData]); - - function applyFilters() { - setKeyword(keywordInput); - setStatusFilter(statusFilterInput); - } - - async function handleSubmit(payload: CreateTicketPriorityConfigPayload) { - if (saving) { - return; - } - setSaving(true); - try { - if (editingItem) { - await updateTicketPriorityConfig({ id: editingItem.id, ...payload }); - toast.success(`已更新工单优先级:${payload.name}`); - } else { - await createTicketPriorityConfig(payload); - toast.success(`已创建工单优先级:${payload.name}`); - } - setDialogOpen(false); - setEditingItem(null); - await loadData(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "保存工单优先级失败", - ); - } finally { - setSaving(false); - } - } - - async function handleDelete(item: TicketPriorityConfig) { - if (deleting) { - return; - } - const confirmed = await confirm({ - title: "确认删除优先级", - description: `删除后将无法恢复。确定要删除工单优先级“${item.name}”吗?`, - confirmText: "确认删除", - cancelText: "取消", - variant: "destructive", - }); - if (!confirmed) { - return; - } - setDeleting(true); - try { - await deleteTicketPriorityConfig(item.id); - toast.success(`已删除工单优先级:${item.name}`); - await loadData(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "删除工单优先级失败", - ); - } finally { - setDeleting(false); - } - } - - async function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; - if (!over || active.id === over.id || sorting || loading) { - return; - } - const previousResults = items; - const oldIndex = previousResults.findIndex((item) => item.id === active.id); - const newIndex = previousResults.findIndex((item) => item.id === over.id); - if (oldIndex < 0 || newIndex < 0) { - return; - } - const nextResults = arrayMove(previousResults, oldIndex, newIndex); - setItems(nextResults); - setSorting(true); - try { - await updateTicketPriorityConfigSort(nextResults.map((item) => item.id)); - toast.success("工单优先级排序已更新"); - await loadData(); - } catch (error) { - setItems(previousResults); - toast.error(error instanceof Error ? error.message : "更新排序失败"); - } finally { - setSorting(false); - } - } - - return ( - <> -
-
-
- - setKeywordInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - applyFilters(); - } - }} - placeholder="按优先级名称筛选" - className="pl-9" - /> -
-
- ({ - value: item.value, - label: item.label, - }))} - /> -
- - - -
- -
- - - - - - - - - - - - - - {loading ? ( - - - - ) : items.length > 0 ? ( - item.id)} - strategy={verticalListSortingStrategy} - > - {items.map((item) => ( - { - setEditingItem(current); - setDialogOpen(true); - }} - onDelete={handleDelete} - /> - ))} - - ) : ( - - - - )} - -
名称首响时长解决时长状态操作
- 加载中... -
- 暂无工单优先级 -
-
-
-
- - { - setDialogOpen(nextOpen); - if (!nextOpen) { - setEditingItem(null); - } - }} - onSubmit={handleSubmit} - /> - - ); -} - -type TicketPriorityEditDialogProps = { - open: boolean; - saving: boolean; - item: TicketPriorityConfig | null; - onOpenChange: (open: boolean) => void; - onSubmit: (payload: CreateTicketPriorityConfigPayload) => Promise; -}; - -function TicketPriorityEditDialog({ - open, - saving, - item, - onOpenChange, - onSubmit, -}: TicketPriorityEditDialogProps) { - const formId = "ticket-priority-edit-form"; - const form = useForm< - z.input, - undefined, - z.output - >({ - resolver, - defaultValues: buildForm(item), - }); - const { - register, - control, - handleSubmit, - reset, - formState: { errors }, - } = form; - - useEffect(() => { - reset(buildForm(item)); - }, [item, reset]); - - return ( - - - - - } - > -
- onSubmit(buildPayload(values)), - )} - > - - 名称 - - - {errors.name ? : null} - - - -
- - - 首响时长 - - - - {errors.firstResponseMinutes ? ( - - ) : null} - - - - - - 解决时长 - - - - {errors.resolutionMinutes ? ( - - ) : null} - - -
- - - 状态 - - ( - - )} - /> - {errors.status ? : null} - - - - - 备注 - -