refactor(ticket): simplify frontend ticket API and components
This commit is contained in:
@@ -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) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/dashboard/tickets/detail?id=${ticket.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`/dashboard/tickets?ticketId=${ticket.id}`}
|
||||
className="block rounded-lg border border-border bg-background px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -643,10 +640,9 @@ function RelatedTicketsSection({ conversation }: { conversation: AgentConversati
|
||||
{ticket.ticketNo}
|
||||
</div>
|
||||
</div>
|
||||
<TicketPriorityBadge priority={ticket.priority} priorityName={ticket.priorityName} />
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
|
||||
</span>
|
||||
|
||||
@@ -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<typeof formSchema>;
|
||||
|
||||
const resolver = zodResolver(formSchema as never) as Resolver<
|
||||
z.input<typeof formSchema>,
|
||||
undefined,
|
||||
z.output<typeof formSchema>
|
||||
>;
|
||||
|
||||
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 (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"border-t",
|
||||
isDragging && "relative z-10 bg-muted/60 shadow-sm",
|
||||
)}
|
||||
>
|
||||
<td className="w-14 px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 cursor-grab active:cursor-grabbing"
|
||||
disabled={disabled}
|
||||
aria-label={`拖拽排序 ${item.name}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</td>
|
||||
<td className="px-4 py-3">{item.name}</td>
|
||||
<td className="px-4 py-3">{item.firstResponseMinutes} 分钟</td>
|
||||
<td className="px-4 py-3">{item.resolutionMinutes} 分钟</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={item.status === Status.Ok ? "default" : "secondary"}>
|
||||
{item.status === Status.Ok ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<ButtonGroup className="ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(item)}>
|
||||
<PencilIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onDelete(item)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
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<TicketPriorityConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [items, setItems] = useState<TicketPriorityConfig[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
|
||||
<div className="relative min-w-72">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}
|
||||
}}
|
||||
placeholder="按优先级名称筛选"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full xl:w-40">
|
||||
<OptionCombobox
|
||||
value={statusFilterInput}
|
||||
onChange={setStatusFilterInput}
|
||||
placeholder="全部状态"
|
||||
options={listStatusOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
<SearchIcon className="size-4" />
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void loadData()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
新建优先级
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/35">
|
||||
<tr>
|
||||
<th className="w-14 px-4 py-3 text-left font-medium"></th>
|
||||
<th className="px-4 py-3 text-left font-medium">名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">首响时长</th>
|
||||
<th className="px-4 py-3 text-left font-medium">解决时长</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length > 0 ? (
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<SortablePriorityRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
disabled={sorting}
|
||||
onEdit={(current) => {
|
||||
setEditingItem(current);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
暂无工单优先级
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TicketPriorityEditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
item={editingItem}
|
||||
onOpenChange={(nextOpen) => {
|
||||
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<void>;
|
||||
};
|
||||
|
||||
function TicketPriorityEditDialog({
|
||||
open,
|
||||
saving,
|
||||
item,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TicketPriorityEditDialogProps) {
|
||||
const formId = "ticket-priority-edit-form";
|
||||
const form = useForm<
|
||||
z.input<typeof formSchema>,
|
||||
undefined,
|
||||
z.output<typeof formSchema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: buildForm(item),
|
||||
});
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
useEffect(() => {
|
||||
reset(buildForm(item));
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={item ? "编辑工单优先级" : "新建工单优先级"}
|
||||
description="优先级同时承载首响与解决时长配置。排序请在列表中拖动调整。"
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving}>
|
||||
{saving ? "保存中..." : item ? "保存" : "创建"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
className="space-y-4"
|
||||
onSubmit={handleSubmit(async (values) =>
|
||||
onSubmit(buildPayload(values)),
|
||||
)}
|
||||
>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="ticket-priority-name">名称</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-priority-name"
|
||||
placeholder="请输入优先级名称"
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name ? <FieldError errors={[errors.name]} /> : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errors.firstResponseMinutes)}>
|
||||
<FieldLabel htmlFor="ticket-priority-first-response">
|
||||
首响时长
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-priority-first-response"
|
||||
placeholder="分钟"
|
||||
{...register("firstResponseMinutes")}
|
||||
/>
|
||||
{errors.firstResponseMinutes ? (
|
||||
<FieldError errors={[errors.firstResponseMinutes]} />
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.resolutionMinutes)}>
|
||||
<FieldLabel htmlFor="ticket-priority-resolution">
|
||||
解决时长
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-priority-resolution"
|
||||
placeholder="分钟"
|
||||
{...register("resolutionMinutes")}
|
||||
/>
|
||||
{errors.resolutionMinutes ? (
|
||||
<FieldError errors={[errors.resolutionMinutes]} />
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errors.status)}>
|
||||
<FieldLabel>状态</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择状态"
|
||||
options={[
|
||||
{ value: String(Status.Ok), label: "启用" },
|
||||
{ value: String(Status.Disabled), label: "停用" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.status ? <FieldError errors={[errors.status]} /> : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.remark)}>
|
||||
<FieldLabel htmlFor="ticket-priority-remark">备注</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="ticket-priority-remark"
|
||||
rows={4}
|
||||
placeholder="可选"
|
||||
{...register("remark")}
|
||||
/>
|
||||
{errors.remark ? <FieldError errors={[errors.remark]} /> : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Controller, useForm, type Resolver } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ListPagination } from "@/components/list-pagination";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTicketResolutionCode,
|
||||
deleteTicketResolutionCode,
|
||||
fetchTicketResolutionCodes,
|
||||
updateTicketResolutionCode,
|
||||
type CreateTicketResolutionCodePayload,
|
||||
type PageResult,
|
||||
type TicketResolutionCode,
|
||||
} from "@/lib/api/ticket-config";
|
||||
import { getEnumLabel, getEnumOptions } from "@/lib/enums";
|
||||
import { Status, StatusLabels } from "@/lib/generated/enums";
|
||||
|
||||
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, "解决码名称不能为空"),
|
||||
code: z.string().trim().min(1, "解决码编码不能为空"),
|
||||
sortNo: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "排序不能为空")
|
||||
.regex(/^\d+$/, "排序值必须是大于等于 0 的整数"),
|
||||
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
|
||||
message: "请选择状态",
|
||||
}),
|
||||
remark: z.string().trim(),
|
||||
});
|
||||
|
||||
type EditForm = z.infer<typeof formSchema>;
|
||||
|
||||
const resolver = zodResolver(formSchema as never) as Resolver<
|
||||
z.input<typeof formSchema>,
|
||||
undefined,
|
||||
z.output<typeof formSchema>
|
||||
>;
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
name: "",
|
||||
code: "",
|
||||
sortNo: "0",
|
||||
status: String(Status.Ok),
|
||||
remark: "",
|
||||
};
|
||||
|
||||
function buildForm(item: TicketResolutionCode | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm;
|
||||
}
|
||||
return {
|
||||
name: item.name,
|
||||
code: item.code,
|
||||
sortNo: String(item.sortNo),
|
||||
status: String(item.status) as EditForm["status"],
|
||||
remark: item.remark || "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateTicketResolutionCodePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
sortNo: Number(form.sortNo),
|
||||
status: Number(form.status),
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TicketResolutionCodesPage() {
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [statusFilterInput, setStatusFilterInput] = useState("all");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<TicketResolutionCode | null>(
|
||||
null,
|
||||
);
|
||||
const [result, setResult] = useState<PageResult<TicketResolutionCode>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: 20, total: 0 },
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTicketResolutionCodes({
|
||||
name: keyword.trim() || undefined,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载解决码失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, statusFilter, page, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
function applyFilters() {
|
||||
setKeyword(keywordInput);
|
||||
setStatusFilter(statusFilterInput);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateTicketResolutionCodePayload) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateTicketResolutionCode({ id: editingItem.id, ...payload });
|
||||
toast.success(`已更新解决码:${payload.name}`);
|
||||
} else {
|
||||
await createTicketResolutionCode(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: TicketResolutionCode) {
|
||||
try {
|
||||
await deleteTicketResolutionCode(item.id);
|
||||
toast.success(`已删除解决码:${item.name}`);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除解决码失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
|
||||
<div className="relative min-w-72">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}
|
||||
}}
|
||||
placeholder="按解决码名称筛选"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full xl:w-40">
|
||||
<OptionCombobox
|
||||
value={statusFilterInput}
|
||||
onChange={setStatusFilterInput}
|
||||
placeholder="全部状态"
|
||||
options={listStatusOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
<SearchIcon className="size-4" />
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void loadData()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
新建解决码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/35">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">编码</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left font-medium">排序</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : result.results.length > 0 ? (
|
||||
result.results.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-4 py-3">{item.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{item.code}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={
|
||||
item.status === Status.Ok ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{getEnumLabel(StatusLabels, item.status as Status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{item.sortNo}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="sm" />}
|
||||
>
|
||||
操作
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => void handleDelete(item)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
暂无解决码
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ListPagination
|
||||
page={result.page.page}
|
||||
total={result.page.total}
|
||||
limit={result.page.limit}
|
||||
loading={loading}
|
||||
onPageChange={setPage}
|
||||
onLimitChange={(value) => {
|
||||
setLimit(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TicketResolutionCodeEditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
item={editingItem}
|
||||
onOpenChange={(open) => {
|
||||
if (!saving) {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TicketResolutionCodeEditDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
item: TicketResolutionCode | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateTicketResolutionCodePayload) => Promise<void>;
|
||||
};
|
||||
|
||||
function TicketResolutionCodeEditDialog({
|
||||
open,
|
||||
saving,
|
||||
item,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TicketResolutionCodeEditDialogProps) {
|
||||
const formId = "ticket-resolution-code-edit-form";
|
||||
const form = useForm<
|
||||
z.input<typeof formSchema>,
|
||||
undefined,
|
||||
z.output<typeof formSchema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: emptyForm,
|
||||
});
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
useEffect(() => {
|
||||
reset(buildForm(item));
|
||||
}, [item, reset]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={item ? "编辑解决码" : "新建解决码"}
|
||||
size="md"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving}>
|
||||
{saving ? "保存中..." : item ? "保存" : "创建"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit(async (values) =>
|
||||
onSubmit(buildPayload(values)),
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="ticket-resolution-code-name">
|
||||
解决码名称
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-resolution-code-name"
|
||||
placeholder="请输入解决码名称"
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="ticket-resolution-code-code">
|
||||
解决码编码
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-resolution-code-code"
|
||||
placeholder="请输入解决码编码"
|
||||
{...register("code")}
|
||||
/>
|
||||
<FieldError errors={[errors.code]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field data-invalid={!!errors.sortNo}>
|
||||
<FieldLabel htmlFor="ticket-resolution-code-sort">排序</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="ticket-resolution-code-sort" {...register("sortNo")} />
|
||||
<FieldError errors={[errors.sortNo]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.status}>
|
||||
<FieldLabel>状态</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择状态"
|
||||
options={listStatusOptions
|
||||
.filter((item) => item.value !== "all")
|
||||
.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.status]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ticket-resolution-code-remark">备注</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="ticket-resolution-code-remark"
|
||||
rows={4}
|
||||
{...register("remark")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { AlertTriangleIcon, CircleDashedIcon, RefreshCcwIcon, TimerResetIcon, WrenchIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
fetchAgentTeamsAll,
|
||||
type AdminAgentTeam,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
fetchTicketRiskOverview,
|
||||
fetchTicketRiskList,
|
||||
type TicketItem,
|
||||
type TicketRiskOverview,
|
||||
} from "@/lib/api/ticket"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { TicketPriorityBadge } from "../tickets/_components/ticket-priority-badge"
|
||||
import { TicketSLABadge } from "../tickets/_components/ticket-sla-badge"
|
||||
import { TicketStatusBadge } from "../tickets/_components/ticket-status-badge"
|
||||
|
||||
type RiskTableProps = {
|
||||
title: string
|
||||
description: string
|
||||
items: TicketItem[]
|
||||
emptyText: string
|
||||
}
|
||||
|
||||
function RiskTable({ title, description, items, emptyText }: RiskTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>工单</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>SLA</TableHead>
|
||||
<TableHead>处理人</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="min-w-64">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.tags && item.tags.length > 0
|
||||
? item.tags.map((tag) => tag.name).join(" / ")
|
||||
: "未打标签"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TicketPriorityBadge priority={item.priority} priorityName={item.priorityName} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TicketStatusBadge status={item.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TicketSLABadge ticket={item} />
|
||||
</TableCell>
|
||||
<TableCell>{item.currentAssigneeName || "未指派"}</TableCell>
|
||||
<TableCell>{item.updatedAt ? formatDateTime(item.updatedAt) : "—"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link href={`/dashboard/tickets/detail?id=${item.id}`} target="_blank" rel="noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
查看详情
|
||||
</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
|
||||
{emptyText}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TicketRiskPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [overview, setOverview] = useState<TicketRiskOverview | null>(null)
|
||||
const [overdueTickets, setOverdueTickets] = useState<TicketItem[]>([])
|
||||
const [highRiskTickets, setHighRiskTickets] = useState<TicketItem[]>([])
|
||||
const [unassignedTickets, setUnassignedTickets] = useState<TicketItem[]>([])
|
||||
const [pendingInternalTickets, setPendingInternalTickets] = useState<TicketItem[]>([])
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [teamFilter, setTeamFilter] = useState("all")
|
||||
const [riskWindow, setRiskWindow] = useState("240")
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchAgentTeamsAll()
|
||||
setTeams(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setTeams([])
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const currentTeamId = teamFilter === "all" ? undefined : Number(teamFilter)
|
||||
const riskMinutes = Number(riskWindow)
|
||||
const [overviewData, overdueData, highRiskData, unassignedData, pendingInternalData] =
|
||||
await Promise.all([
|
||||
fetchTicketRiskOverview({ currentTeamId, riskWindowMins: riskMinutes }),
|
||||
fetchTicketRiskList({ riskType: "overdue", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
||||
fetchTicketRiskList({ riskType: "high_risk", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
||||
fetchTicketRiskList({ riskType: "unassigned", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
||||
fetchTicketRiskList({ riskType: "pending_internal", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
|
||||
])
|
||||
|
||||
setOverview(overviewData)
|
||||
setOverdueTickets(Array.isArray(overdueData.results) ? overdueData.results : [])
|
||||
setHighRiskTickets(Array.isArray(highRiskData.results) ? highRiskData.results : [])
|
||||
setUnassignedTickets(Array.isArray(unassignedData.results) ? unassignedData.results : [])
|
||||
setPendingInternalTickets(
|
||||
Array.isArray(pendingInternalData.results) ? pendingInternalData.results : [],
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载 SLA 风险页失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [riskWindow, teamFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const cards = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "已超时",
|
||||
description: "解决 SLA 已经 breach 的工单",
|
||||
value: overview?.overdue ?? 0,
|
||||
icon: AlertTriangleIcon,
|
||||
tone: "text-red-700 bg-red-500/10",
|
||||
},
|
||||
{
|
||||
title: `${Number(riskWindow) / 60} 小时内到期`,
|
||||
description: "建议组长优先盯防的风险队列",
|
||||
value: overview?.highRisk ?? 0,
|
||||
icon: TimerResetIcon,
|
||||
tone: "text-orange-700 bg-orange-500/10",
|
||||
},
|
||||
{
|
||||
title: "待分配",
|
||||
description: "目前还没有明确负责人的工单",
|
||||
value: overview?.unassigned ?? 0,
|
||||
icon: CircleDashedIcon,
|
||||
tone: "text-amber-700 bg-amber-500/10",
|
||||
},
|
||||
{
|
||||
title: "待内部处理",
|
||||
description: "等待内部团队协作处理的工单",
|
||||
value: overview?.pendingInternal ?? 0,
|
||||
icon: WrenchIcon,
|
||||
tone: "text-blue-700 bg-blue-500/10",
|
||||
},
|
||||
],
|
||||
[overview, riskWindow],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">SLA 风险运营</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
给主管和组长使用的风险盯防页,优先查看超时、临近超时和待分配工单
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-44">
|
||||
<OptionCombobox
|
||||
value={teamFilter}
|
||||
onChange={setTeamFilter}
|
||||
placeholder="全部团队"
|
||||
options={[
|
||||
{ value: "all", label: "全部团队" },
|
||||
...teams.map((team) => ({ value: String(team.id), label: team.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<OptionCombobox
|
||||
value={riskWindow}
|
||||
onChange={setRiskWindow}
|
||||
placeholder="风险时间窗"
|
||||
options={[
|
||||
{ value: "60", label: "1 小时内" },
|
||||
{ value: "240", label: "4 小时内" },
|
||||
{ value: "1440", label: "24 小时内" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Link href="/dashboard/tickets">
|
||||
<Button variant="outline">前往工单工作台</Button>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Card key={item.title}>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
|
||||
<CardDescription>{item.description}</CardDescription>
|
||||
</div>
|
||||
<div className={`rounded-full p-2 ${item.tone}`}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-semibold tracking-tight">
|
||||
{loading ? "..." : item.value.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<RiskTable
|
||||
title="已超时工单"
|
||||
description="需要立即处理或升级的高风险工单"
|
||||
items={overdueTickets}
|
||||
emptyText="当前没有已超时工单"
|
||||
/>
|
||||
|
||||
<RiskTable
|
||||
title="4 小时内到期"
|
||||
description="建议优先处理,避免进入超时队列"
|
||||
items={highRiskTickets}
|
||||
emptyText="当前没有临近超时工单"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">滞留原因</CardTitle>
|
||||
<CardDescription>帮助主管快速判断风险是由分配、协作还是 SLA 配置问题造成</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{(overview?.reasons?.length ?? 0) > 0 ? (
|
||||
overview?.reasons?.map((item) => (
|
||||
<div key={item.code} className="rounded-lg border bg-muted/20 p-4">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{item.count.toLocaleString()}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">暂无滞留原因数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<RiskTable
|
||||
title="待分配工单"
|
||||
description="进入队列但尚未明确负责人的工单"
|
||||
items={unassignedTickets}
|
||||
emptyText="当前没有待分配工单"
|
||||
/>
|
||||
<RiskTable
|
||||
title="待内部处理"
|
||||
description="需要内部团队介入,容易长期滞留的工单"
|
||||
items={pendingInternalTickets}
|
||||
emptyText="当前没有待内部处理工单"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,8 +30,6 @@ export function CreateTicketFromConversationDialog({
|
||||
? {
|
||||
title: conversation.customerName || "",
|
||||
description: conversation.lastMessageSummary || "",
|
||||
priority: 2,
|
||||
severity: 1,
|
||||
currentAssigneeId: conversation.currentAssigneeId || undefined,
|
||||
}
|
||||
: undefined
|
||||
@@ -55,11 +53,8 @@ export function CreateTicketFromConversationDialog({
|
||||
conversationId: conversation.id,
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
priority: payload.priority,
|
||||
severity: payload.severity,
|
||||
currentTeamId: payload.currentTeamId,
|
||||
currentAssigneeId: payload.currentAssigneeId,
|
||||
syncToConversation: true,
|
||||
tagIds: payload.tagIds,
|
||||
})
|
||||
toast.success("工单创建成功")
|
||||
onSuccess?.()
|
||||
|
||||
@@ -31,16 +31,10 @@ 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,
|
||||
@@ -61,34 +55,26 @@ type EditDialogProps = {
|
||||
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
|
||||
}
|
||||
|
||||
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(),
|
||||
const schema = z.object({
|
||||
title: z.string().trim().min(1, "请输入工单标题"),
|
||||
description: z.string().trim().min(1, "请输入问题描述"),
|
||||
currentAssigneeId: z.coerce.number().int().min(0).optional(),
|
||||
tagIds: z.array(z.number().int().positive()).default([]),
|
||||
})
|
||||
|
||||
type EditForm = z.infer<typeof ticketFormSchema>
|
||||
type EditForm = z.infer<typeof schema>
|
||||
|
||||
const editFormResolver = zodResolver(ticketFormSchema as never) as Resolver<
|
||||
z.input<typeof ticketFormSchema>,
|
||||
const editFormResolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof ticketFormSchema>
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
title: "",
|
||||
description: "",
|
||||
currentAssigneeId: 0,
|
||||
tagIds: [],
|
||||
priority: "",
|
||||
severity: "1",
|
||||
currentTeamId: "",
|
||||
currentAssigneeId: "",
|
||||
dueAt: "",
|
||||
}
|
||||
|
||||
function buildForm(item: TicketItem | null): EditForm {
|
||||
@@ -98,12 +84,8 @@ function buildForm(item: TicketItem | null): EditForm {
|
||||
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) : "",
|
||||
currentAssigneeId: item.currentAssigneeId ?? 0,
|
||||
tagIds: (item.tags ?? []).map((tag) => tag.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,27 +93,18 @@ function buildInitialForm(initialValues?: Partial<CreateTicketPayload>): EditFor
|
||||
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) : "",
|
||||
currentAssigneeId: initialValues?.currentAssigneeId ?? 0,
|
||||
tagIds: initialValues?.tagIds ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateTicketPayload {
|
||||
const currentAssigneeId = form.currentAssigneeId ?? 0
|
||||
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,
|
||||
currentAssigneeId,
|
||||
tagIds: form.tagIds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +126,8 @@ function flattenTagTree(nodes: TagTree[], depth = 0, parentPath = ""): FlatTagNo
|
||||
}
|
||||
|
||||
type TicketTagSelectorProps = {
|
||||
value?: string[]
|
||||
onChange: (value: string[]) => void
|
||||
value?: number[]
|
||||
onChange: (value: number[]) => void
|
||||
availableTags: TagTree[]
|
||||
}
|
||||
|
||||
@@ -163,11 +136,11 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
|
||||
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.filter((tag) => selectedTagIDs.has(tag.id)),
|
||||
[flatTags, selectedTagIDs],
|
||||
)
|
||||
|
||||
function handleToggle(tagID: string) {
|
||||
function handleToggle(tagID: number) {
|
||||
if (selectedTagIDs.has(tagID)) {
|
||||
onChange(selectedValues.filter((item) => item !== tagID))
|
||||
return
|
||||
@@ -183,8 +156,8 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
|
||||
<Button type="button" variant="outline" className="w-full justify-start" />
|
||||
}
|
||||
>
|
||||
<TagIcon className="size-4" />
|
||||
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
|
||||
<TagIcon className="size-4" />
|
||||
{selectedTags.length > 0 ? `已选择 ${selectedTags.length} 个标签` : "请选择工单标签"}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[320px] p-0">
|
||||
<Command>
|
||||
@@ -193,12 +166,12 @@ function TicketTagSelector({ value, onChange, availableTags }: TicketTagSelector
|
||||
<CommandEmpty>暂无可用标签</CommandEmpty>
|
||||
<CommandGroup heading="标签">
|
||||
{flatTags.map((tag) => {
|
||||
const checked = selectedTagIDs.has(String(tag.id))
|
||||
const checked = selectedTagIDs.has(tag.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
value={`${tag.id} ${tag.path} ${tag.remark}`}
|
||||
onSelect={() => handleToggle(String(tag.id))}
|
||||
onSelect={() => handleToggle(tag.id)}
|
||||
>
|
||||
<CheckIcon className={`mr-2 size-4 ${checked ? "opacity-100" : "opacity-0"}`} />
|
||||
<span className="truncate" style={{ paddingLeft: `${tag.depth * 12}px` }}>
|
||||
@@ -274,13 +247,11 @@ function TicketEditDialogBody({
|
||||
const formId = "ticket-edit-form"
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tags, setTags] = useState<TagTree[]>([])
|
||||
const [priorities, setPriorities] = useState<TicketPriorityConfig[]>([])
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const form = useForm<
|
||||
z.input<typeof ticketFormSchema>,
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof ticketFormSchema>
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
@@ -315,31 +286,16 @@ function TicketEditDialogBody({
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const [tagData, priorityData, teamData, agentData] = await Promise.all([
|
||||
const [tagData, 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(
|
||||
const agentOptions = [{ value: "0", label: "不指定处理人" }].concat(
|
||||
agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
@@ -426,9 +382,25 @@ function TicketEditDialogBody({
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>工单标签</FieldLabel>
|
||||
</div>
|
||||
<FieldLabel>处理人</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currentAssigneeId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={String(field.value ?? 0)}
|
||||
onChange={(value) => field.onChange(Number(value))}
|
||||
placeholder="请选择处理人"
|
||||
options={agentOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>工单标签</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -443,101 +415,6 @@ function TicketEditDialogBody({
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field data-invalid={!!errors.priority}>
|
||||
<FieldLabel>优先级</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择优先级"
|
||||
options={priorityOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.priority]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.severity}>
|
||||
<FieldLabel>严重度</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="severity"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择严重度"
|
||||
options={[
|
||||
{ value: "1", label: "轻微" },
|
||||
{ value: "2", label: "严重" },
|
||||
{ value: "3", label: "致命" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.severity]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>处理团队</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currentTeamId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择团队"
|
||||
options={teamOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>处理人</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currentAssigneeId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择处理人"
|
||||
options={agentOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={!!errors.dueAt}>
|
||||
<FieldLabel htmlFor="ticket-due-at">截止时间</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-due-at"
|
||||
type="datetime-local"
|
||||
aria-invalid={!!errors.dueAt}
|
||||
{...register("dueAt")}
|
||||
/>
|
||||
<FieldError errors={[errors.dueAt]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -19,15 +19,12 @@ import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/fie
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchAgentProfilesAll,
|
||||
fetchAgentTeamsAll,
|
||||
type AdminAgentProfile,
|
||||
type AdminAgentTeam,
|
||||
} from "@/lib/api/admin"
|
||||
import { assignTicket, batchAssignTickets } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
toUserId: z.string().trim().min(1, "请选择处理人"),
|
||||
toTeamId: z.string().trim(),
|
||||
reason: z.string().trim(),
|
||||
})
|
||||
|
||||
@@ -41,7 +38,6 @@ const resolver = zodResolver(schema as never) as Resolver<
|
||||
|
||||
const emptyForm: FormValues = {
|
||||
toUserId: "",
|
||||
toTeamId: "",
|
||||
reason: "",
|
||||
}
|
||||
|
||||
@@ -49,7 +45,6 @@ type TicketAssignDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
ticketIds?: number[]
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
@@ -59,7 +54,6 @@ export function TicketAssignDialog({
|
||||
open,
|
||||
ticketId,
|
||||
ticketIds,
|
||||
currentTeamId,
|
||||
currentAssigneeId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
@@ -71,7 +65,6 @@ export function TicketAssignDialog({
|
||||
key={ticketId ?? "ticket-assign"}
|
||||
ticketId={ticketId}
|
||||
ticketIds={ticketIds}
|
||||
currentTeamId={currentTeamId}
|
||||
currentAssigneeId={currentAssigneeId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSuccess={onSuccess}
|
||||
@@ -84,14 +77,12 @@ export function TicketAssignDialog({
|
||||
function TicketAssignDialogBody({
|
||||
ticketId,
|
||||
ticketIds,
|
||||
currentTeamId,
|
||||
currentAssigneeId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: Omit<TicketAssignDialogProps, "open">) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
|
||||
const form = useForm<
|
||||
@@ -114,16 +105,14 @@ function TicketAssignDialogBody({
|
||||
useEffect(() => {
|
||||
reset({
|
||||
toUserId: currentAssigneeId ? String(currentAssigneeId) : "",
|
||||
toTeamId: currentTeamId ? String(currentTeamId) : "",
|
||||
reason: "",
|
||||
})
|
||||
}, [currentAssigneeId, currentTeamId, reset, ticketId])
|
||||
}, [currentAssigneeId, reset, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([fetchAgentTeamsAll(), fetchAgentProfilesAll()])
|
||||
.then(([teamData, agentData]) => {
|
||||
setTeams(Array.isArray(teamData) ? teamData : [])
|
||||
fetchAgentProfilesAll()
|
||||
.then((agentData) => {
|
||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -144,7 +133,6 @@ function TicketAssignDialogBody({
|
||||
await batchAssignTickets({
|
||||
ticketIds: validTicketIds,
|
||||
toUserId: Number(values.toUserId),
|
||||
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
|
||||
reason: values.reason.trim() || undefined,
|
||||
})
|
||||
toast.success(`已批量指派 ${validTicketIds.length} 张工单`)
|
||||
@@ -152,7 +140,6 @@ function TicketAssignDialogBody({
|
||||
await assignTicket({
|
||||
ticketId: ticketId!,
|
||||
toUserId: Number(values.toUserId),
|
||||
toTeamId: values.toTeamId ? Number(values.toTeamId) : undefined,
|
||||
reason: values.reason.trim() || undefined,
|
||||
})
|
||||
toast.success("处理人已更新")
|
||||
@@ -173,29 +160,6 @@ function TicketAssignDialogBody({
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field>
|
||||
<FieldLabel>处理团队</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="toTeamId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={loading ? "加载中..." : "选择处理团队"}
|
||||
options={[
|
||||
{ value: "", label: "不指定团队" },
|
||||
...teams.map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.toUserId}>
|
||||
<FieldLabel>处理人</FieldLabel>
|
||||
<FieldContent>
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
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 { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
|
||||
import { addTicketCollaborator } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
userId: z.string().trim().min(1, "请选择协作人"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
type TicketCollaboratorDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketCollaboratorDialog({
|
||||
open,
|
||||
ticketId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketCollaboratorDialogProps) {
|
||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const userOptions = agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label: agent.displayName || agent.nickname || agent.username || `客服 #${agent.userId}`,
|
||||
}))
|
||||
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: { userId: "" },
|
||||
})
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({ userId: "" })
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setLoadingAgents(true)
|
||||
fetchAgentProfilesAll()
|
||||
.then((data) => {
|
||||
setAgents(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingAgents(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addTicketCollaborator({ ticketId, userId: Number(values.userId) })
|
||||
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.userId}>
|
||||
<FieldLabel>协作人</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="userId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={userOptions}
|
||||
placeholder={loadingAgents ? "加载中..." : "选择协作人"}
|
||||
searchPlaceholder="搜索客服"
|
||||
emptyText="暂无可选客服"
|
||||
disabled={isSubmitting || loadingAgents}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.userId]} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { getTicketPriorityMap } from "@/lib/ticket-priority"
|
||||
|
||||
const priorityClassNameMap: Record<number, string> = {
|
||||
0: "bg-slate-500/10 text-slate-700 border-slate-500/20",
|
||||
1: "bg-blue-500/10 text-blue-700 border-blue-500/20",
|
||||
2: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
||||
3: "bg-red-500/10 text-red-700 border-red-500/20",
|
||||
4: "bg-fuchsia-500/10 text-fuchsia-700 border-fuchsia-500/20",
|
||||
}
|
||||
|
||||
export function ticketPriorityLabel(priority: number, priorityName?: string) {
|
||||
return priorityName?.trim() || `P${priority}`
|
||||
}
|
||||
|
||||
export function TicketPriorityBadge({
|
||||
priority,
|
||||
priorityName,
|
||||
}: {
|
||||
priority: number
|
||||
priorityName?: string
|
||||
}) {
|
||||
const [priorityMap, setPriorityMap] = useState<Record<number, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setPriorityMap(await getTicketPriorityMap())
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={priorityClassNameMap[priority] ?? priorityClassNameMap[0]}
|
||||
>
|
||||
{ticketPriorityLabel(priority, priorityName || priorityMap[priority])}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } 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 { 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 { Textarea } from "@/components/ui/textarea"
|
||||
import { closeTicket, reopenTicket } from "@/lib/api/ticket"
|
||||
|
||||
const schema = z.object({
|
||||
reason: z.string().trim().min(1, "请输入原因"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const resolver = zodResolver(schema as never) as Resolver<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>
|
||||
|
||||
type TicketReasonDialogProps = {
|
||||
open: boolean
|
||||
mode: "close" | "reopen"
|
||||
ticketId: number | null
|
||||
defaultReason?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketReasonDialog({
|
||||
open,
|
||||
mode,
|
||||
ticketId,
|
||||
defaultReason,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketReasonDialogProps) {
|
||||
const form = useForm<
|
||||
z.input<typeof schema>,
|
||||
undefined,
|
||||
z.output<typeof schema>
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: { reason: "" },
|
||||
})
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
reset({ reason: defaultReason || "" })
|
||||
}, [defaultReason, reset, ticketId, open])
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (mode === "close") {
|
||||
await closeTicket({ ticketId, closeReason: values.reason })
|
||||
toast.success("工单已关闭")
|
||||
} else {
|
||||
await reopenTicket({ ticketId, reason: values.reason })
|
||||
toast.success("工单已重开")
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : mode === "close" ? "关闭工单失败" : "重开工单失败")
|
||||
}
|
||||
}
|
||||
|
||||
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>{mode === "close" ? "关闭工单" : "重开工单"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.reason}>
|
||||
<FieldLabel>{mode === "close" ? "关闭原因" : "重开原因"}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={mode === "close" ? "请输入关闭原因" : "请输入重开原因"}
|
||||
{...register("reason")}
|
||||
/>
|
||||
<FieldError errors={[errors.reason]} />
|
||||
</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 ? "提交中..." : mode === "close" ? "确认关闭" : "确认重开"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { MessageSquarePlusIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { fetchAgentProfilesAll, type AdminAgentProfile } from "@/lib/api/admin"
|
||||
import { addTicketInternalNote, replyTicket } from "@/lib/api/ticket"
|
||||
|
||||
type TicketReplyDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketReplyDialog({
|
||||
open,
|
||||
ticketId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketReplyDialogProps) {
|
||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public")
|
||||
const [replyContent, setReplyContent] = useState("")
|
||||
const [mentionUserId, setMentionUserId] = useState("")
|
||||
const [mentionedUsers, setMentionedUsers] = useState<AdminAgentProfile[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setReplyMode("public")
|
||||
setReplyContent("")
|
||||
setMentionUserId("")
|
||||
setMentionedUsers([])
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setLoadingAgents(true)
|
||||
fetchAgentProfilesAll()
|
||||
.then((data) => {
|
||||
setAgents(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingAgents(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const mentionOptions = useMemo(
|
||||
() =>
|
||||
agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
agent.displayName ||
|
||||
agent.nickname ||
|
||||
agent.username ||
|
||||
`客服 #${agent.userId}`,
|
||||
})),
|
||||
[agents],
|
||||
)
|
||||
|
||||
function handleAddMentionUser() {
|
||||
const userId = Number(mentionUserId)
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
const user = agents.find((item) => item.userId === userId)
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
setMentionedUsers((current) => {
|
||||
if (current.some((item) => item.userId === user.userId)) {
|
||||
return current
|
||||
}
|
||||
return [...current, user]
|
||||
})
|
||||
setMentionUserId("")
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!ticketId) {
|
||||
toast.error("工单不存在")
|
||||
return
|
||||
}
|
||||
if (!replyContent.trim()) {
|
||||
toast.error(replyMode === "public" ? "回复内容不能为空" : "备注内容不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (replyMode === "public") {
|
||||
await replyTicket({
|
||||
ticketId,
|
||||
contentType: "text",
|
||||
content: replyContent.trim(),
|
||||
})
|
||||
toast.success("已回复客户")
|
||||
} else {
|
||||
const payload =
|
||||
mentionedUsers.length > 0
|
||||
? JSON.stringify({
|
||||
mentionUserIds: mentionedUsers.map((item) => item.userId),
|
||||
})
|
||||
: undefined
|
||||
await addTicketInternalNote({
|
||||
ticketId,
|
||||
contentType: "text",
|
||||
content: replyContent.trim(),
|
||||
payload,
|
||||
})
|
||||
toast.success("已添加内部备注")
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "提交失败")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>回复与备注</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={replyMode === "public" ? "default" : "outline"}
|
||||
onClick={() => setReplyMode("public")}
|
||||
disabled={submitting}
|
||||
>
|
||||
回复客户
|
||||
</Button>
|
||||
<Button
|
||||
variant={replyMode === "internal" ? "default" : "outline"}
|
||||
onClick={() => setReplyMode("internal")}
|
||||
disabled={submitting}
|
||||
>
|
||||
内部备注
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={replyContent}
|
||||
placeholder={replyMode === "public" ? "输入给客户的回复内容" : "输入内部备注"}
|
||||
disabled={submitting}
|
||||
onChange={(event) => setReplyContent(event.target.value)}
|
||||
/>
|
||||
|
||||
{replyMode === "internal" ? (
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-sm font-medium">@提及协作人</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<OptionCombobox
|
||||
value={mentionUserId}
|
||||
options={mentionOptions}
|
||||
placeholder={loadingAgents ? "加载中..." : "选择要提及的客服"}
|
||||
searchPlaceholder="搜索客服"
|
||||
emptyText="暂无可选客服"
|
||||
disabled={submitting || loadingAgents}
|
||||
onChange={setMentionUserId}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting || loadingAgents}
|
||||
onClick={handleAddMentionUser}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{mentionedUsers.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mentionedUsers.map((user) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
type="button"
|
||||
className="rounded-full border px-3 py-1 text-xs"
|
||||
onClick={() =>
|
||||
setMentionedUsers((current) =>
|
||||
current.filter((item) => item.userId !== user.userId),
|
||||
)
|
||||
}
|
||||
>
|
||||
@
|
||||
{user.displayName ||
|
||||
user.nickname ||
|
||||
user.username ||
|
||||
`客服#${user.userId}`}{" "}
|
||||
×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">未添加提及对象</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" disabled={submitting} onClick={() => void handleSubmit()}>
|
||||
<MessageSquarePlusIcon className="size-4" />
|
||||
{submitting ? "提交中..." : replyMode === "public" ? "发送回复" : "保存备注"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { TicketItem } from "@/lib/api/ticket"
|
||||
|
||||
function isClosedStatus(status: string) {
|
||||
return status === "resolved" || status === "closed" || status === "cancelled"
|
||||
}
|
||||
|
||||
export function TicketSLABadge({ ticket }: { ticket: TicketItem }) {
|
||||
if (isClosedStatus(ticket.status)) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
||||
已结束
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (!ticket.resolveDeadlineAt) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
||||
未设置
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const deadline = new Date(ticket.resolveDeadlineAt.replace(" ", "T"))
|
||||
if (Number.isNaN(deadline.getTime())) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-muted text-muted-foreground">
|
||||
未设置
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const remainingMinutes = Math.floor((deadline.getTime() - Date.now()) / 60000)
|
||||
if (remainingMinutes < 0) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
|
||||
已超时
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (remainingMinutes <= 60) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/20 bg-red-500/10 text-red-700">
|
||||
1 小时内
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (remainingMinutes <= 240) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/20 bg-amber-500/10 text-amber-700">
|
||||
今日风险
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/20 bg-emerald-500/10 text-emerald-700">
|
||||
正常
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { TicketStatus } from "@/lib/api/ticket"
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
new: "新建",
|
||||
open: "处理中",
|
||||
pending_customer: "待客户反馈",
|
||||
pending_internal: "待内部处理",
|
||||
resolved: "已解决",
|
||||
closed: "已关闭",
|
||||
cancelled: "已取消",
|
||||
}
|
||||
|
||||
const statusClassNameMap: Record<string, string> = {
|
||||
new: "bg-sky-500/10 text-sky-700 border-sky-500/20",
|
||||
open: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
|
||||
pending_customer: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
||||
pending_internal: "bg-orange-500/10 text-orange-700 border-orange-500/20",
|
||||
resolved: "bg-lime-500/10 text-lime-700 border-lime-500/20",
|
||||
closed: "bg-muted text-muted-foreground border-border",
|
||||
cancelled: "bg-rose-500/10 text-rose-700 border-rose-500/20",
|
||||
}
|
||||
const statusMap = {
|
||||
pending: { label: "待处理", className: "border-amber-200 bg-amber-50 text-amber-700" },
|
||||
in_progress: { label: "处理中", className: "border-blue-200 bg-blue-50 text-blue-700" },
|
||||
done: { label: "已处理", className: "border-emerald-200 bg-emerald-50 text-emerald-700" },
|
||||
} as const
|
||||
|
||||
export function ticketStatusLabel(status: string) {
|
||||
return statusLabelMap[status] ?? status
|
||||
return statusMap[status as TicketStatus]?.label ?? status
|
||||
}
|
||||
|
||||
export function TicketStatusBadge({ status }: { status: string }) {
|
||||
const option = statusMap[status as TicketStatus]
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={statusClassNameMap[status] ?? statusClassNameMap.closed}>
|
||||
{ticketStatusLabel(status)}
|
||||
<Badge variant="outline" className={option?.className ?? "border-border bg-muted text-muted-foreground"}>
|
||||
{option?.label ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Settings2Icon } from "lucide-react"
|
||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
@@ -18,20 +15,16 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchTicketResolutionCodesAll,
|
||||
type TicketResolutionCode,
|
||||
} from "@/lib/api/ticket-config"
|
||||
import { batchChangeTicketStatus, changeTicketStatus } from "@/lib/api/ticket"
|
||||
import { changeTicketStatus, type TicketStatus } from "@/lib/api/ticket"
|
||||
|
||||
const ticketStatuses = [
|
||||
{ value: "pending", label: "待处理" },
|
||||
{ value: "in_progress", label: "处理中" },
|
||||
{ value: "done", label: "已处理" },
|
||||
] satisfies Array<{ value: TicketStatus; label: string }>
|
||||
|
||||
const schema = z.object({
|
||||
status: z.string().trim().min(1, "请选择状态"),
|
||||
pendingReason: z.string().trim(),
|
||||
closeReason: z.string().trim(),
|
||||
resolutionCode: z.string().trim(),
|
||||
resolutionSummary: z.string().trim(),
|
||||
reason: z.string().trim(),
|
||||
status: z.enum(["pending", "in_progress", "done"], { message: "请选择状态" }),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
@@ -66,56 +59,23 @@ export function TicketStatusDialog({
|
||||
>({
|
||||
resolver,
|
||||
defaultValues: {
|
||||
status: "",
|
||||
pendingReason: "",
|
||||
closeReason: "",
|
||||
resolutionCode: "",
|
||||
resolutionSummary: "",
|
||||
reason: "",
|
||||
status: isTicketStatus(currentStatus) ? currentStatus : "pending",
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form
|
||||
const [resolutionCodes, setResolutionCodes] = useState<TicketResolutionCode[]>([])
|
||||
|
||||
const targetStatus = watch("status")
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
status: currentStatus || "",
|
||||
pendingReason: "",
|
||||
closeReason: "",
|
||||
resolutionCode: "",
|
||||
resolutionSummary: "",
|
||||
reason: "",
|
||||
})
|
||||
}, [currentStatus, reset, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
reset({ status: isTicketStatus(currentStatus) ? currentStatus : "pending" })
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchTicketResolutionCodesAll()
|
||||
setResolutionCodes(Array.isArray(data) ? data : [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载解决码失败")
|
||||
}
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
const resolutionCodeOptions = resolutionCodes.map((item) => ({
|
||||
value: item.code,
|
||||
label: item.name,
|
||||
}))
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
const validTicketIds = (ticketIds ?? []).filter((item) => item > 0)
|
||||
@@ -125,25 +85,14 @@ export function TicketStatusDialog({
|
||||
}
|
||||
try {
|
||||
if (validTicketIds.length > 0) {
|
||||
await batchChangeTicketStatus({
|
||||
ticketIds: validTicketIds,
|
||||
status: values.status,
|
||||
pendingReason: values.pendingReason || undefined,
|
||||
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
|
||||
resolutionCode: values.resolutionCode || undefined,
|
||||
resolutionSummary: values.resolutionSummary || undefined,
|
||||
reason: values.reason || undefined,
|
||||
})
|
||||
await Promise.all(
|
||||
validTicketIds.map((id) => changeTicketStatus({ ticketId: id, status: values.status })),
|
||||
)
|
||||
toast.success(`已批量更新 ${validTicketIds.length} 张工单`)
|
||||
} else {
|
||||
await changeTicketStatus({
|
||||
ticketId: ticketId!,
|
||||
status: values.status,
|
||||
pendingReason: values.pendingReason || undefined,
|
||||
closeReason: values.status === "closed" ? values.closeReason || undefined : undefined,
|
||||
resolutionCode: values.resolutionCode || undefined,
|
||||
resolutionSummary: values.resolutionSummary || undefined,
|
||||
reason: values.reason || undefined,
|
||||
})
|
||||
toast.success("状态已更新")
|
||||
}
|
||||
@@ -155,7 +104,7 @@ export function TicketStatusDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{ticketIds?.length ? `批量变更状态(${ticketIds.length})` : "变更工单状态"}</DialogTitle>
|
||||
@@ -173,96 +122,13 @@ export function TicketStatusDialog({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择状态"
|
||||
options={[
|
||||
{ value: "new", label: "新建" },
|
||||
{ value: "open", label: "处理中" },
|
||||
{ value: "pending_customer", label: "待客户反馈" },
|
||||
{ value: "pending_internal", label: "待内部处理" },
|
||||
{ value: "resolved", label: "已解决" },
|
||||
{ value: "closed", label: "已关闭" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
]}
|
||||
options={ticketStatuses}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.status]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{(targetStatus === "pending_customer" ||
|
||||
targetStatus === "pending_internal") && (
|
||||
<Field data-invalid={!!errors.pendingReason}>
|
||||
<FieldLabel>挂起原因</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={3} placeholder="请输入待处理原因" {...register("pendingReason")} />
|
||||
<FieldError errors={[errors.pendingReason]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{targetStatus === "resolved" && (
|
||||
<>
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>解决编码</FieldLabel>
|
||||
</div>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="resolutionCode"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="请选择解决编码"
|
||||
options={resolutionCodeOptions}
|
||||
emptyText="暂无可选解决码"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{resolutionCodeOptions.length === 0 ? (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50/70 p-3 text-xs text-amber-900">
|
||||
当前没有可用解决码,解决结果无法标准化统计。
|
||||
<Link
|
||||
href="/dashboard/ticket-resolution-codes"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1 font-medium underline underline-offset-4"
|
||||
>
|
||||
前往配置解决码
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>解决说明</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={3} placeholder="请输入解决说明" {...register("resolutionSummary")} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{targetStatus === "closed" && (
|
||||
<Field>
|
||||
<FieldLabel>关闭原因</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={3} placeholder="请输入关闭原因" {...register("closeReason")} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>操作说明</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={targetStatus === "closed" ? "可补充本次批量关闭说明" : "填写本次状态变更说明"}
|
||||
{...register("reason")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -277,3 +143,7 @@ export function TicketStatusDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function isTicketStatus(status: string | undefined): status is TicketStatus {
|
||||
return status === "pending" || status === "in_progress" || status === "done"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+186
-1158
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { linkConversationToCustomer } from "@/lib/api/agent"
|
||||
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
|
||||
import { linkTicketToCustomer } from "@/lib/api/ticket"
|
||||
|
||||
export type CustomerLinkOrCreateDialogProps = {
|
||||
open: boolean
|
||||
@@ -88,10 +87,8 @@ export function CustomerLinkOrCreateDialog({
|
||||
customerId: customer.id,
|
||||
})
|
||||
} else if (ticketId) {
|
||||
await linkTicketToCustomer({
|
||||
ticketId,
|
||||
customerId: customer.id,
|
||||
})
|
||||
toast.error("轻量工单暂不支持在此关联客户")
|
||||
return
|
||||
}
|
||||
toast.success("已关联客户")
|
||||
onOpenChange(false)
|
||||
@@ -114,11 +111,7 @@ export function CustomerLinkOrCreateDialog({
|
||||
})
|
||||
toast.success("已创建客户并关联当前会话")
|
||||
} else if (ticketId) {
|
||||
await linkTicketToCustomer({
|
||||
ticketId,
|
||||
customerId: created.id,
|
||||
})
|
||||
toast.success("已创建客户并关联当前工单")
|
||||
toast.success("已创建客户")
|
||||
} else {
|
||||
toast.success("已创建客户")
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type TicketResolutionCode = {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
sortNo: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type TicketPriorityConfig = {
|
||||
id: number
|
||||
name: string
|
||||
sortNo: number
|
||||
firstResponseMinutes: number
|
||||
resolutionMinutes: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type CreateTicketResolutionCodePayload = {
|
||||
name: string
|
||||
code: string
|
||||
sortNo: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateTicketResolutionCodePayload = CreateTicketResolutionCodePayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
export type CreateTicketPriorityConfigPayload = {
|
||||
name: string
|
||||
firstResponseMinutes: number
|
||||
resolutionMinutes: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateTicketPriorityConfigPayload = CreateTicketPriorityConfigPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchTicketResolutionCodes(query?: Record<string, string | number | undefined>) {
|
||||
return request<PageResult<TicketResolutionCode>>(
|
||||
`/api/dashboard/ticket-resolution-code/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchTicketResolutionCodesAll() {
|
||||
return request<TicketResolutionCode[]>("/api/dashboard/ticket-resolution-code/list_all")
|
||||
}
|
||||
|
||||
export function createTicketResolutionCode(payload: CreateTicketResolutionCodePayload) {
|
||||
return request<TicketResolutionCode>("/api/dashboard/ticket-resolution-code/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicketResolutionCode(payload: UpdateTicketResolutionCodePayload) {
|
||||
return request<void>("/api/dashboard/ticket-resolution-code/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketResolutionCode(id: number) {
|
||||
return request<void>("/api/dashboard/ticket-resolution-code/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchTicketPriorityConfigs(query?: Record<string, string | number | undefined>) {
|
||||
return request<TicketPriorityConfig[]>(
|
||||
`/api/dashboard/ticket-priority-config/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchTicketPriorityConfigsAll() {
|
||||
return request<TicketPriorityConfig[]>("/api/dashboard/ticket-priority-config/list_all")
|
||||
}
|
||||
|
||||
export function createTicketPriorityConfig(payload: CreateTicketPriorityConfigPayload) {
|
||||
return request<TicketPriorityConfig>("/api/dashboard/ticket-priority-config/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicketPriorityConfig(payload: UpdateTicketPriorityConfigPayload) {
|
||||
return request<void>("/api/dashboard/ticket-priority-config/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicketPriorityConfigSort(ids: number[]) {
|
||||
return request<void>("/api/dashboard/ticket-priority-config/update_sort", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(ids),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketPriorityConfig(id: number) {
|
||||
return request<void>("/api/dashboard/ticket-priority-config/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
+49
-287
@@ -12,6 +12,9 @@ export type PageResult<T> = {
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type TicketStatus = "pending" | "in_progress" | "done"
|
||||
export type TicketSource = "manual" | "conversation"
|
||||
|
||||
export type TicketCustomer = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -28,41 +31,12 @@ export type TicketCustomer = {
|
||||
primaryEmail?: string
|
||||
}
|
||||
|
||||
export type TicketSLA = {
|
||||
slaType: string
|
||||
targetMinutes: number
|
||||
status: string
|
||||
startedAt?: string
|
||||
pausedAt?: string
|
||||
stoppedAt?: string
|
||||
breachedAt?: string
|
||||
elapsedMin: number
|
||||
}
|
||||
|
||||
export type TicketComment = {
|
||||
export type TicketProgress = {
|
||||
id: number
|
||||
ticketId: number
|
||||
commentType: string
|
||||
authorType: string
|
||||
content: string
|
||||
authorId: number
|
||||
authorName?: string
|
||||
contentType: string
|
||||
content: string
|
||||
payload?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type TicketEvent = {
|
||||
id: number
|
||||
ticketId: number
|
||||
eventType: string
|
||||
operatorType: string
|
||||
operatorId: number
|
||||
operatorName?: string
|
||||
oldValue?: string
|
||||
newValue?: string
|
||||
content?: string
|
||||
payload?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
@@ -71,100 +45,35 @@ export type TicketItem = {
|
||||
ticketNo: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
source: TicketSource
|
||||
channel: string
|
||||
customerId: number
|
||||
conversationId: number
|
||||
tags?: Tag[]
|
||||
type: string
|
||||
priority: number
|
||||
priorityName?: string
|
||||
severity: number
|
||||
status: string
|
||||
currentTeamId: number
|
||||
currentTeamName?: string
|
||||
status: TicketStatus
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
watchedByMe: boolean
|
||||
pendingReason?: string
|
||||
closeReason?: string
|
||||
resolutionCode?: string
|
||||
resolutionCodeName?: string
|
||||
resolutionSummary?: string
|
||||
firstResponseAt?: string
|
||||
resolvedAt?: string
|
||||
closedAt?: string
|
||||
dueAt?: string
|
||||
nextReplyDeadlineAt?: string
|
||||
resolveDeadlineAt?: string
|
||||
reopenedCount: number
|
||||
createdBy: number
|
||||
createdByName?: string
|
||||
handledAt?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
customer?: TicketCustomer
|
||||
sla?: TicketSLA[]
|
||||
}
|
||||
|
||||
export type TicketDetail = {
|
||||
ticket: TicketItem
|
||||
watchers?: Array<{
|
||||
id: number
|
||||
userId: number
|
||||
userName?: string
|
||||
}>
|
||||
collaborators?: TicketCollaborator[]
|
||||
comments?: TicketComment[]
|
||||
events?: TicketEvent[]
|
||||
relatedTickets?: TicketRelation[]
|
||||
}
|
||||
|
||||
export type TicketCollaborator = {
|
||||
id: number
|
||||
userId: number
|
||||
userName?: string
|
||||
teamName?: string
|
||||
}
|
||||
|
||||
export type TicketRelation = {
|
||||
id: number
|
||||
ticketId: number
|
||||
relatedTicketId: number
|
||||
relationType: string
|
||||
relatedTicketNo?: string
|
||||
relatedTicketTitle?: string
|
||||
relatedTicketStatus?: string
|
||||
currentTeamName?: string
|
||||
currentAssigneeName?: string
|
||||
updatedAt?: string
|
||||
progresses?: TicketProgress[]
|
||||
}
|
||||
|
||||
export type TicketSummary = {
|
||||
all: number
|
||||
pending: number
|
||||
inProgress: number
|
||||
done: number
|
||||
unassigned: number
|
||||
mine: number
|
||||
watching: number
|
||||
collaboration: number
|
||||
participating: number
|
||||
mentioned: number
|
||||
unassigned: number
|
||||
pendingCustomer: number
|
||||
pendingInternal: number
|
||||
overdue: number
|
||||
}
|
||||
|
||||
export type TicketRiskReason = {
|
||||
code: string
|
||||
title: string
|
||||
description: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type TicketRiskOverview = {
|
||||
overdue: number
|
||||
highRisk: number
|
||||
unassigned: number
|
||||
pendingInternal: number
|
||||
pendingCustomer: number
|
||||
riskWindowMins: number
|
||||
reasons?: TicketRiskReason[]
|
||||
stale: number
|
||||
}
|
||||
|
||||
export type TicketSavedView = {
|
||||
@@ -178,47 +87,26 @@ export type TicketListQuery = {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
priority?: number
|
||||
severity?: number
|
||||
status?: TicketStatus
|
||||
tagId?: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
customerId?: number
|
||||
conversationId?: number
|
||||
source?: string
|
||||
watching?: number
|
||||
collaboration?: number
|
||||
collaborating?: number
|
||||
mentioned?: number
|
||||
mine?: number
|
||||
unassigned?: number
|
||||
overdue?: number
|
||||
}
|
||||
|
||||
export type TicketRiskListQuery = {
|
||||
riskType: "overdue" | "high_risk" | "unassigned" | "pending_internal" | "pending_customer"
|
||||
currentTeamId?: number
|
||||
riskWindowMins?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
source?: TicketSource
|
||||
channel?: string
|
||||
mine?: number | boolean
|
||||
unassigned?: number | boolean
|
||||
}
|
||||
|
||||
export type CreateTicketPayload = {
|
||||
title: string
|
||||
description: string
|
||||
source?: string
|
||||
source?: TicketSource
|
||||
channel?: string
|
||||
customerId?: number
|
||||
conversationId?: number
|
||||
tagIds?: number[]
|
||||
type?: string
|
||||
priority: number
|
||||
severity: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
dueAt?: string
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CreateTicketFromConversationPayload = {
|
||||
@@ -226,12 +114,7 @@ export type CreateTicketFromConversationPayload = {
|
||||
title: string
|
||||
description: string
|
||||
tagIds?: number[]
|
||||
priority: number
|
||||
severity: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
syncToConversation: boolean
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UpdateTicketPayload = {
|
||||
@@ -239,16 +122,10 @@ export type UpdateTicketPayload = {
|
||||
title: string
|
||||
description: string
|
||||
tagIds?: number[]
|
||||
type?: string
|
||||
priority: number
|
||||
severity: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
dueAt?: string
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
function toQueryString(query?: Record<string, string | number | boolean | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
@@ -268,47 +145,14 @@ export function fetchTickets(query?: TicketListQuery) {
|
||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketSummary(query?: { staleHours?: number }) {
|
||||
return request<TicketSummary>(`/api/dashboard/ticket/summary${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketDetail(id: number) {
|
||||
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
|
||||
}
|
||||
|
||||
export function fetchTicketSummary() {
|
||||
return request<TicketSummary>("/api/dashboard/ticket/summary")
|
||||
}
|
||||
|
||||
export function fetchTicketViews() {
|
||||
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
|
||||
}
|
||||
|
||||
export function saveTicketView(payload: {
|
||||
id?: number
|
||||
name: string
|
||||
filters?: Record<string, unknown>
|
||||
}) {
|
||||
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketView(id: number) {
|
||||
return request<void>("/api/dashboard/ticket/delete_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchTicketRiskOverview(query?: {
|
||||
currentTeamId?: number
|
||||
riskWindowMins?: number
|
||||
}) {
|
||||
return request<TicketRiskOverview>(`/api/dashboard/ticket/risk_overview${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketRiskList(query: TicketRiskListQuery) {
|
||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/risk_list${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function createTicket(payload: CreateTicketPayload) {
|
||||
return request<TicketItem>("/api/dashboard/ticket/create", {
|
||||
method: "POST",
|
||||
@@ -330,20 +174,9 @@ export function updateTicket(payload: UpdateTicketPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
export function linkTicketToCustomer(payload: {
|
||||
ticketId: number
|
||||
customerId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/link_customer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function assignTicket(payload: {
|
||||
ticketId: number
|
||||
toUserId: number
|
||||
toTeamId?: number
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/assign", {
|
||||
@@ -355,7 +188,6 @@ export function assignTicket(payload: {
|
||||
export function batchAssignTickets(payload: {
|
||||
ticketIds: number[]
|
||||
toUserId: number
|
||||
toTeamId?: number
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/batch_assign", {
|
||||
@@ -366,12 +198,7 @@ export function batchAssignTickets(payload: {
|
||||
|
||||
export function changeTicketStatus(payload: {
|
||||
ticketId: number
|
||||
status: string
|
||||
pendingReason?: string
|
||||
closeReason?: string
|
||||
resolutionCode?: string
|
||||
resolutionSummary?: string
|
||||
reason?: string
|
||||
status: TicketStatus
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/change_status", {
|
||||
method: "POST",
|
||||
@@ -379,109 +206,44 @@ export function changeTicketStatus(payload: {
|
||||
})
|
||||
}
|
||||
|
||||
export function batchChangeTicketStatus(payload: {
|
||||
ticketIds: number[]
|
||||
status: string
|
||||
pendingReason?: string
|
||||
closeReason?: string
|
||||
resolutionCode?: string
|
||||
resolutionSummary?: string
|
||||
reason?: string
|
||||
export function fetchTicketProgresses(query: {
|
||||
ticketId: number
|
||||
page?: number
|
||||
limit?: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/batch_change_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return request<PageResult<TicketProgress>>(
|
||||
`/api/dashboard/ticket/progress/list${toQueryString(query)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function replyTicket(payload: {
|
||||
export function createTicketProgress(payload: {
|
||||
ticketId: number
|
||||
contentType?: string
|
||||
content: string
|
||||
payload?: string
|
||||
}) {
|
||||
return request<TicketComment>("/api/dashboard/ticket/reply", {
|
||||
return request<TicketProgress>("/api/dashboard/ticket/progress/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketInternalNote(payload: {
|
||||
ticketId: number
|
||||
contentType?: string
|
||||
content: string
|
||||
payload?: string
|
||||
export function fetchTicketViews() {
|
||||
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
|
||||
}
|
||||
|
||||
export function saveTicketView(payload: {
|
||||
id?: number
|
||||
name: string
|
||||
filters: Record<string, unknown>
|
||||
}) {
|
||||
return request<TicketComment>("/api/dashboard/ticket/internal_note", {
|
||||
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function closeTicket(payload: { ticketId: number; closeReason: string }) {
|
||||
return request<void>("/api/dashboard/ticket/close", {
|
||||
export function deleteTicketView(id: number) {
|
||||
return request<void>("/api/dashboard/ticket/delete_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function reopenTicket(payload: { ticketId: number; reason: string }) {
|
||||
return request<void>("/api/dashboard/ticket/reopen", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function watchTicket(ticketId: number) {
|
||||
return request<void>("/api/dashboard/ticket/watch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticketId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function unwatchTicket(ticketId: number) {
|
||||
return request<void>("/api/dashboard/ticket/unwatch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticketId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function batchWatchTickets(payload: { ticketIds: number[]; watched: boolean }) {
|
||||
return request<void>("/api/dashboard/ticket/batch_watch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketRelation(payload: {
|
||||
ticketId: number
|
||||
relatedTicketId?: number
|
||||
relatedTicketNo?: string
|
||||
relationType: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/add_relation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketRelation(payload: { ticketId: number; relationId: number }) {
|
||||
return request<void>("/api/dashboard/ticket/delete_relation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketCollaborator(payload: { ticketId: number; userId: number }) {
|
||||
return request<void>("/api/dashboard/ticket/add_collaborator", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketCollaborator(payload: { ticketId: number; collaboratorId: number }) {
|
||||
return request<void>("/api/dashboard/ticket/delete_collaborator", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
BrainCircuitIcon,
|
||||
Building2Icon,
|
||||
CalendarClockIcon,
|
||||
ChartColumnIncreasingIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquareMoreIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
TagsIcon,
|
||||
UserCogIcon,
|
||||
@@ -119,12 +117,6 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
icon: <BotMessageSquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
},
|
||||
{
|
||||
title: "SLA风险",
|
||||
url: "/dashboard/ticket-risk",
|
||||
icon: <ChartColumnIncreasingIcon />,
|
||||
requiredPermission: "ticket.view",
|
||||
},
|
||||
{
|
||||
title: "客户管理",
|
||||
url: "/dashboard/customers",
|
||||
@@ -154,18 +146,6 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
icon: <MessageSquareMoreIcon />,
|
||||
requiredPermission: "quickReply.view",
|
||||
},
|
||||
{
|
||||
title: "工单优先级",
|
||||
url: "/dashboard/ticket-priorities",
|
||||
icon: <Settings2Icon />,
|
||||
requiredPermission: "ticketPriorityConfig.view",
|
||||
},
|
||||
{
|
||||
title: "工单解决码",
|
||||
url: "/dashboard/ticket-resolution-codes",
|
||||
icon: <KeyRoundIcon />,
|
||||
requiredPermission: "ticketResolutionCode.view",
|
||||
},
|
||||
{
|
||||
title: "客服档案",
|
||||
url: "/dashboard/agents",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { fetchTicketPriorityConfigsAll } from "@/lib/api/ticket-config"
|
||||
|
||||
export type TicketPriorityOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export async function getTicketPriorityOptions() {
|
||||
const list = await fetchTicketPriorityConfigsAll()
|
||||
return (Array.isArray(list) ? list : []).map((item) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})) satisfies TicketPriorityOption[]
|
||||
}
|
||||
|
||||
export async function getTicketPriorityMap() {
|
||||
const options = await getTicketPriorityOptions()
|
||||
return Object.fromEntries(options.map((item) => [Number(item.value), item.label])) as Record<number, string>
|
||||
}
|
||||
Reference in New Issue
Block a user