Files
hi-frontend/apps/admin/src/sections/ticket/index.tsx
T
sepakeloudest ce6ab4f05b
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
Issue Check Inactive / issue-check-inactive (push) Has been cancelled
增加工单关闭按钮
2026-05-10 12:58:03 +03:00

545 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@workspace/ui/components/drawer";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { Icon } from "@workspace/ui/composed/icon";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
createTicketFollow,
getTicket,
getTicketList,
updateTicketStatus,
} from "@workspace/ui/services/admin/ticket";
import {
getUserDetail,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
export default function Page() {
const { t } = useTranslation("ticket");
// i18n status declarations for extraction
// t("status.0", "Status")
// t("status.1", "Pending Follow-up")
// t("status.2", "Pending Reply")
// t("status.3", "Resolved")
// t("status.4", "Closed")
const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState("");
const [loadingId, setLoadingId] = useState<any>(null);
const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ["getTicket", ticketId],
queryFn: async () => {
const { data } = await getTicket({
id: ticketId,
});
return data.data as API.Ticket;
},
enabled: !!ticketId,
refetchInterval: 5000,
});
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.children[1]?.scrollTo({
top: scrollRef.current.children[1].scrollHeight,
behavior: "smooth",
});
}
}, 66);
}, [ticket?.follow?.length]);
const ref = useRef<ProTableActions>(null);
return (
<>
<ProTable<API.Ticket, { status: number }>
action={ref}
actions={{
render(row) {
if (row.status === 1) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description="您确定要取消此次打款吗?取消后系统将记录此操作并关闭工单。"
key="cancel-payout"
onConfirm={async () => {
setLoadingId(row.id);
try {
await updateTicketStatus({
id: row.id,
status: 3,
});
toast.success("已取消打款并关闭工单");
ref.current?.refresh();
} catch (e) {
console.error("Cancel payout failed", e);
toast.error("取消打款失败");
}
setLoadingId(null);
}}
title="确认取消打款?"
trigger={
<Button
className="mr-2"
disabled={loadingId === row.id}
variant="outline"
>
{loadingId === row.id && (
<Icon
className="mr-2 animate-spin"
icon="lucide:loader-2"
/>
)}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={
<div className="flex flex-col gap-2">
<p>
线
</p>
<p className="font-bold text-rose-500 text-sm">
USDT 1 USDT
</p>
<p className="font-bold text-rose-500 text-sm">
/ 5%
</p>
</div>
}
key="colse"
onConfirm={async () => {
setLoadingId(row.id);
try {
const titleStr = row.title || "";
const parts = titleStr.split("-");
if (parts.length > 1) {
const deductAmount = Number.parseFloat(
parts?.[1] ?? "0"
);
if (!Number.isNaN(deductAmount)) {
const res = await getUserDetail({ id: row.user_id });
const user = res?.data?.data;
if (user && typeof user.commission !== "undefined") {
const deductAmountCent = deductAmount * 100;
if (Number(user.commission) < deductAmountCent) {
toast.error("用户佣金余额不足,无法完成打款扣费");
setLoadingId(null);
return;
}
const newCommission =
Number(user.commission) - deductAmountCent;
await updateUserBasicInfo({
user_id: row.user_id,
commission: newCommission,
} as any);
}
}
}
} catch (e) {
console.error("Update commission failed", e);
}
await updateTicketStatus({
id: row.id,
status: 4,
});
toast.success(t("closeSuccess", "Closed successfully"));
ref.current?.refresh();
setLoadingId(null);
}}
title="确认已完成打款?"
trigger={
<Button
disabled={loadingId === row.id}
variant="destructive"
>
{loadingId === row.id && (
<Icon
className="mr-2 animate-spin"
icon="lucide:loader-2"
/>
)}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description="检查佣金是否扣除,如已经扣除才可进行关闭,此操作不扣除佣金仅关闭工单。"
key="cancel-payout-1"
onConfirm={async () => {
setLoadingId(row.id);
try {
await updateTicketStatus({
id: row.id,
status: 4,
});
toast.success("已关闭工单");
ref.current?.refresh();
} catch (e) {
console.error("Cancel payout failed", e);
toast.error("关闭工单失败");
}
setLoadingId(null);
}}
title="确认关闭订单?"
trigger={
<Button
className="mr-2"
disabled={loadingId === row.id}
variant="secondary"
>
{loadingId === row.id && (
<Icon
className="mr-2 animate-spin"
icon="lucide:loader-2"
/>
)}
</Button>
}
/>,
];
}
return [];
},
}}
columns={[
{
id: "withdrawal_type",
header: "提现类型",
cell: ({ row }) => {
const title = row.original.title || "";
return title.split("-")[0] || title;
},
},
{
id: "withdrawal_amount",
header: "提现金额",
cell: ({ row }) => {
const title = row.original.title || "";
const parts = title.split("-");
if (parts.length < 2) return "";
return (
<span className="font-bold text-rose-500">${parts[1]}</span>
);
},
},
{
id: "withdrawal_method",
header: "提现方式",
cell: ({ row }) => {
const desc = row.original.description || "";
return desc.split("-")[0] || "";
},
},
{
id: "withdrawal_content",
header: "提现内容",
cell: ({ row }) => {
const desc = row.original.description || "";
const parts = desc.split("-");
if (parts.length < 2) return "";
const content = parts[1];
if (content?.startsWith("data:image/")) {
return (
<img
alt="withdrawal"
className="h-10 w-10 cursor-zoom-in rounded border object-cover"
height={40} // 这里的数值根据你的 UI 设计调整
onClick={() => {
const win = window.open();
win?.document.write(`<img src="${content}" />`);
}}
src={content}
width={40}
/>
);
}
return <span className="font-semibold">{content}</span>;
},
},
{
accessorKey: "user_id",
header: t("user", "User"),
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
},
{
accessorKey: "status",
header: t("status.0", "Status"),
cell: ({ row }) => (
<span
className={cn(
"flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50",
{
"before:bg-rose-500 before:ring-rose-500":
row.original.status === 1,
"before:bg-yellow-500 before:ring-yellow-500":
row.original.status === 2,
"before:bg-green-500 before:ring-green-500":
row.original.status === 3,
"before:bg-zinc-500 before:ring-zinc-500":
row.original.status === 4,
}
)}
>
{t(`status.${row.original.status}`)}
</span>
),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) => formatDate(row.getValue("updated_at")),
},
]}
header={{
title: t("ticketList", "Ticket List"),
}}
params={[
{
key: "status",
placeholder: t("status.0", "Status"),
options: [
{
label: t("close", "Close"),
value: "4",
},
],
},
]}
request={async (pagination, filters) => {
const { data } = await getTicketList({
...pagination,
...filters,
});
const list = (data.data?.list || []) as API.Ticket[];
// Client-side ordering to improve triage efficiency:
// - Put "Pending Follow-up" (status=1) before "Pending Reply" (status=2)
// - Within each group, sort by updated_at desc
const statusPriority = (status: number) => {
if (status === 1) return 0;
if (status === 2) return 1;
return 2;
};
const toTime = (value: any) => {
const t = new Date(value).getTime();
return Number.isFinite(t) ? t : 0;
};
list.sort((a, b) => {
const pa = statusPriority(a.status);
const pb = statusPriority(b.status);
if (pa !== pb) return pa - pb;
return toTime(b.updated_at) - toTime(a.updated_at);
});
return {
list,
total: data.data?.total || 0,
};
}}
/>
<Drawer
onOpenChange={(open) => {
if (!open) setTicketId(null);
}}
open={!!ticketId}
>
<DrawerContent className="container mx-auto h-screen *:select-text">
<DrawerHeader className="border-b text-left">
<DrawerTitle>{ticket?.title}</DrawerTitle>
</DrawerHeader>
<ScrollArea className="h-full overflow-hidden" ref={scrollRef}>
<div className="flex h-full flex-col gap-4 p-4">
{/* 显示工单描述作为第一条用户消息 */}
{ticket?.description && (
<div className="flex items-center gap-4">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">
{formatDate(ticket.created_at)}
</p>
<p className="w-fit rounded-lg bg-accent p-2 font-medium">
{ticket.description}
</p>
</div>
</div>
)}
{/* 显示后续跟进消息 */}
{ticket?.follow?.map((item) => (
<div
className={cn("flex items-center gap-4", {
"flex-row-reverse": item.from === "System",
})}
key={item.id}
>
<div
className={cn("flex flex-col gap-1", {
"items-end": item.from === "System",
})}
>
<p className="text-muted-foreground text-sm">
{formatDate(item.created_at)}
</p>
<p
className={cn(
"w-fit rounded-lg bg-accent p-2 font-medium",
{
"bg-primary text-primary-foreground":
item.from === "System",
}
)}
>
{item.type === 1 && item.content}
{item.type === 2 && (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="attachment"
className="!size-auto object-cover"
height={300}
src={item.content!}
width={300}
/>
)}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
{ticket?.status !== 4 && (
<DrawerFooter>
<form
className="flex w-full flex-row items-center gap-2"
onSubmit={async (event) => {
event.preventDefault();
if (message) {
await createTicketFollow({
ticket_id: ticketId,
from: "System",
type: 1,
content: message,
});
refetchTicket();
setMessage("");
}
}}
>
<Button className="p-0" type="button" variant="outline">
<Label className="p-2" htmlFor="picture">
<Icon className="text-2xl" icon="uil:image-upload" />
</Label>
<Input
accept="image/*"
className="hidden"
id="picture"
onChange={(event) => {
const file = event.target.files?.[0];
if (file?.type.startsWith("image/")) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const maxWidth = 300;
const maxHeight = 300;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round(
(maxWidth / width) * height
);
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((maxHeight / height) * width);
height = maxHeight;
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob!);
reader.onloadend = async () => {
await createTicketFollow({
ticket_id: ticketId,
from: "System",
type: 2,
content: reader.result as string,
});
refetchTicket();
};
},
"image/webp",
0.8
);
};
};
}
}}
type="file"
/>
</Button>
<Input
onChange={(e) => setMessage(e.target.value)}
placeholder={t(
"inputPlaceholder",
"Please enter your question, we will reply as soon as possible."
)}
value={message}
/>
<Button disabled={!message} type="submit">
<Icon icon="uil:navigator" />
</Button>
</form>
</DrawerFooter>
)}
</DrawerContent>
</Drawer>
</>
);
}