feat(web): end user list support page

This commit is contained in:
zhaoying
2026-03-31 12:26:17 +08:00
parent db8b3416a6
commit 02660c7c97
4 changed files with 176 additions and 105 deletions

View File

@@ -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 = () => {

View 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;

View File

@@ -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>

View File

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