### What problem does this PR solve? fix: Optimized popups and the search page #3221 - Added a new PortalModal component - Refactored the Modal component, adding show and hide methods to support popups - Updated the search page, adding a new query function and optimizing the search card style - Localized, added search-related translations ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue)tags/v0.20.1
| import { ReactNode, useEffect, useState } from 'react'; | |||||
| import { createPortal } from 'react-dom'; | |||||
| import { createRoot } from 'react-dom/client'; | |||||
| import { Modal, ModalProps } from './modal'; | |||||
| type PortalModalProps = Omit<ModalProps, 'open' | 'onOpenChange'> & { | |||||
| visible: boolean; | |||||
| onVisibleChange: (visible: boolean) => void; | |||||
| container?: HTMLElement; | |||||
| children: ReactNode; | |||||
| [key: string]: any; | |||||
| }; | |||||
| const PortalModal = ({ | |||||
| visible, | |||||
| onVisibleChange, | |||||
| container, | |||||
| children, | |||||
| ...restProps | |||||
| }: PortalModalProps) => { | |||||
| const [mounted, setMounted] = useState(false); | |||||
| useEffect(() => { | |||||
| setMounted(true); | |||||
| return () => setMounted(false); | |||||
| }, []); | |||||
| if (!mounted || !visible) return null; | |||||
| console.log('PortalModal:', visible); | |||||
| return createPortal( | |||||
| <Modal open={visible} onOpenChange={onVisibleChange} {...restProps}> | |||||
| {children} | |||||
| </Modal>, | |||||
| container || document.body, | |||||
| ); | |||||
| }; | |||||
| export const createPortalModal = () => { | |||||
| let container = document.createElement('div'); | |||||
| document.body.appendChild(container); | |||||
| let currentProps: any = {}; | |||||
| let isVisible = false; | |||||
| let root: ReturnType<typeof createRoot> | null = null; | |||||
| root = createRoot(container); | |||||
| const destroy = () => { | |||||
| if (root && container) { | |||||
| root.unmount(); | |||||
| if (container.parentNode) { | |||||
| container.parentNode.removeChild(container); | |||||
| } | |||||
| root = null; | |||||
| } | |||||
| isVisible = false; | |||||
| currentProps = {}; | |||||
| }; | |||||
| const render = () => { | |||||
| const { onVisibleChange, ...props } = currentProps; | |||||
| const modalParam = { | |||||
| visible: isVisible, | |||||
| onVisibleChange: (visible: boolean) => { | |||||
| isVisible = visible; | |||||
| if (onVisibleChange) { | |||||
| onVisibleChange(visible); | |||||
| } | |||||
| if (!visible) { | |||||
| render(); | |||||
| } | |||||
| }, | |||||
| ...props, | |||||
| }; | |||||
| root?.render(isVisible ? <PortalModal {...modalParam} /> : null); | |||||
| }; | |||||
| const show = (props: PortalModalProps) => { | |||||
| if (!container) { | |||||
| container = document.createElement('div'); | |||||
| document.body.appendChild(container); | |||||
| } | |||||
| if (!root) { | |||||
| root = createRoot(container); | |||||
| } | |||||
| currentProps = { ...currentProps, ...props }; | |||||
| isVisible = true; | |||||
| render(); | |||||
| }; | |||||
| const hide = () => { | |||||
| isVisible = false; | |||||
| render(); | |||||
| }; | |||||
| const update = (props = {}) => { | |||||
| currentProps = { ...currentProps, ...props }; | |||||
| render(); | |||||
| }; | |||||
| return { show, hide, update, destroy }; | |||||
| }; |
| // src/components/ui/modal.tsx | // src/components/ui/modal.tsx | ||||
| import { cn } from '@/lib/utils'; | |||||
| import * as DialogPrimitive from '@radix-ui/react-dialog'; | import * as DialogPrimitive from '@radix-ui/react-dialog'; | ||||
| import { Loader, X } from 'lucide-react'; | import { Loader, X } from 'lucide-react'; | ||||
| import { FC, ReactNode, useCallback, useEffect, useMemo } from 'react'; | import { FC, ReactNode, useCallback, useEffect, useMemo } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { createPortalModal } from './modal-manage'; | |||||
| interface ModalProps { | |||||
| export interface ModalProps { | |||||
| open: boolean; | open: boolean; | ||||
| onOpenChange?: (open: boolean) => void; | onOpenChange?: (open: boolean) => void; | ||||
| title?: ReactNode; | title?: ReactNode; | ||||
| titleClassName?: string; | |||||
| children: ReactNode; | children: ReactNode; | ||||
| footer?: ReactNode; | footer?: ReactNode; | ||||
| footerClassName?: string; | |||||
| showfooter?: boolean; | showfooter?: boolean; | ||||
| className?: string; | className?: string; | ||||
| size?: 'small' | 'default' | 'large'; | size?: 'small' | 'default' | 'large'; | ||||
| onOk?: () => void; | onOk?: () => void; | ||||
| onCancel?: () => void; | onCancel?: () => void; | ||||
| } | } | ||||
| export interface ModalType extends FC<ModalProps> { | |||||
| show: typeof modalIns.show; | |||||
| hide: typeof modalIns.hide; | |||||
| } | |||||
| export const Modal: FC<ModalProps> = ({ | |||||
| const Modal: ModalType = ({ | |||||
| open, | open, | ||||
| onOpenChange, | onOpenChange, | ||||
| title, | title, | ||||
| titleClassName, | |||||
| children, | children, | ||||
| footer, | footer, | ||||
| footerClassName, | |||||
| showfooter = true, | showfooter = true, | ||||
| className = '', | className = '', | ||||
| size = 'default', | size = 'default', | ||||
| }, [onOpenChange, onOk]); | }, [onOpenChange, onOk]); | ||||
| const handleChange = (open: boolean) => { | const handleChange = (open: boolean) => { | ||||
| onOpenChange?.(open); | onOpenChange?.(open); | ||||
| console.log('open', open, onOpenChange); | |||||
| if (open) { | if (open) { | ||||
| handleOk(); | handleOk(); | ||||
| } | } | ||||
| ); | ); | ||||
| } | } | ||||
| return ( | return ( | ||||
| <div className="flex items-center justify-end border-t border-border px-6 py-4"> | |||||
| <div | |||||
| className={cn( | |||||
| 'flex items-center justify-end px-6 py-4', | |||||
| footerClassName, | |||||
| )} | |||||
| > | |||||
| {footerTemp} | {footerTemp} | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| handleCancel, | handleCancel, | ||||
| handleOk, | handleOk, | ||||
| showfooter, | showfooter, | ||||
| footerClassName, | |||||
| ]); | ]); | ||||
| return ( | return ( | ||||
| <DialogPrimitive.Root open={open} onOpenChange={handleChange}> | <DialogPrimitive.Root open={open} onOpenChange={handleChange}> | ||||
| onClick={(e) => e.stopPropagation()} | onClick={(e) => e.stopPropagation()} | ||||
| > | > | ||||
| {/* title */} | {/* title */} | ||||
| {title && ( | |||||
| <div className="flex items-center justify-between border-b border-border px-6 py-4"> | |||||
| <DialogPrimitive.Title className="text-lg font-medium text-foreground"> | |||||
| {title} | |||||
| </DialogPrimitive.Title> | |||||
| {(title || closable) && ( | |||||
| <div | |||||
| className={cn( | |||||
| 'flex items-center px-6 py-4', | |||||
| { | |||||
| 'justify-end': closable && !title, | |||||
| 'justify-between': closable && title, | |||||
| 'justify-start': !closable, | |||||
| }, | |||||
| titleClassName, | |||||
| )} | |||||
| > | |||||
| {title && ( | |||||
| <DialogPrimitive.Title className="text-lg font-medium text-foreground"> | |||||
| {title} | |||||
| </DialogPrimitive.Title> | |||||
| )} | |||||
| {closable && ( | {closable && ( | ||||
| <DialogPrimitive.Close asChild> | <DialogPrimitive.Close asChild> | ||||
| <button | <button | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| )} | )} | ||||
| {/* title */} | |||||
| {!title && ( | |||||
| <DialogPrimitive.Title className="text-lg font-medium text-foreground"></DialogPrimitive.Title> | |||||
| )} | |||||
| {/* content */} | {/* content */} | ||||
| <div className="p-6 overflow-y-auto max-h-[80vh] focus-visible:!outline-none"> | |||||
| <div className="py-2 px-6 overflow-y-auto max-h-[80vh] focus-visible:!outline-none"> | |||||
| {destroyOnClose && !open ? null : children} | {destroyOnClose && !open ? null : children} | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| }; | }; | ||||
| // example usage | |||||
| /* | |||||
| import { Modal } from '@/components/ui/modal'; | |||||
| function Demo() { | |||||
| const [open, setOpen] = useState(false); | |||||
| let modalIns = createPortalModal(); | |||||
| Modal.show = modalIns | |||||
| ? modalIns.show | |||||
| : () => { | |||||
| modalIns = createPortalModal(); | |||||
| return modalIns.show; | |||||
| }; | |||||
| Modal.hide = modalIns.hide; | |||||
| return ( | |||||
| <div> | |||||
| <button onClick={() => setOpen(true)}>open modal</button> | |||||
| <Modal | |||||
| open={open} | |||||
| onOpenChange={setOpen} | |||||
| title="title" | |||||
| footer={ | |||||
| <div className="flex gap-2"> | |||||
| <button onClick={() => setOpen(false)} className="px-4 py-2 border rounded-md"> | |||||
| cancel | |||||
| </button> | |||||
| <button onClick={() => setOpen(false)} className="px-4 py-2 bg-primary text-white rounded-md"> | |||||
| ok | |||||
| </button> | |||||
| </div> | |||||
| } | |||||
| > | |||||
| <div className="py-4">弹窗内容区域</div> | |||||
| </Modal> | |||||
| <Modal | |||||
| title={'modal-title'} | |||||
| onOk={handleOk} | |||||
| confirmLoading={loading} | |||||
| destroyOnClose | |||||
| > | |||||
| <div className="py-4">弹窗内容区域</div> | |||||
| </Modal> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| */ | |||||
| export { Modal }; |
| addMCP: 'Add MCP', | addMCP: 'Add MCP', | ||||
| editMCP: 'Edit MCP', | editMCP: 'Edit MCP', | ||||
| }, | }, | ||||
| search: { | |||||
| createSearch: 'Create Search', | |||||
| }, | |||||
| }, | }, | ||||
| }; | }; |
| okText: '确认', | okText: '确认', | ||||
| cancelText: '取消', | cancelText: '取消', | ||||
| }, | }, | ||||
| search: { | |||||
| createSearch: '新建查询', | |||||
| }, | |||||
| }; | }; |
| import MessageItem from '@/components/next-message-item'; | import MessageItem from '@/components/next-message-item'; | ||||
| import { Modal } from '@/components/ui/modal'; | |||||
| import { Modal } from '@/components/ui/modal/modal'; | |||||
| import { useFetchAgent } from '@/hooks/use-agent-request'; | import { useFetchAgent } from '@/hooks/use-agent-request'; | ||||
| import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; | ||||
| import { IAgentLogMessage } from '@/interfaces/database/agent'; | import { IAgentLogMessage } from '@/interfaces/database/agent'; |
| HoverCardContent, | HoverCardContent, | ||||
| HoverCardTrigger, | HoverCardTrigger, | ||||
| } from '@/components/ui/hover-card'; | } from '@/components/ui/hover-card'; | ||||
| import { Modal } from '@/components/ui/modal'; | |||||
| import { Modal } from '@/components/ui/modal/modal'; | |||||
| import Space from '@/components/ui/space'; | import Space from '@/components/ui/space'; | ||||
| import { Switch } from '@/components/ui/switch'; | import { Switch } from '@/components/ui/switch'; | ||||
| import { Textarea } from '@/components/ui/textarea'; | import { Textarea } from '@/components/ui/textarea'; |
| }; | }; | ||||
| return ( | return ( | ||||
| <div className="flex pr-[25px]"> | <div className="flex pr-[25px]"> | ||||
| <div className="flex items-center gap-4 bg-background-card text-muted w-fit h-[35px] rounded-md px-4 py-2 text-base"> | |||||
| <div className="flex items-center gap-4 bg-background-card text-muted-foreground w-fit h-[35px] rounded-md px-4 py-2"> | |||||
| {textSelectOptions.map((option) => ( | {textSelectOptions.map((option) => ( | ||||
| <div | <div | ||||
| key={option.value} | key={option.value} |
| setCurrentTab(val); | setCurrentTab(val); | ||||
| }} | }} | ||||
| > | > | ||||
| <TabsList className="grid bg-background grid-cols-2 rounded-none bg-[#161618]"> | |||||
| <TabsList className="grid bg-transparent grid-cols-2 rounded-none text-foreground"> | |||||
| <TabsTrigger | <TabsTrigger | ||||
| value="generalForm" | value="generalForm" | ||||
| className="group bg-transparent p-0 !border-transparent" | className="group bg-transparent p-0 !border-transparent" | ||||
| > | > | ||||
| <div className="flex w-full h-full justify-center items-center bg-[#161618]"> | |||||
| <span className="h-full group-data-[state=active]:border-b-2 border-white "> | |||||
| <div className="flex w-full h-full justify-center items-center"> | |||||
| <span className="h-full group-data-[state=active]:border-b-2 border-foreground "> | |||||
| General | General | ||||
| </span> | </span> | ||||
| </div> | </div> | ||||
| value="chunkMethodForm" | value="chunkMethodForm" | ||||
| className="group bg-transparent p-0 !border-transparent" | className="group bg-transparent p-0 !border-transparent" | ||||
| > | > | ||||
| <div className="flex w-full h-full justify-center items-center bg-[#161618]"> | |||||
| <span className="h-full group-data-[state=active]:border-b-2 border-white "> | |||||
| <div className="flex w-full h-full justify-center items-center"> | |||||
| <span className="h-full group-data-[state=active]:border-b-2 border-foreground "> | |||||
| Chunk Method | Chunk Method | ||||
| </span> | </span> | ||||
| </div> | </div> |
| import { Modal } from '@/components/ui/modal'; | |||||
| import { Modal } from '@/components/ui/modal/modal'; | |||||
| import { IModalProps } from '@/interfaces/common'; | import { IModalProps } from '@/interfaces/common'; | ||||
| import DebugContent from '@/pages/agent/debug-content'; | import DebugContent from '@/pages/agent/debug-content'; | ||||
| import { buildBeginInputListFromObject } from '@/pages/agent/form/begin-form/utils'; | import { buildBeginInputListFromObject } from '@/pages/agent/form/begin-form/utils'; |
| import ListFilterBar from '@/components/list-filter-bar'; | import ListFilterBar from '@/components/list-filter-bar'; | ||||
| import { Input } from '@/components/originui/input'; | |||||
| import { Button } from '@/components/ui/button'; | import { Button } from '@/components/ui/button'; | ||||
| import { Modal } from '@/components/ui/modal/modal'; | |||||
| import { useTranslate } from '@/hooks/common-hooks'; | |||||
| import { useFetchFlowList } from '@/hooks/flow-hooks'; | import { useFetchFlowList } from '@/hooks/flow-hooks'; | ||||
| import { Plus } from 'lucide-react'; | |||||
| import { Plus, Search } from 'lucide-react'; | |||||
| import { useState } from 'react'; | |||||
| import { SearchCard } from './search-card'; | import { SearchCard } from './search-card'; | ||||
| export default function SearchList() { | export default function SearchList() { | ||||
| const { data } = useFetchFlowList(); | const { data } = useFetchFlowList(); | ||||
| const { t } = useTranslate('search'); | |||||
| const [searchName, setSearchName] = useState(''); | |||||
| const handleSearchChange = (value: string) => { | |||||
| console.log(value); | |||||
| }; | |||||
| return ( | return ( | ||||
| <section> | <section> | ||||
| <div className="px-8 pt-8"> | <div className="px-8 pt-8"> | ||||
| <ListFilterBar title="Search apps"> | |||||
| <Button variant={'tertiary'} size={'sm'}> | |||||
| <ListFilterBar | |||||
| icon={ | |||||
| <div className="rounded-sm bg-emerald-400 bg-gradient-to-t from-emerald-400 via-emerald-400 to-emerald-200 p-1 size-6 flex justify-center items-center"> | |||||
| <Search size={14} className="font-bold m-auto" /> | |||||
| </div> | |||||
| } | |||||
| title="Search apps" | |||||
| showFilter={false} | |||||
| onSearchChange={(e) => handleSearchChange(e.target.value)} | |||||
| > | |||||
| <Button | |||||
| variant={'default'} | |||||
| onClick={() => { | |||||
| Modal.show({ | |||||
| title: ( | |||||
| <div className="rounded-sm bg-emerald-400 bg-gradient-to-t from-emerald-400 via-emerald-400 to-emerald-200 p-1 size-6 flex justify-center items-center"> | |||||
| <Search size={14} className="font-bold m-auto" /> | |||||
| </div> | |||||
| ), | |||||
| titleClassName: 'border-none', | |||||
| footerClassName: 'border-none', | |||||
| visible: true, | |||||
| children: ( | |||||
| <div> | |||||
| <div>{t('createSearch')}</div> | |||||
| <div>name:</div> | |||||
| <Input | |||||
| defaultValue={searchName} | |||||
| onChange={(e) => { | |||||
| console.log(e.target.value, e); | |||||
| setSearchName(e.target.value); | |||||
| }} | |||||
| /> | |||||
| </div> | |||||
| ), | |||||
| onOk: () => { | |||||
| console.log('ok', searchName); | |||||
| }, | |||||
| onVisibleChange: (e) => { | |||||
| Modal.hide(); | |||||
| }, | |||||
| }); | |||||
| }} | |||||
| > | |||||
| <Plus className="mr-2 h-4 w-4" /> | <Plus className="mr-2 h-4 w-4" /> | ||||
| Create app | |||||
| {t('createSearch')} | |||||
| </Button> | </Button> | ||||
| </ListFilterBar> | </ListFilterBar> | ||||
| </div> | </div> | ||||
| <div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 max-h-[84vh] overflow-auto px-8"> | |||||
| <div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[84vh] overflow-auto px-8"> | |||||
| {data.map((x) => { | {data.map((x) => { | ||||
| return <SearchCard key={x.id} data={x}></SearchCard>; | return <SearchCard key={x.id} data={x}></SearchCard>; | ||||
| })} | })} |
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; | |||||
| import { MoreButton } from '@/components/more-button'; | |||||
| import { RAGFlowAvatar } from '@/components/ragflow-avatar'; | |||||
| import { Button } from '@/components/ui/button'; | import { Button } from '@/components/ui/button'; | ||||
| import { Card, CardContent } from '@/components/ui/card'; | import { Card, CardContent } from '@/components/ui/card'; | ||||
| import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; | import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; | ||||
| return ( | return ( | ||||
| <Card className="bg-colors-background-inverse-weak border-colors-outline-neutral-standard"> | <Card className="bg-colors-background-inverse-weak border-colors-outline-neutral-standard"> | ||||
| <CardContent className="p-4"> | |||||
| <CardContent className="p-4 flex gap-2 items-start group"> | |||||
| <div className="flex justify-between mb-4"> | <div className="flex justify-between mb-4"> | ||||
| {data.avatar ? ( | |||||
| <div | |||||
| className="w-[70px] h-[70px] rounded-xl bg-cover" | |||||
| style={{ backgroundImage: `url(${data.avatar})` }} | |||||
| /> | |||||
| ) : ( | |||||
| <Avatar className="w-[70px] h-[70px]"> | |||||
| <AvatarImage src="https://github.com/shadcn.png" /> | |||||
| <AvatarFallback>CN</AvatarFallback> | |||||
| </Avatar> | |||||
| )} | |||||
| <RAGFlowAvatar | |||||
| className="w-[70px] h-[70px]" | |||||
| avatar={data.avatar} | |||||
| name={data.title} | |||||
| /> | |||||
| </div> | |||||
| <div className="flex flex-col gap-1"> | |||||
| <section className="flex justify-between"> | |||||
| <div className="text-[20px] font-bold size-7 leading-5"> | |||||
| {data.title} | |||||
| </div> | |||||
| <MoreButton></MoreButton> | |||||
| </section> | |||||
| <div>An app that does things An app that does things</div> | |||||
| <section className="flex justify-between"> | |||||
| <div> | |||||
| Search app | |||||
| <p className="text-sm opacity-80"> | |||||
| {formatPureDate(data.update_time)} | |||||
| </p> | |||||
| </div> | |||||
| <div className="space-x-2 invisible group-hover:visible"> | |||||
| <Button variant="icon" size="icon" onClick={navigateToSearch}> | |||||
| <ChevronRight className="h-6 w-6" /> | |||||
| </Button> | |||||
| <Button variant="icon" size="icon"> | |||||
| <Trash2 /> | |||||
| </Button> | |||||
| </div> | |||||
| </section> | |||||
| </div> | </div> | ||||
| <h3 className="text-xl font-bold mb-2">{data.title}</h3> | |||||
| <p>An app that does things An app that does things</p> | |||||
| <section className="flex justify-between pt-3"> | |||||
| <div> | |||||
| Search app | |||||
| <p className="text-sm opacity-80"> | |||||
| {formatPureDate(data.update_time)} | |||||
| </p> | |||||
| </div> | |||||
| <div className="space-x-2"> | |||||
| <Button variant="icon" size="icon" onClick={navigateToSearch}> | |||||
| <ChevronRight className="h-6 w-6" /> | |||||
| </Button> | |||||
| <Button variant="icon" size="icon"> | |||||
| <Trash2 /> | |||||
| </Button> | |||||
| </div> | |||||
| </section> | |||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| ); | ); |