421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import { Empty } from '@/components/empty';
|
||
import { ProList, ProListActions } from '@/components/pro-list';
|
||
import {
|
||
createUserTicket,
|
||
createUserTicketFollow,
|
||
getUserTicketDetails,
|
||
getUserTicketList,
|
||
updateUserTicketStatus,
|
||
} from '@/services/user/ticket';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||
import { Button } from '@workspace/airo-ui/components/button';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@workspace/airo-ui/components/card';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from '@workspace/airo-ui/components/dialog';
|
||
import {
|
||
Drawer,
|
||
DrawerContent,
|
||
DrawerDescription,
|
||
DrawerFooter,
|
||
DrawerHeader,
|
||
DrawerTitle,
|
||
} from '@workspace/airo-ui/components/drawer';
|
||
import { Input } from '@workspace/airo-ui/components/input';
|
||
import { Label } from '@workspace/airo-ui/components/label';
|
||
import { ScrollArea } from '@workspace/airo-ui/components/scroll-area';
|
||
import { Textarea } from '@workspace/airo-ui/components/textarea';
|
||
import { ConfirmButton } from '@workspace/airo-ui/custom-components/confirm-button';
|
||
import { Icon } from '@workspace/airo-ui/custom-components/icon';
|
||
import { cn } from '@workspace/airo-ui/lib/utils';
|
||
import { formatDate } from '@workspace/airo-ui/utils';
|
||
import { useTranslations } from 'next-intl';
|
||
import Image from 'next/image';
|
||
import NextImage from 'next/legacy/image';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { toast } from 'sonner';
|
||
|
||
import { PhotoProvider, PhotoView } from 'react-photo-view';
|
||
import 'react-photo-view/dist/react-photo-view.css';
|
||
|
||
export default function Page() {
|
||
const t = useTranslations('ticket');
|
||
|
||
const [ticketId, setTicketId] = useState<any>(null);
|
||
const [message, setMessage] = useState('');
|
||
|
||
const { data: ticket, refetch: refetchTicket } = useQuery({
|
||
queryKey: ['getUserTicketDetails', ticketId],
|
||
queryFn: async () => {
|
||
const { data } = await getUserTicketDetails({ 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<ProListActions>(null);
|
||
const [create, setCreate] = useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
|
||
|
||
return (
|
||
<>
|
||
<ProList<API.Ticket, { status: number }>
|
||
action={ref}
|
||
header={{
|
||
title: t('ticketList'),
|
||
toolbar: (
|
||
<Dialog open={create?.open} onOpenChange={(open) => setCreate({ open })}>
|
||
<DialogTrigger asChild>
|
||
<AiroButton className={'mr-3'}>{t('createTicket')}</AiroButton>
|
||
</DialogTrigger>
|
||
<DialogContent className='sm:max-w-[425px]'>
|
||
<DialogHeader>
|
||
<DialogTitle>{t('createTicket')}</DialogTitle>
|
||
<DialogDescription>{t('createTicketDescription')}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className='grid gap-4 py-4'>
|
||
<Label htmlFor='title'>{t('title')}</Label>
|
||
<Input
|
||
id='title'
|
||
defaultValue={create?.title}
|
||
onChange={(e) => setCreate({ ...create, title: e.target.value! })}
|
||
/>
|
||
<Label htmlFor='content'>{t('description')}</Label>
|
||
<Textarea
|
||
id='content'
|
||
defaultValue={create?.description}
|
||
onChange={(e) => setCreate({ ...create, description: e.target.value! })}
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
disabled={!create?.title || !create?.description}
|
||
onClick={async () => {
|
||
await createUserTicket({
|
||
title: create!.title!,
|
||
description: create!.description!,
|
||
});
|
||
ref.current?.refresh();
|
||
toast.success(t('createSuccess'));
|
||
setCreate({ open: false });
|
||
}}
|
||
>
|
||
{t('submit')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
),
|
||
}}
|
||
params={[
|
||
{
|
||
key: 'search',
|
||
},
|
||
{
|
||
key: 'status',
|
||
placeholder: t('status.0'),
|
||
options: [
|
||
{
|
||
label: t('close'),
|
||
value: '4',
|
||
},
|
||
],
|
||
},
|
||
]}
|
||
request={async (pagination, filters) => {
|
||
const { data } = await getUserTicketList({
|
||
...pagination,
|
||
...filters,
|
||
});
|
||
return {
|
||
list: data.data?.list || [],
|
||
total: data.data?.total || 0,
|
||
};
|
||
}}
|
||
renderItem={(item) => {
|
||
return (
|
||
<Card className='overflow-hidden rounded-[20px] pl-5 sm:rounded-[32px] sm:pl-16'>
|
||
<CardHeader className='flex flex-row items-center justify-between gap-2 space-y-0 bg-transparent p-3 pb-0 pl-0 sm:pb-3'>
|
||
<CardTitle>
|
||
<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-yellow-500 before:ring-yellow-500': item.status === 1,
|
||
'before:bg-rose-500 before:ring-rose-500': item.status === 2,
|
||
'before:bg-green-500 before:ring-green-500': item.status === 3,
|
||
'before:bg-zinc-500 before:ring-zinc-500': item.status === 4,
|
||
},
|
||
)}
|
||
>
|
||
{t(`status.${item.status}`)}
|
||
</span>
|
||
</CardTitle>
|
||
<CardDescription className='flex gap-2'>
|
||
{item.status !== 4 ? (
|
||
<>
|
||
{item.issue_type === 0 ? (
|
||
<AiroButton
|
||
variant={'primary'}
|
||
onClick={() => setTicketId(item.id)}
|
||
className={'hidden sm:flex'}
|
||
/>
|
||
) : null}
|
||
<ConfirmButton
|
||
key='close'
|
||
trigger={
|
||
<Button
|
||
variant='destructive'
|
||
className={
|
||
'h-8 rounded-full border-white bg-transparent px-6 text-center text-sm font-bold text-[#FF4248] shadow-none hover:bg-transparent sm:min-w-[100px] sm:border-[#FF4248] sm:bg-[#FF4248] sm:text-white sm:shadow sm:hover:border-[#E22C2E] sm:hover:bg-[#E22C2E]'
|
||
}
|
||
>
|
||
{t('close')}
|
||
</Button>
|
||
}
|
||
title={t('confirmClose')}
|
||
description={t('closeWarning')}
|
||
onConfirm={async () => {
|
||
await updateUserTicketStatus({ id: item.id, status: 4 });
|
||
toast.success(t('closeSuccess'));
|
||
ref.current?.refresh();
|
||
}}
|
||
cancelText={t('cancel')}
|
||
confirmText={t('confirm')}
|
||
/>
|
||
</>
|
||
) : (
|
||
<AiroButton
|
||
key='check'
|
||
variant={'primary'}
|
||
onClick={() => setTicketId(item.id)}
|
||
>
|
||
{t('check')}
|
||
</AiroButton>
|
||
)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
|
||
<CardContent className='p-3 pl-0 text-[10px] sm:text-sm'>
|
||
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-3'>
|
||
<li>
|
||
<span className='font-normal text-[#225BA9]'>{t('title')}</span>
|
||
<span className={'font-bold'}> {item.title}</span>
|
||
</li>
|
||
<li className='order-2 sm:order-3'>
|
||
<span className='font-normal text-[#225BA9]'>{t('description')}</span>
|
||
<time className={'font-bold'}>
|
||
{item?.description?.includes('data:image') ? (
|
||
<div>
|
||
<div>提现方式:{item?.description?.split('-')[0]}</div>
|
||
<PhotoProvider>
|
||
<PhotoView src={item?.description?.split('-')[1]}>
|
||
<Image
|
||
src={item?.description?.split('-')[1]}
|
||
height={48}
|
||
width={48}
|
||
className={'mx-1 cursor-pointer border'}
|
||
/>
|
||
</PhotoView>
|
||
</PhotoProvider>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<div>提现方式:{item?.description.split('-')[0]}</div>
|
||
<div>提现地址: {item?.description.split('-')[1]}</div>
|
||
</div>
|
||
)}
|
||
</time>
|
||
</li>
|
||
<li className=''>
|
||
<span className='font-normal text-[#225BA9]'>{t('updatedAt')}</span>
|
||
<time className={'font-bold'}>{formatDate(item.updated_at)}</time>
|
||
</li>
|
||
</ul>
|
||
</CardContent>
|
||
<CardFooter className={'flex justify-center sm:hidden'}>
|
||
<AiroButton key='reply' variant={'primary'} onClick={() => setTicketId(item.id)}>
|
||
{t('reply')}
|
||
</AiroButton>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}}
|
||
empty={<Empty />}
|
||
/>
|
||
<Drawer
|
||
open={!!ticketId}
|
||
onOpenChange={(open) => {
|
||
if (!open) setTicketId(null);
|
||
}}
|
||
>
|
||
<DrawerContent className='container mx-auto h-screen'>
|
||
<DrawerHeader className='border-b text-left'>
|
||
<DrawerTitle>{ticket?.title}</DrawerTitle>
|
||
<DrawerDescription className='line-clamp-3'>{ticket?.description}</DrawerDescription>
|
||
</DrawerHeader>
|
||
<ScrollArea className='h-full overflow-hidden' ref={scrollRef}>
|
||
<div className='flex flex-col gap-4 p-4'>
|
||
{ticket?.follow?.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className={cn('flex items-center gap-4', {
|
||
'flex-row-reverse': item.from !== 'System',
|
||
})}
|
||
>
|
||
<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('bg-accent w-fit rounded-lg p-2 font-medium', {
|
||
'bg-primary text-primary-foreground': item.from !== 'System',
|
||
})}
|
||
>
|
||
{item.type === 1 && item.content}
|
||
{item.type === 2 && (
|
||
<NextImage
|
||
src={item.content!}
|
||
width={300}
|
||
height={300}
|
||
className='!size-auto object-cover'
|
||
alt='image'
|
||
/>
|
||
)}
|
||
</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 createUserTicketFollow({
|
||
ticket_id: ticketId,
|
||
from: 'User',
|
||
type: 1,
|
||
content: message,
|
||
issue_type: 0,
|
||
});
|
||
refetchTicket();
|
||
setMessage('');
|
||
}
|
||
}}
|
||
>
|
||
<Button type='button' variant='outline' className='p-0'>
|
||
<Label htmlFor='picture' className='p-2'>
|
||
<Icon icon='uil:image-upload' className='text-2xl' />
|
||
</Label>
|
||
<Input
|
||
id='picture'
|
||
type='file'
|
||
className='hidden'
|
||
accept='image/*'
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (file && 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 createUserTicketFollow({
|
||
ticket_id: ticketId,
|
||
from: 'User',
|
||
type: 2,
|
||
content: reader.result as string,
|
||
});
|
||
refetchTicket();
|
||
};
|
||
},
|
||
'image/webp',
|
||
0.8,
|
||
);
|
||
};
|
||
};
|
||
}
|
||
}}
|
||
/>
|
||
</Button>
|
||
<Input
|
||
placeholder={t('inputPlaceholder')}
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
/>
|
||
<Button type='submit' disabled={!message}>
|
||
<Icon icon='uil:navigator' />
|
||
</Button>
|
||
</form>
|
||
</DrawerFooter>
|
||
)}
|
||
</DrawerContent>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
}
|