488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||
import {
|
||
createUser,
|
||
deleteUser,
|
||
getUserDetail,
|
||
getUserList,
|
||
updateUserBasicInfo,
|
||
} from '@/services/admin/user';
|
||
import { useSubscribe } from '@/store/subscribe';
|
||
import { formatDate } from '@/utils/common';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Button } from '@workspace/ui/components/button';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@workspace/ui/components/dropdown-menu';
|
||
import { Input } from '@workspace/ui/components/input';
|
||
import {
|
||
Popover,
|
||
PopoverClose,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from '@workspace/ui/components/popover';
|
||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetTrigger,
|
||
} from '@workspace/ui/components/sheet';
|
||
import { Switch } from '@workspace/ui/components/switch';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
|
||
import { FilePenLine } from 'lucide-react';
|
||
import { useTranslations } from 'next-intl';
|
||
import Link from 'next/link';
|
||
import { useSearchParams } from 'next/navigation';
|
||
import { useRef, useState } from 'react';
|
||
import { toast } from 'sonner';
|
||
import { UserDetail } from './user-detail';
|
||
import UserForm from './user-form';
|
||
import { BasicInfoForm } from './user-profile/basic-info-form';
|
||
import { NotifySettingsForm } from './user-profile/notify-settings-form';
|
||
import UserSubscription from './user-subscription';
|
||
|
||
function getDeviceTypeInfo(userAgent = '') {
|
||
let deviceType = 'Unknown';
|
||
const ua = userAgent.toLowerCase();
|
||
|
||
if (ua.includes('android')) {
|
||
deviceType = 'Android';
|
||
} else if (ua.includes('iphone') || ua.includes('ios')) {
|
||
deviceType = 'iPhone';
|
||
} else if (ua.includes('ipad')) {
|
||
deviceType = 'iPad';
|
||
} else if (ua.includes('mac os') || ua.includes('mac')) {
|
||
deviceType = 'Mac';
|
||
} else if (ua.includes('windows')) {
|
||
deviceType = 'Windows';
|
||
} else if (ua.includes('linux')) {
|
||
deviceType = 'Linux';
|
||
}
|
||
|
||
return { deviceType };
|
||
}
|
||
|
||
// 为 RemarkForm 组件定义 props 类型
|
||
interface RemarkFormProps {
|
||
initialRemark?: string | null;
|
||
onSave: (remark: string) => void;
|
||
CloseComponent: React.ComponentType<{ asChild?: boolean; children: React.ReactNode }>;
|
||
}
|
||
|
||
// 新的子组件,在管理它自己的备注状态
|
||
const RemarkForm: React.FC<RemarkFormProps> = ({ onSave, initialRemark, CloseComponent }) => {
|
||
const [remark, setRemark] = useState<string>(initialRemark ?? '');
|
||
|
||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
setRemark(event.target.value);
|
||
};
|
||
|
||
const handleSaveClick = () => {
|
||
onSave(remark);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className='mb-2 text-sm font-semibold'>备注</div>
|
||
<Input
|
||
type='text'
|
||
value={remark}
|
||
onChange={handleInputChange}
|
||
placeholder='在此输入备注...'
|
||
className='w-full'
|
||
/>
|
||
<CloseComponent asChild>
|
||
<Button onClick={handleSaveClick} variant='default' size={'sm'} className={'mt-2'}>
|
||
保存
|
||
</Button>
|
||
</CloseComponent>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default function Page() {
|
||
const t = useTranslations('user');
|
||
const [loading, setLoading] = useState(false);
|
||
const ref = useRef<ProTableActions>(null);
|
||
const sp = useSearchParams();
|
||
|
||
const { subscribes } = useSubscribe();
|
||
|
||
const initialFilters = {
|
||
search: sp.get('search') || undefined,
|
||
user_id: sp.get('user_id') || undefined,
|
||
subscribe_id: sp.get('subscribe_id') || undefined,
|
||
user_subscribe_id: sp.get('user_subscribe_id') || undefined,
|
||
device_id: sp.get('device_id') || undefined,
|
||
};
|
||
|
||
return (
|
||
<ProTable<API.User, API.GetUserListParams>
|
||
key={initialFilters.user_id}
|
||
action={ref}
|
||
initialFilters={initialFilters}
|
||
header={{
|
||
title: t('userList'),
|
||
toolbar: (
|
||
<UserForm<API.CreateUserRequest>
|
||
key='create'
|
||
trigger={t('create')}
|
||
title={t('createUser')}
|
||
loading={loading}
|
||
onSubmit={async (values) => {
|
||
setLoading(true);
|
||
try {
|
||
await createUser(values);
|
||
toast.success(t('createSuccess'));
|
||
ref.current?.refresh();
|
||
setLoading(false);
|
||
|
||
return true;
|
||
} catch (error) {
|
||
setLoading(false);
|
||
|
||
return false;
|
||
}
|
||
}}
|
||
/>
|
||
),
|
||
}}
|
||
columns={[
|
||
{
|
||
accessorKey: 'enable',
|
||
header: t('enable'),
|
||
cell: ({ row }) => {
|
||
return (
|
||
<Switch
|
||
defaultChecked={row.getValue('enable')}
|
||
onCheckedChange={async (checked) => {
|
||
const {
|
||
auth_methods,
|
||
user_devices,
|
||
enable_balance_notify,
|
||
enable_login_notify,
|
||
enable_subscribe_notify,
|
||
enable_trade_notify,
|
||
updated_at,
|
||
created_at,
|
||
id,
|
||
...rest
|
||
} = row.original;
|
||
await updateUserBasicInfo({
|
||
user_id: id,
|
||
...rest,
|
||
enable: checked,
|
||
} as unknown as API.UpdateUserBasiceInfoRequest);
|
||
toast.success(t('updateSuccess'));
|
||
ref.current?.refresh();
|
||
}}
|
||
/>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
accessorKey: 'id',
|
||
header: 'ID',
|
||
},
|
||
{
|
||
accessorKey: 'auth_methods',
|
||
header: '绑定邮箱',
|
||
cell: ({ row }) => {
|
||
const method = row.original.auth_methods;
|
||
return (
|
||
<div>
|
||
<Popover>
|
||
<PopoverTrigger>
|
||
<div className={'flex items-center'}>
|
||
{method?.find((v) => v.auth_type === 'email')?.auth_identifier || '待绑定'}
|
||
{row.original?.remark ? `(${row.original.remark})` : ''}
|
||
<FilePenLine size={14} className={'text-primary ml-2'} />
|
||
</div>
|
||
</PopoverTrigger>
|
||
<PopoverContent className={'w-64'}>
|
||
<RemarkForm
|
||
initialRemark={row.original.remark}
|
||
CloseComponent={PopoverClose}
|
||
onSave={async (remark) => {
|
||
const {
|
||
auth_methods,
|
||
user_devices,
|
||
enable_balance_notify,
|
||
enable_login_notify,
|
||
enable_subscribe_notify,
|
||
enable_trade_notify,
|
||
updated_at,
|
||
created_at,
|
||
id,
|
||
...rest
|
||
} = row.original;
|
||
await updateUserBasicInfo({
|
||
user_id: id,
|
||
...rest,
|
||
remark,
|
||
} as unknown as API.UpdateUserBasiceInfoRequest);
|
||
toast.success(t('updateSuccess'));
|
||
ref.current?.refresh();
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
accessorKey: 'user_devices',
|
||
header: '绑定设备',
|
||
cell: ({ row }) => {
|
||
const devices = row?.original.user_devices ?? [];
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{devices.map((v, index) => {
|
||
const { deviceType } = getDeviceTypeInfo(v.user_agent);
|
||
|
||
return (
|
||
<div key={v.id + '_wrapper'}>
|
||
<div
|
||
style={{
|
||
padding: '4px 6px',
|
||
background: '#f8f8f8',
|
||
borderRadius: '4px',
|
||
border: '1px solid #e0e0e0',
|
||
fontSize: '12px',
|
||
lineHeight: '16px',
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 500 }}>
|
||
ID:{v.id}({deviceType})
|
||
</div>
|
||
</div>
|
||
|
||
{index !== devices.length - 1 && (
|
||
<div
|
||
style={{
|
||
height: '1px',
|
||
background: '#eee',
|
||
margin: '4px 0',
|
||
}}
|
||
></div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
/*{
|
||
accessorKey: 'balance',
|
||
header: t('balance'),
|
||
cell: ({ row }) => <Display type='currency' value={row.getValue('balance')} />,
|
||
},
|
||
{
|
||
accessorKey: 'gift_amount',
|
||
header: t('giftAmount'),
|
||
cell: ({ row }) => <Display type='currency' value={row.getValue('gift_amount')} />,
|
||
},
|
||
{
|
||
accessorKey: 'commission',
|
||
header: t('commission'),
|
||
cell: ({ row }) => <Display type='currency' value={row.getValue('commission')} />,
|
||
},*/
|
||
{
|
||
accessorKey: 'refer_code',
|
||
header: t('inviteCode'),
|
||
cell: ({ row }) => row.getValue('refer_code') || '--',
|
||
},
|
||
{
|
||
accessorKey: 'last_login_time',
|
||
header: '最后登录时间',
|
||
cell: ({ row }) => {
|
||
const v = (row.original as any)?.last_login_time;
|
||
if (!v) return '---';
|
||
const ts = Number(v);
|
||
const ms = ts < 1e12 ? ts * 1000 : ts;
|
||
return formatDate(ms) as any;
|
||
},
|
||
},
|
||
{
|
||
accessorKey: 'member_status',
|
||
header: '会员状态',
|
||
cell: ({ row }) => {
|
||
const v = (row.original as any)?.member_status;
|
||
return <span className='text-sm'>{v ?? '---'}</span>;
|
||
},
|
||
},
|
||
{
|
||
accessorKey: 'referer_id',
|
||
header: t('referer'),
|
||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||
},
|
||
{
|
||
accessorKey: 'created_at',
|
||
header: t('createdAt'),
|
||
cell: ({ row }) => formatDate(row.getValue('created_at')),
|
||
},
|
||
]}
|
||
request={async (pagination, filter) => {
|
||
const { data } = await getUserList({
|
||
...pagination,
|
||
...filter,
|
||
});
|
||
return {
|
||
list: data.data?.list || [],
|
||
total: data.data?.total || 0,
|
||
};
|
||
}}
|
||
params={[
|
||
{
|
||
key: 'subscribe_id',
|
||
placeholder: t('subscription'),
|
||
options: subscribes?.map((item) => ({
|
||
label: item.name!,
|
||
value: String(item.id!),
|
||
})),
|
||
},
|
||
{
|
||
key: 'search',
|
||
placeholder: 'Search',
|
||
},
|
||
{
|
||
key: 'user_id',
|
||
placeholder: t('userId'),
|
||
},
|
||
{
|
||
key: 'user_subscribe_id',
|
||
placeholder: t('subscriptionId'),
|
||
},
|
||
{
|
||
key: 'device_id',
|
||
placeholder: '设备id',
|
||
},
|
||
]}
|
||
actions={{
|
||
render: (row) => {
|
||
return [
|
||
<ProfileSheet key='profile' userId={row.id} />,
|
||
<SubscriptionSheet key='subscription' userId={row.id} />,
|
||
<ConfirmButton
|
||
key='edit'
|
||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||
title={t('confirmDelete')}
|
||
description={t('deleteDescription')}
|
||
onConfirm={async () => {
|
||
await deleteUser({ id: row.id });
|
||
toast.success(t('deleteSuccess'));
|
||
ref.current?.refresh();
|
||
}}
|
||
cancelText={t('cancel')}
|
||
confirmText={t('confirm')}
|
||
/>,
|
||
<DropdownMenu key='more'>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant='outline'>{t('more')}</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align='end'>
|
||
<DropdownMenuItem asChild>
|
||
<Link href={`/dashboard/order?user_id=${row.id}`}>{t('orderList')}</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link href={`/dashboard/log/login?user_id=${row.id}`}>{t('loginLogs')}</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link href={`/dashboard/log/balance?user_id=${row.id}`}>{t('balanceLogs')}</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link href={`/dashboard/log/commission?user_id=${row.id}`}>
|
||
{t('commissionLogs')}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link href={`/dashboard/log/gift?user_id=${row.id}`}>{t('giftLogs')}</Link>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>,
|
||
];
|
||
},
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ProfileSheet({ userId }: { userId: number }) {
|
||
const t = useTranslations('user');
|
||
const [open, setOpen] = useState(false);
|
||
const { data: user, refetch } = useQuery({
|
||
enabled: open,
|
||
queryKey: ['user', userId],
|
||
queryFn: async () => {
|
||
const { data } = await getUserDetail({ id: userId });
|
||
return data.data as API.User;
|
||
},
|
||
});
|
||
return (
|
||
<Sheet open={open} onOpenChange={setOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant='default'>{t('edit')}</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side='right' className='w-[700px] max-w-full md:max-w-screen-lg'>
|
||
<SheetHeader>
|
||
<SheetTitle>
|
||
{t('userProfile')} · ID: {userId}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
{user && (
|
||
<ScrollArea className='h-[calc(100dvh-140px)] p-2'>
|
||
<Tabs defaultValue='basic'>
|
||
<TabsList className='mb-3'>
|
||
<TabsTrigger value='basic'>{t('basicInfoTitle')}</TabsTrigger>
|
||
<TabsTrigger value='notify'>{t('notifySettingsTitle')}</TabsTrigger>
|
||
{/*<TabsTrigger value='auth'>{t('authMethodsTitle')}</TabsTrigger>*/}
|
||
</TabsList>
|
||
<TabsContent value='basic' className='mt-0'>
|
||
<BasicInfoForm user={user} refetch={refetch as any} />
|
||
</TabsContent>
|
||
<TabsContent value='notify' className='mt-0'>
|
||
<NotifySettingsForm user={user} refetch={refetch as any} />
|
||
</TabsContent>
|
||
{/*<TabsContent value='auth' className='mt-0'>
|
||
<AuthMethodsForm user={user} refetch={refetch as any} />
|
||
</TabsContent>*/}
|
||
</Tabs>
|
||
</ScrollArea>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function SubscriptionSheet({ userId }: { userId: number }) {
|
||
const t = useTranslations('user');
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<Sheet open={open} onOpenChange={setOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant='secondary'>订阅</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side='right' className='w-[1000px] max-w-full md:max-w-screen-xl'>
|
||
<SheetHeader>
|
||
<SheetTitle>
|
||
{t('subscriptionList')} · ID: {userId}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
<div className='mt-2'>
|
||
<UserSubscription userId={userId} />
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|