mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-08-16 06:35:56 +08:00
feat: support search group or members
This commit is contained in:
parent
e8ce7de718
commit
d1de872d86
@ -1,17 +1,44 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useDebounce } from 'ahooks'
|
||||||
import Avatar from '../../base/avatar'
|
import Avatar from '../../base/avatar'
|
||||||
import Button from '../../base/button'
|
import Button from '../../base/button'
|
||||||
import Checkbox from '../../base/checkbox'
|
import Checkbox from '../../base/checkbox'
|
||||||
import Input from '../../base/input'
|
import Input from '../../base/input'
|
||||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||||
|
import Loading from '../../base/loading'
|
||||||
import classNames from '@/utils/classnames'
|
import classNames from '@/utils/classnames'
|
||||||
|
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||||
|
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||||
|
import { SubjectType } from '@/models/access-control'
|
||||||
|
|
||||||
export default function AddMemberOrGroupDialog() {
|
export default function AddMemberOrGroupDialog() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [pageNumber, setPageNumber] = useState(1)
|
||||||
|
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||||
|
|
||||||
|
const { isPending, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, pageNumber, resultsPerPage: 10 }, open)
|
||||||
|
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeyword(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const hasMore = data?.has_more ?? true
|
||||||
|
let observer: IntersectionObserver | undefined
|
||||||
|
if (anchorRef.current) {
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isPending && hasMore)
|
||||||
|
setPageNumber((size: number) => size + 1)
|
||||||
|
}, { rootMargin: '20px' })
|
||||||
|
observer.observe(anchorRef.current)
|
||||||
|
}
|
||||||
|
return () => observer?.disconnect()
|
||||||
|
}, [isPending, setPageNumber, anchorRef, data])
|
||||||
|
|
||||||
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
|
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
|
||||||
<PortalToFollowElemTrigger asChild>
|
<PortalToFollowElemTrigger asChild>
|
||||||
@ -21,34 +48,51 @@ export default function AddMemberOrGroupDialog() {
|
|||||||
</Button>
|
</Button>
|
||||||
</PortalToFollowElemTrigger>
|
</PortalToFollowElemTrigger>
|
||||||
<PortalToFollowElemContent className='z-[25]'>
|
<PortalToFollowElemContent className='z-[25]'>
|
||||||
<div className='w-[400px] max-h-[400px] overflow-y-auto flex flex-col border-[0.5px] border-components-panel-border rounded-xl bg-components-panel-bg-blur backdrop-blur-[5px] shadow-lg'>
|
<div className='w-[400px] max-h-[400px] relative overflow-y-auto flex flex-col border-[0.5px] border-components-panel-border rounded-xl bg-components-panel-bg-blur backdrop-blur-[5px] shadow-lg'>
|
||||||
<div className='p-2 pb-0.5'>
|
<div className='p-2 pb-0.5 sticky top-0'>
|
||||||
<Input placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
||||||
</div>
|
</div>
|
||||||
|
{
|
||||||
|
(data?.subjects?.length ?? 0) > 0
|
||||||
|
? <>
|
||||||
<div className='flex items-center h-7 px-2 py-0.5'>
|
<div className='flex items-center h-7 px-2 py-0.5'>
|
||||||
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||||
</div>
|
</div>
|
||||||
<RenderGroupOrMember data={[]} />
|
<RenderGroupOrMember data={data?.subjects ?? []} />
|
||||||
|
<div ref={anchorRef} className='h-0'> </div>
|
||||||
|
</>
|
||||||
|
: isPending
|
||||||
|
? null
|
||||||
|
: <div className='flex items-center justify-center h-7 px-2 py-0.5'>
|
||||||
|
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.noResult')}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
isPending && <div className='p-1'><Loading /></div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PortalToFollowElemContent>
|
||||||
</PortalToFollowElem>
|
</PortalToFollowElem>
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderGroupOrMemberProps = {
|
type RenderGroupOrMemberProps = {
|
||||||
data: any[]
|
data: Subject[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderGroupOrMember({ data }: RenderGroupOrMemberProps) {
|
function RenderGroupOrMember({ data }: RenderGroupOrMemberProps) {
|
||||||
return <div className='p-1'>
|
return <div className='p-1'>
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
if (item.type === 'group')
|
if (item.subjectType === SubjectType.Group)
|
||||||
return <GroupItem key={index} />
|
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
|
||||||
return <MemberItem key={index} />
|
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupItem() {
|
type GroupItemProps = {
|
||||||
|
group: AccessControlGroup
|
||||||
|
}
|
||||||
|
function GroupItem({ group }: GroupItemProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return <BaseItem>
|
return <BaseItem>
|
||||||
<Checkbox className='w-4 h-4 shrink-0' />
|
<Checkbox className='w-4 h-4 shrink-0' />
|
||||||
@ -58,8 +102,8 @@ function GroupItem() {
|
|||||||
<RiOrganizationChart className='w-[14px] h-[14px] text-components-avatar-shape-fill-stop-0' />
|
<RiOrganizationChart className='w-[14px] h-[14px] text-components-avatar-shape-fill-stop-0' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className='system-sm-medium text-text-secondary mr-1'>Name</p>
|
<p className='system-sm-medium text-text-secondary mr-1'>{group.name}</p>
|
||||||
<p className='system-xs-regular text-text-tertiary'>5</p>
|
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" variant='ghost-accent' className='py-1 px-1.5 shrink-0 flex items-center justify-between'>
|
<Button size="small" variant='ghost-accent' className='py-1 px-1.5 shrink-0 flex items-center justify-between'>
|
||||||
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
|
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
|
||||||
@ -68,19 +112,22 @@ function GroupItem() {
|
|||||||
</BaseItem>
|
</BaseItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemberItem() {
|
type MemberItemProps = {
|
||||||
|
member: AccessControlAccount
|
||||||
|
}
|
||||||
|
function MemberItem({ member }: MemberItemProps) {
|
||||||
return <BaseItem className='pr-3'>
|
return <BaseItem className='pr-3'>
|
||||||
<Checkbox className='w-4 h-4 shrink-0' />
|
<Checkbox className='w-4 h-4 shrink-0' />
|
||||||
<div className='flex items-center grow'>
|
<div className='flex items-center grow'>
|
||||||
<div className='w-5 h-5 rounded-full bg-components-icon-bg-blue-solid overflow-hidden mr-2'>
|
<div className='w-5 h-5 rounded-full bg-components-icon-bg-blue-solid overflow-hidden mr-2'>
|
||||||
<div className='w-full h-full flex items-center justify-center bg-access-app-icon-mask-bg'>
|
<div className='w-full h-full flex items-center justify-center bg-access-app-icon-mask-bg'>
|
||||||
<Avatar className='w-[14px] h-[14px]' textClassName='text-[12px]' avatar={null} name='M' />
|
<Avatar className='w-[14px] h-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className='system-sm-medium text-text-secondary mr-1'>Name</p>
|
<p className='system-sm-medium text-text-secondary mr-1'>{member.name}</p>
|
||||||
<p className='system-xs-regular text-text-tertiary'>5</p>
|
<p className='system-xs-regular text-text-tertiary'>You</p>
|
||||||
</div>
|
</div>
|
||||||
<p className='system-xs-regular text-text-quaternary'>douxc512@gmail.com</p>
|
<p className='system-xs-regular text-text-quaternary'>{member.email}</p>
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,6 +194,7 @@ const translation = {
|
|||||||
searchPlaceholder: 'Search groups and members',
|
searchPlaceholder: 'Search groups and members',
|
||||||
allMembers: 'All members',
|
allMembers: 'All members',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
|
noResult: 'No result',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -195,6 +195,7 @@ const translation = {
|
|||||||
searchPlaceholder: '搜索组或成员',
|
searchPlaceholder: '搜索组或成员',
|
||||||
allMembers: '所有成员',
|
allMembers: '所有成员',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
|
noResult: '没有结果',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
29
web/models/access-control.ts
Normal file
29
web/models/access-control.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export enum SubjectType {
|
||||||
|
Group = 'group',
|
||||||
|
Account = 'account',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AccessModel {
|
||||||
|
PUBLIC = 'PUBLIC',
|
||||||
|
PRIVATE = 'PRIVATE',
|
||||||
|
ORGANIZATION = 'ORGANIZATION',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccessControlGroup = {
|
||||||
|
'id': 'string'
|
||||||
|
'name': 'string'
|
||||||
|
'groupSize': 5
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccessControlAccount = {
|
||||||
|
'id': 'string'
|
||||||
|
'name': 'string'
|
||||||
|
'email': 'string'
|
||||||
|
'avatar': 'string'
|
||||||
|
'avatarUrl': 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubjectGroup = { subjectId: string; subjectType: SubjectType; groupData: AccessControlGroup }
|
||||||
|
export type SubjectAccount = { subjectId: string; subjectType: SubjectType; accountData: AccessControlAccount }
|
||||||
|
|
||||||
|
export type Subject = SubjectGroup | SubjectAccount
|
35
web/service/access-control.ts
Normal file
35
web/service/access-control.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { get } from './base'
|
||||||
|
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||||
|
|
||||||
|
const NAME_SPACE = 'access-control'
|
||||||
|
|
||||||
|
export const useAppWhiteListSubjects = (appId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [NAME_SPACE, 'app-whitelist-subjects', appId],
|
||||||
|
queryFn: () => get<{ groups: AccessControlGroup[]; members: AccessControlAccount[] }>(`/enterprise/webapp/app/subjects?appId=${appId}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResults = {
|
||||||
|
currPage: number
|
||||||
|
totalPages: number
|
||||||
|
subjects: Subject[]
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSearchForWhiteListCandidates = (query: { appId?: string; keyword?: string; pageNumber?: number; resultsPerPage?: number }, enabled: boolean) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [NAME_SPACE, 'app-whitelist-candidates', query],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
Object.keys(query).forEach((key) => {
|
||||||
|
const typedKey = key as keyof typeof query
|
||||||
|
if (query[typedKey])
|
||||||
|
params.append(key, `${query[typedKey]}`)
|
||||||
|
})
|
||||||
|
return get<SearchResults>(`/enterprise/webapp/app/subject/search?${new URLSearchParams(params).toString()}`)
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user