feat(web): end user list support page
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 14:00:06
|
* @Date: 2026-02-03 14:00:06
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-24 17:48:01
|
* @Last Modified time: 2026-03-31 12:25:53
|
||||||
*/
|
*/
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
import type { AxiosRequestConfig } from 'axios'
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
@@ -63,8 +63,8 @@ export const getDashboardData = () => {
|
|||||||
|
|
||||||
/****************** User Memory APIs *******************************/
|
/****************** User Memory APIs *******************************/
|
||||||
export const userMemoryListUrl = '/dashboard/end_users'
|
export const userMemoryListUrl = '/dashboard/end_users'
|
||||||
export const getUserMemoryList = () => {
|
export const getUserMemoryList = (query?: { keyword?: string }) => {
|
||||||
return request.get(userMemoryListUrl)
|
return request.get(userMemoryListUrl, query)
|
||||||
}
|
}
|
||||||
// User Memory - Total end users
|
// User Memory - Total end users
|
||||||
export const getTotalEndUsers = () => {
|
export const getTotalEndUsers = () => {
|
||||||
|
|||||||
106
web/src/components/DebounceSelect/index.tsx
Normal file
106
web/src/components/DebounceSelect/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useRef, useState, useCallback, useEffect, type FC } from 'react';
|
||||||
|
import { Select, Spin, Avatar } from 'antd';
|
||||||
|
import type { SelectProps, DefaultOptionType } from 'antd/es/select';
|
||||||
|
|
||||||
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
interface OptionType {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
items?: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
|
||||||
|
/** API endpoint URL — mutually exclusive with fetchOptions */
|
||||||
|
url?: string;
|
||||||
|
/** Extra query params merged with the search keyword */
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
/** Key used as option value */
|
||||||
|
valueKey?: string;
|
||||||
|
/** Key used as option label */
|
||||||
|
labelKey?: string;
|
||||||
|
/** Key name sent to the API for the search keyword */
|
||||||
|
searchKey?: string;
|
||||||
|
/** Custom fetch function — mutually exclusive with url */
|
||||||
|
fetchOptions?: (search: string | null) => Promise<DefaultOptionType[]>;
|
||||||
|
/** Transform raw API items before rendering */
|
||||||
|
format?: (items: OptionType[]) => OptionType[];
|
||||||
|
debounceTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebounceSelect: FC<DebounceSelectProps> = ({
|
||||||
|
url,
|
||||||
|
params = { page: 1, pagesize: 20 },
|
||||||
|
valueKey = 'value',
|
||||||
|
labelKey = 'label',
|
||||||
|
searchKey = 'search',
|
||||||
|
fetchOptions,
|
||||||
|
format,
|
||||||
|
debounceTimeout = 300,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [options, setOptions] = useState<DefaultOptionType[]>([]);
|
||||||
|
const fetchRef = useRef(0);
|
||||||
|
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Load initial options on mount
|
||||||
|
useEffect(() => {
|
||||||
|
debounceFetcher(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debounceFetcher = useCallback((keyword: string | null) => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
fetchRef.current += 1;
|
||||||
|
const fetchId = fetchRef.current;
|
||||||
|
setOptions([]);
|
||||||
|
setFetching(true);
|
||||||
|
|
||||||
|
const promise: Promise<DefaultOptionType[]> = fetchOptions
|
||||||
|
? fetchOptions(keyword)
|
||||||
|
: request
|
||||||
|
.get<ApiResponse<OptionType>>(url!, { ...params, [searchKey]: keyword })
|
||||||
|
.then((res) => {
|
||||||
|
const data: OptionType[] = Array.isArray(res) ? res : res?.items || [];
|
||||||
|
const formatted = format ? format(data) : data.map((item) => ({
|
||||||
|
label: item[labelKey],
|
||||||
|
value: item[valueKey],
|
||||||
|
avatar: item.avatar,
|
||||||
|
raw: item,
|
||||||
|
}));
|
||||||
|
return formatted;
|
||||||
|
});
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((newOptions) => {
|
||||||
|
if (fetchId !== fetchRef.current) return;
|
||||||
|
setOptions(newOptions);
|
||||||
|
setFetching(false);
|
||||||
|
})
|
||||||
|
.catch(() => setFetching(false));
|
||||||
|
}, debounceTimeout);
|
||||||
|
}, [url, params, searchKey, fetchOptions, format, valueKey, labelKey, debounceTimeout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
labelInValue
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={debounceFetcher}
|
||||||
|
notFoundContent={fetching ? <Spin size="small" /> : null}
|
||||||
|
{...props}
|
||||||
|
options={options}
|
||||||
|
optionRender={(option) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{option.data.avatar && <Avatar src={option.data.avatar} size="small" />}
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DebounceSelect;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 17:09:03
|
* @Date: 2026-02-03 17:09:03
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-26 15:00:15
|
* @Last Modified time: 2026-03-31 12:21:56
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Memory Conversation Page
|
* Memory Conversation Page
|
||||||
@@ -12,16 +12,18 @@
|
|||||||
|
|
||||||
import { type FC, type ReactNode, useState, useEffect } from 'react'
|
import { type FC, type ReactNode, useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Col, Row, App, Skeleton, Select, Segmented, Tooltip, Flex } from 'antd'
|
import { Col, Row, App, Skeleton, Segmented, Tooltip, Flex } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { AnyObject } from 'antd/es/_util/type';
|
import type { AnyObject } from 'antd/es/_util/type';
|
||||||
|
|
||||||
import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg'
|
import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg'
|
||||||
import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.png'
|
import AnalysisEmptyIcon from '@/assets/images/conversation/analysisEmpty.png'
|
||||||
import { readService, getUserMemoryList } from '@/api/memory'
|
import { readService, userMemoryListUrl } from '@/api/memory'
|
||||||
import Empty from '@/components/Empty'
|
import Empty from '@/components/Empty'
|
||||||
|
import DebounceSelect from '@/components/DebounceSelect'
|
||||||
import Markdown from '@/components/Markdown'
|
import Markdown from '@/components/Markdown'
|
||||||
import type { Data } from '@/views/UserMemory/types'
|
import type { Data } from '@/views/UserMemory/types'
|
||||||
|
import type { DefaultOptionType } from 'antd/es/select'
|
||||||
import Chat from '@/components/Chat'
|
import Chat from '@/components/Chat'
|
||||||
import type { ChatItem } from '@/components/Chat/types'
|
import type { ChatItem } from '@/components/Chat/types'
|
||||||
import RbCard from '@/components/RbCard/Card';
|
import RbCard from '@/components/RbCard/Card';
|
||||||
@@ -60,7 +62,7 @@ export interface TestParams {
|
|||||||
search_switch: string;
|
search_switch: string;
|
||||||
/** Conversation history */
|
/** Conversation history */
|
||||||
history: { role: string; content: string }[];
|
history: { role: string; content: string }[];
|
||||||
/** Enable web search */
|
/** Enable web keyword */
|
||||||
web_search?: boolean;
|
web_search?: boolean;
|
||||||
/** Enable memory function */
|
/** Enable memory function */
|
||||||
memory?: boolean;
|
memory?: boolean;
|
||||||
@@ -108,21 +110,10 @@ const MemoryConversation: FC = () => {
|
|||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [chatData, setChatData] = useState<ChatItem[]>([])
|
const [chatData, setChatData] = useState<ChatItem[]>([])
|
||||||
const [logs, setLogs] = useState<LogItem[]>([])
|
const [logs, setLogs] = useState<LogItem[]>([])
|
||||||
const [userList, setUserList] = useState<Data[]>([])
|
|
||||||
const [search_switch, setSearchSwitch] = useState('0')
|
const [search_switch, setSearchSwitch] = useState('0')
|
||||||
const [msg, setMsg] = useState<string>('')
|
const [msg, setMsg] = useState<string>('')
|
||||||
const [expandedLogs, setExpandedLogs] = useState<Record<number, boolean>>({})
|
const [expandedLogs, setExpandedLogs] = useState<Record<number, boolean>>({})
|
||||||
|
|
||||||
/** Load user list on mount */
|
|
||||||
useEffect(() => {
|
|
||||||
getUserMemoryList().then(res => {
|
|
||||||
setUserList((res as Data[] || []).map(item => ({
|
|
||||||
...item,
|
|
||||||
name: item.end_user?.other_name && item.end_user?.other_name !== '' ? item.end_user?.other_name : item.end_user?.id
|
|
||||||
})))
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** Handle message send */
|
/** Handle message send */
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if(!userId) {
|
if(!userId) {
|
||||||
@@ -149,7 +140,7 @@ const MemoryConversation: FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle search mode change */
|
/** Handle keyword mode change */
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
setSearchSwitch(value)
|
setSearchSwitch(value)
|
||||||
}
|
}
|
||||||
@@ -158,19 +149,21 @@ const MemoryConversation: FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Select
|
<DebounceSelect
|
||||||
options={userList.map(item => ({
|
url={userMemoryListUrl}
|
||||||
|
searchKey="keyword"
|
||||||
|
format={(items) => (items as Data[]).map(item => ({
|
||||||
|
...item,
|
||||||
|
'end_user.id': item.end_user?.id,
|
||||||
|
label: item.end_user?.other_name || item.end_user?.id,
|
||||||
value: item.end_user?.id,
|
value: item.end_user?.id,
|
||||||
label: item?.name,
|
|
||||||
}))}
|
}))}
|
||||||
filterOption={(inputValue, option) => option?.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1}
|
|
||||||
showSearch={true}
|
|
||||||
// filterOption={(inputValue, option) => option.label?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1}
|
|
||||||
placeholder={t('memoryConversation.searchPlaceholder')}
|
placeholder={t('memoryConversation.searchPlaceholder')}
|
||||||
style={{ width: '100%', marginBottom: '16px' }}
|
style={{ width: '100%', marginBottom: '16px' }}
|
||||||
onChange={setUserId}
|
onChange={(opt: DefaultOptionType) => setUserId(opt?.value as string)}
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
className="rb:bg-white rb:rounded-lg"
|
className="rb:bg-white rb:rounded-lg"
|
||||||
|
showSearch
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -2,51 +2,36 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 17:53:44
|
* @Date: 2026-02-03 17:53:44
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-26 14:58:48
|
* @Last Modified time: 2026-03-31 12:15:59
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* User Memory Page
|
* User Memory Page
|
||||||
* Displays list of end users with their memory statistics and configuration
|
* Displays list of end users with their memory statistics and configuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Row, Col, Form, Flex, Tooltip } from 'antd';
|
import { Row, Col, Form, Flex, Tooltip } from 'antd';
|
||||||
|
|
||||||
import type { Data } from './types'
|
import type { Data } from './types'
|
||||||
import { getUserMemoryList } from '@/api/memory';
|
import { userMemoryListUrl } from '@/api/memory';
|
||||||
import { useUser } from '@/store/user'
|
import { useUser } from '@/store/user'
|
||||||
import RbCard from '@/components/RbCard/Card'
|
import RbCard from '@/components/RbCard/Card'
|
||||||
import SearchInput from '@/components/SearchInput';
|
import SearchInput from '@/components/SearchInput';
|
||||||
import RbStatistic from '@/components/RbStatistic';
|
import RbStatistic from '@/components/RbStatistic';
|
||||||
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
|
||||||
|
|
||||||
export default function UserMemory() {
|
export default function UserMemory() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { storageType } = useUser()
|
const { storageType } = useUser()
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [data, setData] = useState<Data[]>([]);
|
|
||||||
|
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const search = Form.useWatch(['search'], form)
|
const keyword = Form.useWatch(['keyword'], form)
|
||||||
|
|
||||||
/** Fetch user memory list */
|
const scrollListRef = useRef<PageScrollListRef>(null)
|
||||||
useEffect(() => {
|
|
||||||
getData()
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/** Get data from API */
|
|
||||||
const getData = () => {
|
|
||||||
setLoading(true)
|
|
||||||
getUserMemoryList().then((res) => {
|
|
||||||
setData(res as Data[] || [])
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/** Navigate to user memory detail */
|
/** Navigate to user memory detail */
|
||||||
const handleViewDetail = (id: string | number) => {
|
const handleViewDetail = (id: string | number) => {
|
||||||
switch (storageType) {
|
switch (storageType) {
|
||||||
@@ -64,25 +49,12 @@ export default function UserMemory() {
|
|||||||
navigate(`/memory`)
|
navigate(`/memory`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filter data by search term */
|
|
||||||
const filterData = useMemo(() => {
|
|
||||||
if (search && search.trim() !== '') {
|
|
||||||
return data.filter((item) => {
|
|
||||||
const { end_user } = item as Data;
|
|
||||||
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
|
|
||||||
return name?.includes(search)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}, [search, data])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Form form={form}>
|
<Form form={form}>
|
||||||
<Row gutter={16} className="rb:mb-4">
|
<Row gutter={16} className="rb:mb-4">
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Form.Item name="search" noStyle>
|
<Form.Item name="keyword" noStyle>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
placeholder={t('userMemory.searchPlaceholder')}
|
placeholder={t('userMemory.searchPlaceholder')}
|
||||||
className="rb:w-full!"
|
className="rb:w-full!"
|
||||||
@@ -91,52 +63,52 @@ export default function UserMemory() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
<BodyWrapper loading={loading} empty={data.length === 0}>
|
|
||||||
<Row
|
|
||||||
gutter={[12, 12]}
|
|
||||||
className="rb:max-h-[calc(100%-48px)] rb:overflow-y-auto"
|
|
||||||
>
|
|
||||||
{filterData.map((item, index) => {
|
|
||||||
const { end_user, memory_num, memory_config } = item as Data;
|
|
||||||
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
|
|
||||||
return (
|
|
||||||
<Col key={index} span={8}>
|
|
||||||
<RbCard
|
|
||||||
title={<Flex gap={4}>
|
|
||||||
<div className="rb:size-6 rb:text-center rb:font-semibold rb:leading-6 rb:rounded-md rb:text-white rb:bg-[#155EEF]">{name[0]}</div>
|
|
||||||
|
|
||||||
<Tooltip title={name || '-'}><div className={`rb:flex-1 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap`}>{name || '-'}</div></Tooltip>
|
|
||||||
</Flex>}
|
<PageScrollList<Data, { keyword: string; }>
|
||||||
headerType="border"
|
ref={scrollListRef}
|
||||||
headerClassName="rb:h-[48px]! rb:mx-4!"
|
url={userMemoryListUrl}
|
||||||
bodyClassName="rb:py-3! rb:px-4!"
|
query={{ keyword }}
|
||||||
className="rb:cursor-pointer"
|
column={3}
|
||||||
onClick={() => handleViewDetail(end_user.id)}
|
renderItem={(item) => {
|
||||||
>
|
const { end_user, memory_num, memory_config } = item as Data;
|
||||||
<Row>
|
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
|
||||||
<Col span={12}>
|
return (
|
||||||
<RbStatistic title={t('userMemory.capacity')} value={memory_num?.total || 0} suffix={t('userMemory.memoryNum')} />
|
<RbCard
|
||||||
</Col>
|
key={item.end_user.id}
|
||||||
<Col span={12}>
|
title={<Flex gap={4}>
|
||||||
<RbStatistic title={t('userMemory.type')} value={t(`userMemory.${item.type || 'person'}`)} />
|
<div className="rb:size-6 rb:text-center rb:font-semibold rb:leading-6 rb:rounded-md rb:text-white rb:bg-[#155EEF]">{name[0]}</div>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<div className="rb:relative rb:z-2 rb:mt-3 rb:bg-[#F6F6F6] rb:rounded-lg rb:py-2 rb:px-3 rb:leading-5" onClick={handleViewMemoryConfig}>
|
<Tooltip title={name || '-'}><div className={`rb:flex-1 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap`}>{name || '-'}</div></Tooltip>
|
||||||
<Flex align="center" justify="space-between" className="rb:text-[#5B6167]">
|
</Flex>}
|
||||||
{t('userMemory.memory_config_name')}
|
headerType="border"
|
||||||
<div
|
headerClassName="rb:h-[48px]! rb:mx-4!"
|
||||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/arrow_right_dark.svg')]"
|
bodyClassName="rb:py-3! rb:px-4!"
|
||||||
></div>
|
className="rb:cursor-pointer"
|
||||||
</Flex>
|
onClick={() => handleViewDetail(end_user.id)}
|
||||||
<div className="rb:font-medium rb:text-[#212332] rb:mt-1">{memory_config?.memory_config_name || '-'}</div>
|
>
|
||||||
</div>
|
<Row>
|
||||||
</RbCard>
|
<Col span={12}>
|
||||||
</Col>
|
<RbStatistic title={t('userMemory.capacity')} value={memory_num?.total || 0} suffix={t('userMemory.memoryNum')} />
|
||||||
)
|
</Col>
|
||||||
})}
|
<Col span={12}>
|
||||||
</Row>
|
<RbStatistic title={t('userMemory.type')} value={t(`userMemory.${item.type || 'person'}`)} />
|
||||||
</BodyWrapper>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div className="rb:relative rb:z-2 rb:mt-3 rb:bg-[#F6F6F6] rb:rounded-lg rb:py-2 rb:px-3 rb:leading-5" onClick={handleViewMemoryConfig}>
|
||||||
|
<Flex align="center" justify="space-between" className="rb:text-[#5B6167]">
|
||||||
|
{t('userMemory.memory_config_name')}
|
||||||
|
<div
|
||||||
|
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/arrow_right_dark.svg')]"
|
||||||
|
></div>
|
||||||
|
</Flex>
|
||||||
|
<div className="rb:font-medium rb:text-[#212332] rb:mt-1">{memory_config?.memory_config_name || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</RbCard>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user