shanshanzhong 56a955ae81
All checks were successful
CI / build (20.15.1) (push) Successful in 14m4s
feat: 1
2026-01-05 03:04:09 -08:00

488 lines
16 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.

'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>
);
}