mirror of
https://gitee.com/wanwujie/sub2api-mobile
synced 2026-04-21 23:34:45 +08:00
feat: refine expo admin mobile flows
This commit is contained in:
@@ -1,264 +1,223 @@
|
||||
import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Copy, Search, UserRound } from 'lucide-react-native';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FlatList, Pressable, RefreshControl, Text, TextInput, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { ListCard } from '@/src/components/list-card';
|
||||
import { ScreenShell } from '@/src/components/screen-shell';
|
||||
import { useDebouncedValue } from '@/src/hooks/use-debounced-value';
|
||||
import { useScreenInteractive } from '@/src/hooks/use-screen-interactive';
|
||||
import { getUser, getUserUsage, listUserApiKeys, listUsers } from '@/src/services/admin';
|
||||
import { queryClient } from '@/src/lib/query-client';
|
||||
import { getUser, listUserApiKeys, listUsers } from '@/src/services/admin';
|
||||
import { adminConfigState } from '@/src/store/admin-config';
|
||||
import type { AdminApiKey, AdminUser, UserUsageSummary } from '@/src/types/admin';
|
||||
import type { AdminUser } from '@/src/types/admin';
|
||||
|
||||
const { useSnapshot } = require('valtio/react');
|
||||
|
||||
type UserSupplement = {
|
||||
usage?: UserUsageSummary;
|
||||
apiKeys: AdminApiKey[];
|
||||
const colors = {
|
||||
page: '#f4efe4',
|
||||
card: '#fbf8f2',
|
||||
mutedCard: '#f1ece2',
|
||||
primary: '#1d5f55',
|
||||
text: '#16181a',
|
||||
subtext: '#6f665c',
|
||||
dangerBg: '#fbf1eb',
|
||||
danger: '#c25d35',
|
||||
accentBg: '#efe4cf',
|
||||
accentText: '#8c5a22',
|
||||
};
|
||||
|
||||
function getUserTitle(user: AdminUser) {
|
||||
return user.username?.trim() || user.email;
|
||||
}
|
||||
type SortOrder = 'desc' | 'asc';
|
||||
|
||||
function getUserSortValue(user: AdminUser) {
|
||||
const raw = user.updated_at || user.created_at || '';
|
||||
const value = raw ? new Date(raw).getTime() : 0;
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
function formatQuotaValue(value: number) {
|
||||
function formatBalance(value?: number) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '$0.00';
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatActivityTime(value?: string) {
|
||||
if (!value) return '时间未知';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '时间未知';
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function toTimeValue(value?: string | null) {
|
||||
if (!value) return 0;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
function getTimeValue(user: AdminUser) {
|
||||
return toTimeValue(user.last_used_at) || toTimeValue(user.updated_at) || toTimeValue(user.created_at) || user.id || 0;
|
||||
}
|
||||
|
||||
function getUserNameLabel(user: AdminUser) {
|
||||
if (user.username?.trim()) return user.username.trim();
|
||||
if (user.notes?.trim()) return user.notes.trim();
|
||||
return user.email.split('@')[0] || '未命名';
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message) {
|
||||
switch (error.message) {
|
||||
case 'BASE_URL_REQUIRED':
|
||||
return '请先到服务器页填写服务地址。';
|
||||
case 'ADMIN_API_KEY_REQUIRED':
|
||||
return '请先到服务器页填写 Admin Token。';
|
||||
default:
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
return '当前无法加载页面数据,请检查服务地址、Token 和网络。';
|
||||
}
|
||||
|
||||
function MetricTile({ title, value, tone = 'default' }: { title: string; value: string; tone?: 'default' | 'accent' }) {
|
||||
const backgroundColor = tone === 'accent' ? colors.accentBg : colors.mutedCard;
|
||||
const valueColor = tone === 'accent' ? colors.accentText : colors.text;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, minWidth: 0, backgroundColor, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 12 }}>
|
||||
<Text style={{ fontSize: 11, color: colors.subtext }}>{title}</Text>
|
||||
<Text numberOfLines={1} style={{ marginTop: 6, fontSize: tone === 'accent' ? 20 : 16, fontWeight: '800', color: valueColor }}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: AdminUser }) {
|
||||
const isAdmin = user.role?.trim().toLowerCase() === 'admin';
|
||||
const statusLabel = `${isAdmin ? 'admin · ' : ''}${user.status || 'active'}`;
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 18, padding: 14 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, fontWeight: '800', color: colors.text }}>{user.email}</Text>
|
||||
<Text style={{ marginTop: 4, fontSize: 12, color: colors.subtext }}>最近使用 {formatActivityTime(user.last_used_at || user.updated_at || user.created_at)}</Text>
|
||||
</View>
|
||||
<View style={{ alignSelf: 'flex-start', backgroundColor: user.status === 'inactive' ? '#cfc5b7' : colors.primary, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
|
||||
<Text style={{ fontSize: 10, fontWeight: '700', color: '#fff' }}>{statusLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||
<MetricTile title="金额" value={formatBalance(Number(user.balance ?? 0))} tone="accent" />
|
||||
<MetricTile title="名称" value={getUserNameLabel(user)} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersScreen() {
|
||||
useScreenInteractive('users_interactive');
|
||||
const config = useSnapshot(adminConfigState);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc');
|
||||
const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
|
||||
const keyword = useDebouncedValue(searchText.trim(), 300);
|
||||
const queryClient = useQueryClient();
|
||||
const hasAccount = Boolean(config.baseUrl.trim());
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
const debouncedSearchText = useDebouncedValue(searchText, 250);
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['users', keyword],
|
||||
queryFn: () => listUsers(keyword),
|
||||
queryKey: ['users', debouncedSearchText],
|
||||
queryFn: () => listUsers(debouncedSearchText),
|
||||
enabled: hasAccount,
|
||||
});
|
||||
|
||||
const items = usersQuery.data?.items ?? [];
|
||||
const userDetailQueries = useQueries({
|
||||
queries: items.map((user) => ({
|
||||
queryKey: ['user-list-supplement', user.id],
|
||||
queryFn: async () => {
|
||||
const [usage, apiKeysData] = await Promise.all([getUserUsage(user.id), listUserApiKeys(user.id)]);
|
||||
const users = useMemo(() => {
|
||||
const items = [...(usersQuery.data?.items ?? [])];
|
||||
items.sort((left, right) => {
|
||||
const value = getTimeValue(left) - getTimeValue(right);
|
||||
return sortOrder === 'desc' ? -value : value;
|
||||
});
|
||||
return items;
|
||||
}, [sortOrder, usersQuery.data?.items]);
|
||||
|
||||
return {
|
||||
usage,
|
||||
apiKeys: apiKeysData.items ?? [],
|
||||
} satisfies UserSupplement;
|
||||
},
|
||||
enabled: hasAccount,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const errorMessage = usersQuery.error instanceof Error ? usersQuery.error.message : '';
|
||||
const supplementsByUserId = useMemo(
|
||||
() =>
|
||||
items.reduce<Record<number, UserSupplement | undefined>>((result, user, index) => {
|
||||
result[user.id] = userDetailQueries[index]?.data;
|
||||
return result;
|
||||
}, {}),
|
||||
[items, userDetailQueries]
|
||||
);
|
||||
const sortedItems = useMemo(
|
||||
() =>
|
||||
[...items].sort((left, right) => {
|
||||
const delta = getUserSortValue(right) - getUserSortValue(left);
|
||||
return sortOrder === 'desc' ? delta : -delta;
|
||||
}),
|
||||
[items, sortOrder]
|
||||
);
|
||||
|
||||
async function copyKey(keyId: number, value: string) {
|
||||
await Clipboard.setStringAsync(value);
|
||||
setCopiedKeyId(keyId);
|
||||
setTimeout(() => setCopiedKeyId((current) => (current === keyId ? null : current)), 1600);
|
||||
}
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item: user }: { item: (typeof sortedItems)[number] }) => {
|
||||
const keyItems = (supplementsByUserId[user.id]?.apiKeys ?? []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
className="px-1"
|
||||
onPress={() => {
|
||||
void queryClient.prefetchQuery({ queryKey: ['user', user.id], queryFn: () => getUser(user.id) });
|
||||
void queryClient.prefetchQuery({ queryKey: ['user-usage', user.id], queryFn: () => getUserUsage(user.id) });
|
||||
void queryClient.prefetchQuery({ queryKey: ['user-api-keys', user.id], queryFn: () => listUserApiKeys(user.id) });
|
||||
router.push(`/users/${user.id}`);
|
||||
}}
|
||||
>
|
||||
<ListCard title={getUserTitle(user)} meta={user.email} badge={user.status || 'active'} icon={UserRound}>
|
||||
<View className="gap-2">
|
||||
<View className="rounded-[14px] bg-[#f7f2e9] px-3 py-2.5">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-[11px] text-[#6f665c]">余额</Text>
|
||||
<Text className="text-sm font-semibold text-[#16181a]">${Number(user.balance ?? 0).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="rounded-[14px] bg-[#f7f2e9] px-3 py-2">
|
||||
<View className="mb-1.5 flex-row items-center justify-between">
|
||||
<Text className="text-[11px] text-[#6f665c]">Keys</Text>
|
||||
<Text className="text-[10px] text-[#8a8072]">{keyItems.length} 个</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-0">
|
||||
{keyItems.map((apiKey, index) => (
|
||||
<View
|
||||
key={apiKey.id}
|
||||
style={{ paddingVertical: 4 }}
|
||||
>
|
||||
{(() => {
|
||||
const quota = Number(apiKey.quota ?? 0);
|
||||
const used = Number(apiKey.quota_used ?? 0);
|
||||
const isUnlimited = quota <= 0;
|
||||
const progressWidth = isUnlimited ? '16%' : (`${Math.max(Math.min((used / quota) * 100, 100), 6)}%` as `${number}%`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text numberOfLines={1} className="flex-1 text-[11px] font-semibold text-[#16181a]">
|
||||
{apiKey.name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-[10px] text-[#6f665c]">
|
||||
{isUnlimited ? `${formatQuotaValue(used)} / 无限` : `${formatQuotaValue(used)} / ${formatQuotaValue(quota)}`}
|
||||
</Text>
|
||||
<Pressable
|
||||
className="rounded-full bg-[#e7dfcf] p-1.5"
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
void copyKey(apiKey.id, apiKey.key);
|
||||
}}
|
||||
>
|
||||
<Copy color="#4e463e" size={11} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="h-1.5 overflow-hidden rounded-full bg-[#ddd2c0]" style={{ marginTop: 3 }}>
|
||||
<View
|
||||
className={
|
||||
isUnlimited
|
||||
? 'h-full rounded-full bg-[#7d7468]'
|
||||
: used / Math.max(quota, 1) >= 0.85
|
||||
? 'h-full rounded-full bg-[#c25d35]'
|
||||
: used / Math.max(quota, 1) >= 0.6
|
||||
? 'h-full rounded-full bg-[#d38b36]'
|
||||
: 'h-full rounded-full bg-[#1d5f55]'
|
||||
}
|
||||
style={{ width: progressWidth }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{copiedKeyId === apiKey.id ? <Text className="text-[10px] text-[#1d5f55]" style={{ paddingTop: 3 }}>已复制</Text> : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{keyItems.length === 0 ? (
|
||||
<View className="py-[10px]">
|
||||
<Text className="text-[11px] text-[#6f665c]">当前用户还没有可展示的 token 额度信息。</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ListCard>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
[copiedKeyId, queryClient, sortedItems, supplementsByUserId]
|
||||
);
|
||||
|
||||
const emptyState = useMemo(
|
||||
() => (
|
||||
<ListCard
|
||||
title={hasAccount ? '暂无匹配用户' : '未连接服务器'}
|
||||
meta={hasAccount ? errorMessage || '调整搜索词后再试。' : '请先前往服务器标签连接 Sub2API。'}
|
||||
icon={UserRound}
|
||||
/>
|
||||
),
|
||||
[errorMessage, hasAccount]
|
||||
);
|
||||
const errorMessage = getErrorMessage(usersQuery.error);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="用户管理"
|
||||
subtitle=""
|
||||
titleAside={<Text className="text-[11px] text-[#a2988a]">搜索结果 {sortedItems.length}</Text>}
|
||||
variant="minimal"
|
||||
scroll={false}
|
||||
bottomInsetClassName="pb-12"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<View className="rounded-[16px] bg-[#fbf8f2] px-2.5 py-2.5">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="flex-1 flex-row items-center rounded-[14px] bg-[#f1ece2] px-3 py-2.5">
|
||||
<Search color="#7d7468" size={18} />
|
||||
<TextInput
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
placeholder="搜索邮箱或用户名"
|
||||
placeholderTextColor="#9b9081"
|
||||
className="ml-3 flex-1 text-base text-[#16181a]"
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
className={sortOrder === 'desc' ? 'rounded-[14px] bg-[#1d5f55] px-3 py-2.5' : 'rounded-[14px] bg-[#e7dfcf] px-3 py-2.5'}
|
||||
onPress={() => setSortOrder('desc')}
|
||||
>
|
||||
<Text className={sortOrder === 'desc' ? 'text-[11px] font-semibold text-white' : 'text-[11px] font-semibold text-[#4e463e]'}>
|
||||
最新
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className={sortOrder === 'asc' ? 'rounded-[14px] bg-[#1d5f55] px-3 py-2.5' : 'rounded-[14px] bg-[#e7dfcf] px-3 py-2.5'}
|
||||
onPress={() => setSortOrder('asc')}
|
||||
>
|
||||
<Text className={sortOrder === 'asc' ? 'text-[11px] font-semibold text-white' : 'text-[11px] font-semibold text-[#4e463e]'}>
|
||||
最早
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.page }}>
|
||||
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 14 }}>
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
<Text style={{ fontSize: 28, fontWeight: '700', color: colors.text }}>用户</Text>
|
||||
<Text style={{ marginTop: 4, fontSize: 12, color: '#8a8072' }}>查看用户列表并进入详情页管理账号。</Text>
|
||||
</View>
|
||||
|
||||
<View className="mt-2.5 flex-1">
|
||||
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
||||
<View style={{ flex: 1, backgroundColor: colors.card, borderRadius: 16, padding: 10 }}>
|
||||
<TextInput
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
placeholder="搜索邮箱、用户名或备注"
|
||||
placeholderTextColor="#9b9081"
|
||||
style={{ backgroundColor: colors.mutedCard, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, fontSize: 15, color: colors.text }}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setSortOrder((value) => (value === 'desc' ? 'asc' : 'desc'))}
|
||||
style={{ backgroundColor: colors.card, borderRadius: 16, paddingHorizontal: 14, paddingVertical: 14, minWidth: 92, alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ fontSize: 11, color: colors.subtext }}>时间</Text>
|
||||
<Text style={{ marginTop: 4, fontSize: 13, fontWeight: '700', color: colors.text }}>{sortOrder === 'desc' ? '倒序' : '正序'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!hasAccount ? (
|
||||
<View style={{ marginTop: 10, backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>未连接服务器</Text>
|
||||
<Text style={{ marginTop: 8, fontSize: 14, lineHeight: 22, color: colors.subtext }}>请先到“服务器”页完成连接,再查看用户列表。</Text>
|
||||
<Pressable
|
||||
style={{ marginTop: 14, alignSelf: 'flex-start', backgroundColor: colors.primary, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 12 }}
|
||||
onPress={() => router.push('/settings')}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: 13, fontWeight: '700' }}>去配置服务器</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : usersQuery.isLoading ? (
|
||||
<View style={{ marginTop: 10, backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>正在加载用户</Text>
|
||||
<Text style={{ marginTop: 8, fontSize: 14, lineHeight: 22, color: colors.subtext }}>已连接服务器,正在拉取用户列表。</Text>
|
||||
</View>
|
||||
) : usersQuery.error ? (
|
||||
<View style={{ marginTop: 10, backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>加载失败</Text>
|
||||
<View style={{ marginTop: 12, borderRadius: 14, backgroundColor: colors.dangerBg, paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||
<Text style={{ color: colors.danger, fontSize: 14, lineHeight: 20 }}>{errorMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={sortedItems}
|
||||
renderItem={renderItem}
|
||||
style={{ marginTop: 10, flex: 1 }}
|
||||
data={users}
|
||||
keyExtractor={(item) => `${item.id}`}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={() => <View className="h-2" />}
|
||||
ListEmptyComponent={emptyState}
|
||||
ListFooterComponent={() => <View className="h-4" />}
|
||||
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
removeClippedSubviews
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={5}
|
||||
refreshControl={<RefreshControl refreshing={usersQuery.isRefetching} onRefresh={() => void usersQuery.refetch()} tintColor="#1d5f55" />}
|
||||
contentContainerStyle={{ paddingBottom: 8, gap: 12, flexGrow: users.length === 0 ? 1 : 0 }}
|
||||
ListEmptyComponent={
|
||||
<View style={{ backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>暂无用户</Text>
|
||||
<Text style={{ marginTop: 8, fontSize: 14, lineHeight: 22, color: colors.subtext }}>当前搜索条件下没有匹配结果,可以修改关键词后重试。</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
void queryClient.prefetchQuery({ queryKey: ['user', item.id], queryFn: () => getUser(item.id) });
|
||||
void queryClient.prefetchQuery({ queryKey: ['user-api-keys', item.id], queryFn: () => listUserApiKeys(item.id) });
|
||||
router.push(`/users/${item.id}`);
|
||||
}}
|
||||
>
|
||||
<UserCard user={item} />
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user