chore: team, orgs, search and so on (#3807)

* feat: clb search support username, memberName, contacts

* feat: popup org names

* feat: update team member table

* feat: restore the member

* feat: search user in team member table

* feat: bind contact

* feat: export members

* feat: org tab could delete member

* feat: org table search

* feat: team notification account bind

* feat: permission tab search

* fix: wecom sso

* chore(init): copy notificationAccount to user.contact

* chore: adjust

* fix: ts error

* fix: useConfirm iconColor customization

* pref: fe

* fix: style

* fix: fix team member manage

* pref: enlarge team member pagesize

* pref: initv4822

* fix: pageSize

* pref: initscritpt
This commit is contained in:
Finley Ge
2025-02-19 17:27:19 +08:00
committed by GitHub
parent 5fd520c794
commit 206325bc5f
35 changed files with 867 additions and 349 deletions

View File

@@ -1,5 +1,9 @@
import { MemberGroupSchemaType, MemberGroupType } from 'support/permission/memberGroup/type';
import { OAuthEnum } from './constant';
import { TrackRegisterParams } from './login/api';
import { TeamMemberStatusEnum } from './team/constant';
import { OrgType } from './team/org/type';
import { TeamMemberItemType } from './team/type';
export type PostLoginProps = {
username: string;
@@ -21,3 +25,9 @@ export type FastLoginProps = {
token: string;
code: string;
};
export type SearchResult = {
members: Omit<TeamMemberItemType, 'teamId' | 'permission'>[];
orgs: Omit<OrgType, 'permission' | 'members'>[];
groups: MemberGroupSchemaType[];
};

View File

@@ -13,6 +13,7 @@ export type CreateTeamProps = {
defaultTeam?: boolean;
memberName?: string;
memberAvatar?: string;
notificationAccount?: string;
};
export type UpdateTeamProps = Omit<ThirdPartyAccountType, 'externalWorkflowVariable'> & {
name?: string;
@@ -39,6 +40,12 @@ export type UpdateInviteProps = {
tmbId: string;
status: TeamMemberSchema['status'];
};
export type UpdateStatusProps = {
tmbId: string;
status: TeamMemberSchema['status'];
};
export type InviteMemberResponse = Record<
'invite' | 'inValid' | 'inTeam',
{ username: string; userId: string }[]

View File

@@ -34,6 +34,7 @@ export type TeamTagSchema = TeamTagItemType & {
_id: string;
teamId: string;
createTime: Date;
updateTime?: Date;
};
export type TeamMemberSchema = {
@@ -41,6 +42,7 @@ export type TeamMemberSchema = {
teamId: string;
userId: string;
createTime: Date;
updateTime?: Date;
name: string;
role: `${TeamMemberRoleEnum}`;
status: `${TeamMemberStatusEnum}`;
@@ -79,6 +81,9 @@ export type TeamMemberItemType = {
role: `${TeamMemberRoleEnum}`;
status: `${TeamMemberStatusEnum}`;
permission: TeamPermission;
contact?: string;
createTime: Date;
updateTime?: Date;
};
export type TeamTagItemType = {

View File

@@ -17,6 +17,7 @@ export type UserModelSchema = {
fastgpt_sem?: {
keyword: string;
};
contact?: string;
};
export type UserType = {
@@ -29,6 +30,7 @@ export type UserType = {
standardInfo?: standardInfoType;
notificationAccount?: string;
permission: TeamPermission;
contact?: string;
};
export type SourceMemberType = {

View File

@@ -0,0 +1,4 @@
export const generateCsv = (headers: string[], data: string[][]) => {
const csv = [headers.join(','), ...data.map((row) => row.join(','))].join('\n');
return csv;
};

View File

@@ -46,6 +46,7 @@ export async function getUserDetail({
promotionRate: user.promotionRate,
team: tmb,
notificationAccount: tmb.notificationAccount,
permission: tmb.permission
permission: tmb.permission,
contact: user.contact
};
}

View File

@@ -57,6 +57,7 @@ const UserSchema = new Schema({
},
fastgpt_sem: Object,
sourceDomain: String,
contact: String,
/** @deprecated */
avatar: String

View File

@@ -36,6 +36,9 @@ const TeamMemberSchema = new Schema({
type: Date,
default: () => new Date()
},
updateTime: {
type: Date
},
defaultTeam: {
type: Boolean,
default: false

View File

@@ -26,6 +26,7 @@ export const iconPaths = {
'common/closeLight': () => import('./icons/common/closeLight.svg'),
'common/confirm/commonTip': () => import('./icons/common/confirm/commonTip.svg'),
'common/confirm/deleteTip': () => import('./icons/common/confirm/deleteTip.svg'),
'common/confirm/restoreTip': () => import('./icons/common/confirm/restoreTip.svg'),
'common/confirm/rightTip': () => import('./icons/common/confirm/rightTip.svg'),
'common/courseLight': () => import('./icons/common/courseLight.svg'),
'common/customTitleLight': () => import('./icons/common/customTitleLight.svg'),
@@ -387,9 +388,9 @@ export const iconPaths = {
'model/moonshot': () => import('./icons/model/moonshot.svg'),
'model/ollama': () => import('./icons/model/ollama.svg'),
'model/openai': () => import('./icons/model/openai.svg'),
'model/ppio': () => import('./icons/model/ppio.svg'),
'model/qwen': () => import('./icons/model/qwen.svg'),
'model/siliconflow': () => import('./icons/model/siliconflow.svg'),
'model/ppio': () => import('./icons/model/ppio.svg'),
'model/sparkDesk': () => import('./icons/model/sparkDesk.svg'),
'model/stepfun': () => import('./icons/model/stepfun.svg'),
'model/yi': () => import('./icons/model/yi.svg'),

View File

@@ -1 +1,3 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1701403554068" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5098" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M572.074667 337.134933a57.275733 57.275733 0 1 0-114.176-6.007466h-0.170667l12.936533 272.896v0.443733a44.544 44.544 0 1 0 89.019734-1.194667l12.424533-266.1376z m-196.949334-191.214933c76.151467-130.048 199.816533-129.774933 275.797334 0l340.3776 581.085867c76.117333 130.048 15.7696 235.4176-135.236267 235.4176H169.984c-150.8352 0-211.217067-105.6768-135.202133-235.4176L375.125333 145.92z m140.049067 687.581867a57.275733 57.275733 0 1 0 0-114.517334 57.275733 57.275733 0 0 0 0 114.517334z" fill="#FB6547" p-id="5099"></path></svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.77324 1.94537C9.14764 1.73458 9.57006 1.62384 9.99973 1.62384C10.4294 1.62384 10.8518 1.73458 11.2262 1.94537C11.6006 2.15617 11.9144 2.4599 12.1372 2.82727L12.1396 2.83123L19.198 14.6146L19.2047 14.6261C19.423 15.0041 19.5385 15.4327 19.5397 15.8692C19.541 16.3058 19.4279 16.735 19.2117 17.1142C18.9955 17.4935 18.6838 17.8095 18.3076 18.0309C17.9314 18.2523 17.5037 18.3713 17.0672 18.3761L17.0581 18.3762L2.93224 18.3761C2.49574 18.3713 2.0681 18.2523 1.69188 18.0309C1.31565 17.8095 1.00394 17.4935 0.787774 17.1142C0.571604 16.735 0.458504 16.3058 0.459727 15.8692C0.460949 15.4327 0.576451 15.0041 0.79474 14.6261L0.80151 14.6146L7.86222 2.82726C8.08506 2.4599 8.39883 2.15617 8.77324 1.94537ZM9.99973 3.29051C9.85651 3.29051 9.7157 3.32742 9.5909 3.39769C9.46666 3.46763 9.36246 3.56828 9.28824 3.68999L2.23531 15.4643C2.16432 15.5891 2.12679 15.7302 2.12639 15.8739C2.12598 16.0194 2.16368 16.1625 2.23574 16.2889C2.30779 16.4153 2.41169 16.5207 2.5371 16.5944C2.66141 16.6676 2.80256 16.7072 2.94673 16.7095H17.0527C17.1969 16.7072 17.338 16.6676 17.4624 16.5944C17.5878 16.5207 17.6917 16.4153 17.7637 16.2889C17.8358 16.1625 17.8735 16.0194 17.8731 15.8739C17.8727 15.7302 17.8351 15.5892 17.7642 15.4644L10.7122 3.69165C10.7119 3.6911 10.7116 3.69054 10.7112 3.68999C10.637 3.56828 10.5328 3.46763 10.4086 3.39769C10.2838 3.32742 10.143 3.29051 9.99973 3.29051ZM9.99973 6.70946C10.46 6.70946 10.8331 7.08256 10.8331 7.54279V10.8761C10.8331 11.3364 10.46 11.7095 9.99973 11.7095C9.53949 11.7095 9.1664 11.3364 9.1664 10.8761V7.54279C9.1664 7.08256 9.53949 6.70946 9.99973 6.70946ZM9.1664 14.2095C9.1664 13.7492 9.53949 13.3761 9.99973 13.3761H10.0081C10.4683 13.3761 10.8414 13.7492 10.8414 14.2095C10.8414 14.6697 10.4683 15.0428 10.0081 15.0428H9.99973C9.53949 15.0428 9.1664 14.6697 9.1664 14.2095Z" fill="#F79009"/>
</svg>

Before

Width:  |  Height:  |  Size: 869 B

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.0058 3.68803C7.89786 3.68803 5.31329 5.93506 4.79046 8.89299L5.70669 8.3668C6.10579 8.13759 6.61514 8.27532 6.84434 8.67443C7.07355 9.07353 6.93582 9.58287 6.53671 9.81208L3.95412 11.2953C3.814 11.3757 3.66029 11.411 3.50989 11.4056C3.21062 11.4162 2.91562 11.2648 2.7564 10.9869L1.29256 8.43222C1.06374 8.03289 1.20197 7.52368 1.6013 7.29487C2.00063 7.06605 2.50984 7.20428 2.73865 7.60361L3.1897 8.39078C3.93457 4.75546 7.15021 2.02136 11.0058 2.02136C15.4123 2.02136 18.9844 5.59352 18.9844 9.99999C18.9844 14.4065 15.4123 17.9786 11.0058 17.9786C9.66714 17.9786 8.30206 17.5718 7.13895 16.9284C5.98005 16.2874 4.95794 15.3757 4.35999 14.3039C4.13578 13.902 4.27984 13.3944 4.68176 13.1701C5.08369 12.9459 5.59128 13.09 5.8155 13.4919C6.2256 14.2271 6.98588 14.9391 7.94567 15.47C8.90126 15.9986 9.9912 16.312 11.0058 16.312C14.4918 16.312 17.3178 13.486 17.3178 9.99999C17.3178 6.51399 14.4918 3.68803 11.0058 3.68803Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1020 B

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Input, InputProps, InputGroup, InputLeftElement } from '@chakra-ui/react';
import MyIcon from '../../Icon';

View File

@@ -84,7 +84,7 @@ const MyModal = ({
objectFit={'contain'}
alt=""
src={iconSrc}
w={'1.5rem'}
w={'20px'}
borderRadius={'sm'}
/>
</>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useDisclosure, Button, ModalBody, ModalFooter } from '@chakra-ui/react';
import { useDisclosure, Button, ModalBody, ModalFooter, type ImageProps } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyModal from '../components/common/MyModal';
import { useMemoizedFn } from 'ahooks';
@@ -11,6 +11,7 @@ export const useConfirm = (props?: {
showCancel?: boolean;
type?: 'common' | 'delete';
hideFooter?: boolean;
iconColor?: ImageProps['color'];
}) => {
const { t } = useTranslation();
@@ -34,6 +35,7 @@ export const useConfirm = (props?: {
const {
title = map?.title || t('common:Warning'),
iconSrc = map?.iconSrc,
iconColor,
content,
showCancel = true,
hideFooter = false
@@ -93,7 +95,13 @@ export const useConfirm = (props?: {
}, [isOpen]);
return (
<MyModal isOpen={isOpen} iconSrc={iconSrc} title={title} maxW={['90vw', '400px']}>
<MyModal
isOpen={isOpen}
iconSrc={iconSrc}
iconColor={iconColor}
title={title}
maxW={['90vw', '400px']}
>
<ModalBody pt={5} whiteSpace={'pre-wrap'} fontSize={'sm'}>
{customContent}
</ModalBody>

View File

@@ -71,5 +71,7 @@
"user_team_team_name": "团队名",
"verification_code": "验证码",
"you_can_convert": "您可以兑换",
"yuan": "元"
"yuan": "元",
"contact": "联系方式",
"please_bind_contact": "请绑定联系方式"
}

View File

@@ -1,7 +1,6 @@
{
"action": "操作",
"confirm_delete_group": "确认删除群组?",
"confirm_delete_member": "确认删除成员?",
"confirm_delete_org": "确认删除该部门?",
"confirm_leave_team": "确认离开该团队? \n退出后您在该团队所有的资源均转让给团队所有者。",
"create_group": "创建群组",
@@ -26,7 +25,9 @@
"owner": "所有者",
"permission": "权限",
"remark": "备注",
"remove_tip": "确认将 {{username}} 移出团队?",
"remove_tip": "确认将 {{username}} 移出团队?成员将被标记为“已离职”,不删除操作数据,账号下资源自动转让给团队所有者。",
"restore_tip": "确认将 {{username}} 加入团队吗?仅恢复该成员账号可用性及相关权限,无法恢复账号下资源。",
"restore_tip_title": "恢复确认",
"retain_admin_permissions": "保留管理员权限",
"search_member_group_name": "搜索成员/群组名称",
"total_team_members": "共 {{amount}} 名成员",
@@ -38,5 +39,17 @@
"waiting": "待接受",
"sync_immediately": "立即同步",
"sync_member_failed": "同步成员失败",
"sync_member_success": "同步成员成功"
"sync_member_success": "同步成员成功",
"contact": "联系方式",
"join_update_time": "加入/更新时间",
"leave": "已离职",
"search_member": "搜索成员",
"export_members": "导出成员",
"delete_from_team": "移出团队",
"delete_from_org": "移出部门",
"confirm_delete_from_team": "确认将 {{username}} 移出团队?",
"confirm_delete_from_org": "确认将 {{username}} 移出部门?",
"search_org": "搜索部门",
"notification_recieve": "团队通知接收",
"set_name_avatar": "团队头像 & 团队名"
}

View File

@@ -1,6 +1,6 @@
{
"name": "app",
"version": "4.8.21",
"version": "4.8.22",
"private": false,
"scripts": {
"dev": "next dev",

View File

@@ -21,9 +21,7 @@ const UpdateInviteModal = dynamic(() => import('@/components/support/user/team/U
const NotSufficientModal = dynamic(() => import('@/components/support/wallet/NotSufficientModal'));
const SystemMsgModal = dynamic(() => import('@/components/support/user/inform/SystemMsgModal'));
const ImportantInform = dynamic(() => import('@/components/support/user/inform/ImportantInform'));
const UpdateNotification = dynamic(
() => import('@/components/support/user/inform/UpdateNotificationModal')
);
const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'));
const pcUnShowLayoutRoute: Record<string, boolean> = {
'/': true,
@@ -156,7 +154,7 @@ const Layout = ({ children }: { children: JSX.Element }) => {
{notSufficientModalType && <NotSufficientModal type={notSufficientModalType} />}
{!!userInfo && <SystemMsgModal />}
{showUpdateNotification && (
<UpdateNotification onClose={() => setIsUpdateNotification(false)} />
<UpdateContact onClose={() => setIsUpdateNotification(false)} mode="contact" />
)}
{!!userInfo && importantInforms.length > 0 && (
<ImportantInform informs={importantInforms} refetch={refetchUnRead} />

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Box, Checkbox, HStack, VStack } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import PermissionTags from './PermissionTags';
import { PermissionValueType } from '@fastgpt/global/support/permission/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import OrgTags from '../../user/team/OrgTags';
function MemberItemCard({
avatar,
key,
onChange,
isChecked,
onDelete,
name,
permission,
orgs
}: {
avatar: string;
key: string;
onChange: () => void;
isChecked?: boolean;
onDelete?: () => void;
name: string;
permission?: PermissionValueType;
orgs?: string[];
}) {
return (
<>
<HStack
justifyContent="space-between"
alignItems="center"
key={key}
px="3"
py="2"
borderRadius="sm"
_hover={{
bgColor: 'myGray.50',
cursor: 'pointer'
}}
onClick={onChange}
>
{isChecked !== undefined && <Checkbox isChecked={isChecked} pointerEvents="none" />}
<Avatar src={avatar} w="1.5rem" borderRadius={'50%'} />
<VStack w="full" gap={0}>
<Box w="full">{name}</Box>
<Box w="full">{orgs && orgs.length > 0 && <OrgTags orgs={orgs} />}</Box>
</VStack>
{permission && <PermissionTags permission={permission} />}
{onDelete !== undefined && (
<MyIcon
name="common/closeLight"
w="1rem"
cursor={'pointer'}
_hover={{
color: 'red.600'
}}
onClick={onDelete}
/>
)}
</HStack>
</>
);
}
export default MemberItemCard;

View File

@@ -37,6 +37,8 @@ import { getTeamMembers } from '@/web/support/user/team/api';
import { getGroupList } from '@/web/support/user/team/group/api';
import { getOrgList } from '@/web/support/user/team/org/api';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
import MemberItemCard from './MemberItemCard';
import { GetSearchUserGroupOrg } from '@/web/support/user/api';
const HoverBoxStyle = {
bgColor: 'myGray.50',
@@ -72,6 +74,12 @@ function MemberModal({
const [parentPath, setParentPath] = useState('');
const { data: searchedData } = useRequest2(() => GetSearchUserGroupOrg(searchText), {
manual: false,
throttleWait: 500,
refreshDeps: [searchText]
});
const paths = useMemo(() => {
const splitPath = parentPath.split('/').filter(Boolean);
return splitPath
@@ -97,7 +105,10 @@ function MemberModal({
return orgs.find((org) => org.pathId === currentOrgId);
}, [orgs, parentPath]);
const filterOrgs: (OrgType & { count?: number })[] = useMemo(() => {
if (searchText) return orgs.filter((item) => item.name.includes(searchText));
if (searchText && searchedData) {
const orgids = searchedData.orgs.map((item) => item._id);
return orgs.filter((org) => orgids.includes(String(org._id)));
}
if (!searchText && filterClass !== 'org') return [];
if (parentPath === '') {
setParentPath(`/${orgs[0].pathId}`);
@@ -110,27 +121,34 @@ function MemberModal({
count:
item.members.length + orgs.filter((org) => org.path === getOrgChildrenPath(item)).length
}));
}, [orgs, searchText, filterClass, parentPath]);
}, [searchText, filterClass, parentPath, orgs, searchedData]);
const [selectedMemberIdList, setSelectedMembers] = useState<string[]>([]);
const filterMembers = useMemo(() => {
if (searchText) return members.filter((item) => item.memberName.includes(searchText));
if (searchText) {
return searchedData?.members;
}
if (!searchText && filterClass !== 'member' && filterClass !== 'org') return [];
if (currentOrg && filterClass === 'org') {
return members.filter((item) => currentOrg.members.find((v) => v.tmbId === item.tmbId));
}
return members;
}, [members, searchText, filterClass, currentOrg]);
}, [members, searchedData, searchText, filterClass, currentOrg]);
const [selectedGroupIdList, setSelectedGroupIdList] = useState<string[]>([]);
const filterGroups = useMemo(() => {
if (searchText) return groups.filter((item) => item.name.includes(searchText));
if (searchText) {
return searchedData?.groups.map((item) => ({
groupName: item.name,
_id: item.id,
...item
}));
}
if (!searchText && filterClass !== 'group') return [];
return groups;
}, [groups, searchText, filterClass]);
}, [searchText, filterClass, groups, searchedData]);
const permissionList = useContextSelector(CollaboratorContext, (v) => v.permissionList);
const getPerLabelList = useContextSelector(CollaboratorContext, (v) => v.getPerLabelList);
@@ -146,6 +164,7 @@ function MemberModal({
CollaboratorContext,
(v) => v.onUpdateCollaborators
);
const { runAsync: onConfirm, loading: isUpdating } = useRequest2(
() =>
onUpdateCollaborators({
@@ -210,6 +229,7 @@ function MemberModal({
iconSrc={addOnly ? 'keyPrimary' : 'modal/AddClb'}
title={addOnly ? t('user:team.add_permission') : t('user:team.add_collaborator')}
minW="800px"
maxW={'60vw'}
h={'100%'}
maxH={'90vh'}
isCentered
@@ -300,14 +320,13 @@ function MemberModal({
</Box>
)}
{filterClass && (
<ScrollData
flexDirection={'column'}
gap={1}
userSelect={'none'}
height={'fit-content'}
>
{filterOrgs.map((org) => {
{filterOrgs?.map((org) => {
const onChange = () => {
setSelectedOrgIdList((state) => {
if (state.includes(org._id)) {
@@ -362,7 +381,7 @@ function MemberModal({
</HStack>
);
})}
{filterMembers.map((member) => {
{filterMembers?.map((member) => {
const onChange = () => {
setSelectedMembers((state) => {
if (state.includes(member.tmbId)) {
@@ -372,30 +391,28 @@ function MemberModal({
});
};
const collaborator = collaboratorList?.find((v) => v.tmbId === member.tmbId);
const memberOrgs = orgs.filter((org) =>
org.members.find((v) => String(v.tmbId) === String(member.tmbId))
);
const memberPathIds = memberOrgs.map((org) =>
(org.path + '/' + org.pathId).split('/').slice(0)
);
const memberOrgNames = memberPathIds.map((pathIds) =>
pathIds.map((id) => orgs.find((v) => v.pathId === id)?.name).join('/')
);
return (
<HStack
justifyContent="space-between"
<MemberItemCard
avatar={member.avatar}
key={member.tmbId}
py="2"
px="3"
borderRadius="sm"
alignItems="center"
_hover={HoverBoxStyle}
onClick={onChange}
>
<Checkbox
name={member.memberName}
permission={collaborator?.permission.value}
onChange={onChange}
isChecked={selectedMemberIdList.includes(member.tmbId)}
pointerEvents="none"
orgs={memberOrgNames}
/>
<MyAvatar src={member.avatar} w="1.5rem" borderRadius={'50%'} />
<Box w="full" ml="2">
{member.memberName}
</Box>
<PermissionTags permission={collaborator?.permission.value} />
</HStack>
);
})}
{filterGroups.map((group) => {
{filterGroups?.map((group) => {
const onChange = () => {
setSelectedGroupIdList((state) => {
if (state.includes(group._id)) {
@@ -406,30 +423,19 @@ function MemberModal({
};
const collaborator = collaboratorList?.find((v) => v.groupId === group._id);
return (
<HStack
justifyContent="space-between"
<MemberItemCard
avatar={group.avatar}
key={group._id}
py="2"
px="3"
borderRadius="sm"
alignItems="center"
_hover={HoverBoxStyle}
onClick={onChange}
>
<Checkbox
name={
group.name === DefaultGroupName ? userInfo?.team.teamName ?? '' : group.name
}
permission={collaborator?.permission.value}
onChange={onChange}
isChecked={selectedGroupIdList.includes(group._id)}
pointerEvents="none"
/>
<MyAvatar src={group.avatar} w="1.5rem" borderRadius={'50%'} />
<Box ml="2" w="full">
{group.name === DefaultGroupName ? userInfo?.team.teamName : group.name}
</Box>
<PermissionTags permission={collaborator?.permission.value} />
</HStack>
);
})}
</ScrollData>
)}
</Flex>
</Flex>
@@ -441,29 +447,27 @@ function MemberModal({
<Flex flexDirection="column" mt="2" gap={1} overflow={'auto'} flex={'1 0 0'} h={0}>
{selectedList.map((item) => {
return (
<HStack
justifyContent="space-between"
<MemberItemCard
key={item.id}
py="2"
px="3"
borderRadius="sm"
alignItems="center"
_hover={HoverBoxStyle}
>
<MyAvatar src={item.avatar} w="1.5rem" borderRadius={'50%'} />
<Box w="full" ml="2">
{item.name}
</Box>
<MyIcon
name="common/closeLight"
w="1rem"
cursor={'pointer'}
_hover={{
color: 'red.600'
}}
onClick={item.onDelete}
avatar={item.avatar}
name={item.name ?? ''}
onChange={item.onDelete}
onDelete={item.onDelete}
orgs={(() => {
if (!item.id.startsWith('member-')) return [];
const id = item.id.replace('member-', '');
const memberOrgs = orgs.filter((org) =>
org.members.find((v) => v.tmbId === id)
);
const memberPathIds = memberOrgs.map((org) =>
(org.path + '/' + org.pathId).split('/').slice(0)
);
const memberOrgNames = memberPathIds.map((pathIds) =>
pathIds.map((id) => orgs.find((v) => v.pathId === id)?.name).join('/')
);
return memberOrgNames;
})()}
/>
</HStack>
);
})}
</Flex>

View File

@@ -4,34 +4,48 @@ import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { updateNotificationAccount } from '@/web/support/user/api';
import { updateContact, updateNotificationAccount } from '@/web/support/user/api';
import Icon from '@fastgpt/web/components/common/Icon';
import { useSendCode } from '@/web/support/user/hooks/useSendCode';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useSystemStore } from '@/web/common/system/useSystemStore';
type FormType = {
account: string;
contact: string;
verifyCode: string;
};
const UpdateNotificationModal = ({ onClose }: { onClose: () => void }) => {
const UpdateContactModal = ({
onClose,
mode
}: {
onClose: () => void;
mode: 'contact' | 'notification_account';
}) => {
const { t } = useTranslation();
const { initUserInfo } = useUserStore();
const { feConfigs } = useSystemStore();
const { register, handleSubmit, watch } = useForm<FormType>({
defaultValues: {
account: '',
contact: '',
verifyCode: ''
}
});
const account = watch('account');
const account = watch('contact');
const verifyCode = watch('verifyCode');
const { runAsync: onSubmit, loading: isLoading } = useRequest2(
(data: FormType) => {
return updateNotificationAccount(data);
if (mode === 'contact') {
return updateContact(data);
} else {
return updateNotificationAccount({
account: data.contact,
verifyCode: data.verifyCode
});
}
},
{
onSuccess() {
@@ -62,7 +76,11 @@ const UpdateNotificationModal = ({ onClose }: { onClose: () => void }) => {
isOpen
iconSrc="common/settingLight"
w={'32rem'}
title={t('common:support.user.info.notification_receiving_hint')}
title={
mode === 'notification_account'
? t('common:support.user.info.notification_receiving_hint')
: t('account_info:contact')
}
>
<ModalBody px={10}>
<Flex flexDirection="column">
@@ -75,7 +93,7 @@ const UpdateNotificationModal = ({ onClose }: { onClose: () => void }) => {
<Input
flex={1}
bg={'myGray.50'}
{...register('account', { required: true })}
{...register('contact', { required: true })}
placeholder={placeholder}
></Input>
</Flex>
@@ -108,4 +126,4 @@ const UpdateNotificationModal = ({ onClose }: { onClose: () => void }) => {
);
};
export default UpdateNotificationModal;
export default UpdateContactModal;

View File

@@ -0,0 +1,39 @@
import { Box, Flex, VStack } from '@chakra-ui/react';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import Tag from '@fastgpt/web/components/common/Tag';
import React from 'react';
function OrgTags({ orgs, type = 'simple' }: { orgs: string[]; type?: 'simple' | 'tag' }) {
return (
<MyTooltip
label={
<VStack gap="1" alignItems={'start'}>
{orgs.map((org, index) => (
<Box key={index} fontSize="sm" fontWeight={400} color="myGray.500">
{org.slice(1)}
</Box>
))}
</VStack>
}
>
{type === 'simple' ? (
<Box fontSize="sm" fontWeight={400} w="full" color="myGray.500" whiteSpace={'nowrap'}>
{orgs
.map((org) => org.split('/').pop())
.join(', ')
.slice(0, 30)}
{orgs.length > 1 && '...'}
</Box>
) : (
<Flex direction="row" gap="1" p="2" alignItems={'start'} wrap={'wrap'}>
{orgs.map((org, index) => (
<Tag key={index}>{org.split('/').pop()}</Tag>
))}
</Flex>
)}
</MyTooltip>
);
}
export default OrgTags;

View File

@@ -2,18 +2,30 @@ import React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'next-i18next';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { Box, Button, Flex, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
import {
Box,
Button,
Flex,
HStack,
Input,
ModalBody,
ModalFooter,
useDisclosure
} from '@chakra-ui/react';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { postCreateTeam, putUpdateTeam } from '@/web/support/user/team/api';
import { CreateTeamProps } from '@fastgpt/global/support/user/team/controller.d';
import { DEFAULT_TEAM_AVATAR } from '@fastgpt/global/common/system/constants';
import Icon from '@fastgpt/web/components/common/Icon';
import dynamic from 'next/dynamic';
const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'));
export type EditTeamFormDataType = CreateTeamProps & {
id?: string;
notificationAccount?: string;
};
export const defaultForm = {
@@ -31,12 +43,12 @@ function EditModal({
onSuccess: () => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const { register, setValue, handleSubmit, watch } = useForm<CreateTeamProps>({
defaultValues: defaultData
});
const avatar = watch('avatar');
const notificationAccount = watch('notificationAccount');
const {
File,
@@ -74,6 +86,8 @@ function EditModal({
errorToast: t('common:common.Update Failed')
});
const { isOpen: isOpenContact, onClose: onCloseContact, onOpen: onOpenContact } = useDisclosure();
return (
<MyModal
isOpen
@@ -84,7 +98,7 @@ function EditModal({
>
<ModalBody>
<Box color={'myGray.800'} fontWeight={'bold'}>
{t('user:team.Set Name')}
{t('account_team:set_name_avatar')}
</Box>
<Flex mt={3} alignItems={'center'}>
<MyTooltip label={t('common:common.Set Avatar')}>
@@ -110,6 +124,38 @@ function EditModal({
})}
/>
</Flex>
<Box color={'myGray.800'} fontWeight={'bold'} mt={4}>
{t('account_team:notification_recieve')}
</Box>
<HStack w="full" justifyContent={'space-between'}>
{(() => {
return notificationAccount ? (
<Box width="full">{notificationAccount}</Box>
) : (
<HStack
px="3"
py="1"
color="red.600"
bgColor="red.50"
borderRadius="md"
fontSize={'sm'}
width={'fit-content'}
>
<Icon name="common/info" w="1rem" />
<Box width="fit-content">{t('account_info:please_bind_contact')}</Box>
</HStack>
);
})()}
<Button
variant={'whiteBase'}
leftIcon={<Icon name="common/setting" w="1rem" />}
onClick={() => {
onOpenContact();
}}
>
{t('common:common.Setting')}
</Button>
</HStack>
</ModalBody>
<ModalFooter>
@@ -142,6 +188,14 @@ function EditModal({
})
}
/>
{isOpenContact && (
<UpdateContact
onClose={() => {
onCloseContact();
}}
mode="notification_account"
/>
)}
</MyModal>
);
}

View File

@@ -148,7 +148,7 @@ function GroupEditModal({ onClose, editGroupId }: { onClose: () => void; editGro
gridTemplateColumns="1fr 1fr"
h={'100%'}
>
<Flex flexDirection="column" p="4">
<Flex flexDirection="column" p="4" overflowY={'auto'} overflowX={'hidden'}>
<SearchInput
placeholder={t('user:search_user')}
fontSize="sm"
@@ -157,7 +157,7 @@ function GroupEditModal({ onClose, editGroupId }: { onClose: () => void; editGro
setSearchKey(e.target.value);
}}
/>
<MemberScrollData mt={3} flexGrow="1" overflow={'auto'} maxH={'400px'}>
<MemberScrollData mt={3} flexGrow="1" overflow={'auto'}>
{filtered.map((member) => {
return (
<HStack

View File

@@ -11,15 +11,15 @@ import {
Th,
Thead,
Tr,
useDisclosure
useDisclosure,
VStack
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { delRemoveMember } from '@/web/support/user/team/api';
import { delRemoveMember, updateStatus } from '@/web/support/user/team/api';
import Tag from '@fastgpt/web/components/common/Tag';
import Icon from '@fastgpt/web/components/common/Icon';
import GroupTags from '@/components/support/permission/Group/GroupTags';
import { useContextSelector } from 'use-context-selector';
import { TeamContext } from './context';
import { useSystemStore } from '@/web/common/system/useSystemStore';
@@ -29,9 +29,17 @@ import { useToast } from '@fastgpt/web/hooks/useToast';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { delLeaveTeam } from '@/web/support/user/team/api';
import { postSyncMembers } from '@/web/support/user/api';
import { GetSearchUserGroupOrg, postSyncMembers } from '@/web/support/user/api';
import MyLoading from '@fastgpt/web/components/common/MyLoading';
import { TeamMemberRoleEnum } from '@fastgpt/global/support/user/team/constant';
import {
TeamMemberRoleEnum,
TeamMemberStatusEnum
} from '@fastgpt/global/support/user/team/constant';
import format from 'date-fns/format';
import OrgTags from '@/components/support/user/team/OrgTags';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import { useState } from 'react';
import { downloadFetch } from '@/web/common/system/utils';
const InviteModal = dynamic(() => import('./InviteModal'));
const TeamTagModal = dynamic(() => import('@/components/support/user/team/TeamTagModal'));
@@ -43,14 +51,14 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
const { feConfigs, setNotSufficientModalType } = useSystemStore();
const {
groups,
refetchGroups,
myTeams,
refetchTeams,
members,
refetchMembers,
onSwitchTeam,
MemberScrollData
MemberScrollData,
orgs
} = useContextSelector(TeamContext, (v) => v);
const {
@@ -64,8 +72,25 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
type: 'delete'
});
const { ConfirmModal: ConfirmRestoreMemberModal, openConfirm: openRestoreMember } = useConfirm({
type: 'common',
title: t('account_team:restore_tip_title'),
iconSrc: 'common/confirm/restoreTip',
iconColor: 'primary.500'
});
const [searchText, setSearchText] = useState<string>('');
const isSyncMember = feConfigs.register_method?.includes('sync');
const { data: searchMembersData } = useRequest2(
() => GetSearchUserGroupOrg(searchText, { members: true, orgs: false, groups: false }),
{
manual: false,
throttleWait: 500,
refreshDeps: [searchText]
}
);
const { runAsync: onLeaveTeam } = useRequest2(
async () => {
const defaultTeam = myTeams.find((item) => item.defaultTeam) || myTeams[0];
@@ -92,12 +117,28 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
errorToast: t('account_team:sync_member_failed')
});
const { runAsync: onRestore, loading: isUpdateInvite } = useRequest2(updateStatus, {
onSuccess() {
refetchMembers();
},
successToast: t('common:user.team.invite.Accepted'),
errorToast: t('common:user.team.invite.Reject')
});
const isLoading = isUpdateInvite || isSyncing;
return (
<>
{isSyncing && <MyLoading />}
{isLoading && <MyLoading />}
<Flex justify={'space-between'} align={'center'} pb={'1rem'}>
{Tabs}
<HStack>
<HStack alignItems={'center'}>
<Box width={'200px'}>
<SearchInput
placeholder={t('account_team:search_member')}
onChange={(e) => setSearchText(e.target.value)}
/>
</Box>
{userInfo?.team.permission.hasManagePer && feConfigs?.show_team_chat && (
<Button
variant={'whitePrimary'}
@@ -165,6 +206,23 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
{t('account_team:user_team_leave_team')}
</Button>
)}
{userInfo?.team.permission.hasManagePer && (
<Button
variant={'whitePrimary'}
size="md"
borderRadius={'md'}
ml={3}
leftIcon={<MyIcon name="export" w={'16px'} />}
onClick={() => {
downloadFetch({
url: '/api/proApi/support/user/team/member/export',
filename: `${userInfo.team.teamName}-${format(new Date(), 'yyyyMMddHHmmss')}.csv`
});
}}
>
{t('account_team:export_members')}
</Button>
)}
</HStack>
</Flex>
@@ -177,45 +235,66 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
<Th borderLeftRadius="6px" bgColor="myGray.100">
{t('account_team:user_name')}
</Th>
<Th bgColor="myGray.100">{t('account_team:member_group')}</Th>
{!isSyncMember && (
<Th bgColor="myGray.100">{t('account_team:contact')}</Th>
<Th bgColor="myGray.100">{t('account_team:org')}</Th>
<Th bgColor="myGray.100">{t('account_team:join_update_time')}</Th>
<Th borderRightRadius="6px" bgColor="myGray.100">
{t('common:common.Action')}
</Th>
)}
</Tr>
</Thead>
<Tbody>
{members?.map((item) => (
<Tr key={item.userId} overflow={'unset'}>
{(searchText && searchMembersData ? searchMembersData.members : members).map(
(member) => (
<Tr key={member.tmbId} overflow={'unset'}>
<Td>
<HStack>
<Avatar src={item.avatar} w={['18px', '22px']} borderRadius={'50%'} />
<Avatar src={member.avatar} w={['18px', '22px']} borderRadius={'50%'} />
<Box className={'textEllipsis'}>
{item.memberName}
{item.status === 'waiting' && (
{member.memberName}
{member.status === 'waiting' && (
<Tag ml="2" colorSchema="yellow">
{t('account_team:waiting')}
</Tag>
)}
{member.status === 'leave' && (
<Tag ml="2" colorSchema="gray">
{t('account_team:leave')}
</Tag>
)}
</Box>
</HStack>
</Td>
<Td maxW={'300px'}>
<GroupTags
names={groups
?.filter((group) =>
group.members.map((m) => m.tmbId).includes(item.tmbId)
)
.map((g) => g.name)}
max={3}
/>
<Td maxW={'300px'}>{member.contact || '-'}</Td>
<Td maxWidth="300px">
{(() => {
const memberOrgs = orgs.filter((org) =>
org.members.find((v) => String(v.tmbId) === String(member.tmbId))
);
const memberPathIds = memberOrgs.map((org) =>
(org.path + '/' + org.pathId).split('/').slice(0)
);
const memberOrgNames = memberPathIds.map((pathIds) =>
pathIds.map((id) => orgs.find((v) => v.pathId === id)?.name).join('/')
);
return <OrgTags orgs={memberOrgNames} type="tag" />;
})()}
</Td>
<Td maxW={'300px'}>
<VStack gap={0}>
<Box>{format(new Date(member.createTime), 'yyyy-MM-dd HH:mm:ss')}</Box>
<Box>
{member.updateTime
? format(new Date(member.updateTime), 'yyyy-MM-dd HH:mm:ss')
: '-'}
</Box>
</VStack>
</Td>
{!isSyncMember && (
<Td>
{userInfo?.team.permission.hasManagePer &&
item.role !== TeamMemberRoleEnum.owner &&
item.tmbId !== userInfo?.team.tmbId && (
member.role !== TeamMemberRoleEnum.owner &&
member.tmbId !== userInfo?.team.tmbId &&
(member.status !== TeamMemberStatusEnum.leave ? (
<Icon
name={'common/trash'}
cursor={'pointer'}
@@ -229,24 +308,50 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
onClick={() => {
openRemoveMember(
() =>
delRemoveMember(item.tmbId).then(() =>
delRemoveMember(member.tmbId).then(() =>
Promise.all([refetchGroups(), refetchMembers()])
),
undefined,
t('account_team:remove_tip', {
username: item.memberName
username: member.memberName
})
)();
}}
/>
) : (
<Icon
name={'common/confirm/restoreTip'}
cursor={'pointer'}
w="1rem"
p="1"
borderRadius="sm"
_hover={{
color: 'primary.500',
bgColor: 'myGray.100'
}}
onClick={() => {
openRestoreMember(
() =>
onRestore({
tmbId: member.tmbId,
status: TeamMemberStatusEnum.active
}),
undefined,
t('account_team:restore_tip', {
username: member.memberName
})
)();
}}
/>
)}
</Td>
)}
</Tr>
))}
</Td>
</Tr>
)
)}
</Tbody>
</Table>
<ConfirmRemoveMemberModal />
<ConfirmRestoreMemberModal />
</TableContainer>
</MemberScrollData>
</Box>

View File

@@ -112,7 +112,7 @@ function OrgMemberManageModal({
gridTemplateColumns="1fr 1fr"
h={'100%'}
>
<Flex flexDirection="column" p="4">
<Flex flexDirection="column" p="4" overflowY="auto" overflowX="hidden">
<SearchInput
placeholder={t('user:search_user')}
fontSize="sm"
@@ -121,7 +121,7 @@ function OrgMemberManageModal({
setSearchKey(e.target.value);
}}
/>
<MemberScrollData mt={3} flexGrow="1" overflow={'auto'} maxH={'400px'}>
<MemberScrollData mt={3} flexGrow="1" overflow={'auto'}>
{filterMembers.map((member) => {
return (
<HStack

View File

@@ -37,6 +37,8 @@ import Path from '@/components/common/folder/Path';
import { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type';
import { getOrgChildrenPath } from '@fastgpt/global/support/user/team/org/constant';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { delRemoveMember } from '@/web/support/user/team/api';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
const OrgInfoModal = dynamic(() => import('./OrgInfoModal'));
const OrgMemberManageModal = dynamic(() => import('./OrgMemberManageModal'));
@@ -74,8 +76,9 @@ function ActionButton({
function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
const { t } = useTranslation();
const { userInfo, isTeamAdmin } = useUserStore();
const [searchOrg, setSearchOrg] = useState('');
const { members, MemberScrollData } = useContextSelector(TeamContext, (v) => v);
const { members, MemberScrollData, refetchMembers } = useContextSelector(TeamContext, (v) => v);
const { feConfigs } = useSystemStore();
const isSyncMember = feConfigs.register_method?.includes('sync');
@@ -88,6 +91,7 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
manual: false,
refreshDeps: [userInfo?.team?.teamId]
});
const currentOrgs = useMemo(() => {
if (orgs.length === 0) return [];
// Auto select the first org(root org is team)
@@ -107,6 +111,7 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
};
});
}, [orgs, parentPath]);
const currentOrg = useMemo(() => {
const splitPath = parentPath.split('/');
const currentOrgId = splitPath[splitPath.length - 1];
@@ -121,7 +126,7 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
.map((id) => {
const org = orgs.find((org) => org.pathId === id)!;
if (org.path === '') return;
if (org?.path === '') return;
return {
parentId: getOrgChildrenPath(org),
@@ -148,20 +153,52 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
});
// Delete member
const { ConfirmModal: ConfirmDeleteMember, openConfirm: openDeleteMemberModal } = useConfirm({
type: 'delete',
content: t('account_team:confirm_delete_member')
const { ConfirmModal: ConfirmDeleteMemberFromOrg, openConfirm: openDeleteMemberFromOrgModal } =
useConfirm({
type: 'delete'
});
const { ConfirmModal: ConfirmDeleteMemberFromTeam, openConfirm: openDeleteMemberFromTeamModal } =
useConfirm({
type: 'delete'
});
const { runAsync: deleteMemberReq } = useRequest2(deleteOrgMember, {
onSuccess: () => {
refetchOrgs();
}
});
const { runAsync: deleteMemberFromTeamReq } = useRequest2(delRemoveMember, {
onSuccess: () => {
refetchOrgs();
refetchMembers();
}
});
const searchedOrgs = useMemo(() => {
if (!searchOrg) return [];
return orgs
.filter((org) => org.name.includes(searchOrg))
.map((org) => ({
...org,
count:
org.members.length + orgs.filter((org) => org.path === getOrgChildrenPath(org)).length
}));
}, [orgs, searchOrg]);
return (
<>
<Flex justify={'space-between'} align={'center'} pb={'1rem'}>
{Tabs}
<Box w="200px">
<SearchInput
placeholder={t('account_team:search_org')}
value={searchOrg}
onChange={(e) => setSearchOrg(e.target.value)}
/>
</Box>
</Flex>
<MyBox
flex={'1 0 0'}
@@ -183,20 +220,48 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
<Th bg="myGray.100" borderLeftRadius="6px">
{t('common:Name')}
</Th>
{!isSyncMember && (
<Th bg="myGray.100" borderRightRadius="6px">
{t('common:common.Action')}
</Th>
)}
</Tr>
</Thead>
<Tbody>
{currentOrgs.map((org) => (
{searchedOrgs.map((org) => (
<Tr
key={org._id}
overflow={'unset'}
onClick={() => setParentPath(getOrgChildrenPath(org))}
>
<Td>
<HStack
cursor={'pointer'}
onClick={() => {
setParentPath(getOrgChildrenPath(org));
setSearchOrg('');
}}
>
<MemberTag name={org.name} avatar={org.avatar} />
<Tag size="sm">{org.count}</Tag>
<MyIcon
name="core/chat/chevronRight"
w={'1rem'}
h={'1rem'}
color={'myGray.500'}
/>
</HStack>
</Td>
</Tr>
))}
{!searchOrg &&
currentOrgs.map((org) => (
<Tr key={org._id} overflow={'unset'}>
<Td>
<HStack
cursor={'pointer'}
onClick={() => setParentPath(getOrgChildrenPath(org))}
onClick={() => {
setParentPath(getOrgChildrenPath(org));
setSearchOrg('');
}}
>
<MemberTag name={org.name} avatar={org.avatar} />
<Tag size="sm">{org.count}</Tag>
@@ -240,7 +305,8 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
)}
</Tr>
))}
{currentOrg?.members.map((member) => {
{!searchOrg &&
currentOrg?.members.map((member) => {
const memberInfo = members.find((m) => m.tmbId === member.tmbId);
if (!memberInfo) return null;
@@ -250,7 +316,7 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
<MemberTag name={memberInfo.memberName} avatar={memberInfo.avatar} />
</Td>
<Td w={'6rem'}>
{isTeamAdmin && !isSyncMember && (
{isTeamAdmin && (
<MyMenu
trigger={'hover'}
Button={<IconButton name="more" />}
@@ -258,14 +324,47 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
{
children: [
{
icon: 'delete',
label: t('account_team:delete'),
type: 'danger',
menuItemStyles: {
_hover: {
color: 'red.600',
backgroundColor: 'red.50'
}
},
label: t('account_team:delete_from_team', {
username: memberInfo.memberName
}),
onClick: () => {
openDeleteMemberFromTeamModal(
() => deleteMemberFromTeamReq(member.tmbId),
undefined,
t('account_team:confirm_delete_from_team', {
username: memberInfo.memberName
})
)();
}
},
...(isSyncMember
? []
: [
{
menuItemStyles: {
_hover: {
color: 'red.600',
bgColor: 'red.50'
}
},
label: t('account_team:delete_from_org'),
onClick: () =>
openDeleteMemberModal(() =>
deleteMemberReq(currentOrg._id, member.tmbId)
openDeleteMemberFromOrgModal(
() =>
deleteMemberReq(currentOrg._id, member.tmbId),
undefined,
t('account_team:confirm_delete_from_org', {
username: memberInfo.memberName
})
)()
}
])
]
}
]}
@@ -361,7 +460,8 @@ function OrgTable({ Tabs }: { Tabs: React.ReactNode }) {
)}
<ConfirmDeleteOrgModal />
<ConfirmDeleteMember />
<ConfirmDeleteMemberFromOrg />
<ConfirmDeleteMemberFromTeam />
</>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import {
Box,
Checkbox,
@@ -40,7 +40,8 @@ import CollaboratorContextProvider, {
} from '@/components/support/permission/MemberManager/context';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useContextSelector } from 'use-context-selector';
import { CollaboratorItemType } from '@fastgpt/global/support/permission/collaborator';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import { GetSearchUserGroupOrg } from '@/web/support/user/api';
function PermissionManage({
Tabs,
@@ -69,27 +70,37 @@ function PermissionManage({
const [isExpandGroup, setExpandGroup] = useToggle(true);
const [isExpandOrg, setExpandOrg] = useToggle(true);
const { tmbList, groupList, orgList } = useMemo(() => {
const tmbList: CollaboratorItemType[] = [];
const groupList: CollaboratorItemType[] = [];
const orgList: CollaboratorItemType[] = [];
const [searchKey, setSearchKey] = useState('');
collaboratorList.forEach((item) => {
if (item.tmbId) {
tmbList.push(item);
} else if (item.groupId) {
groupList.push(item);
} else if (item.orgId) {
orgList.push(item);
}
const { data: searchResult } = useRequest2(() => GetSearchUserGroupOrg(searchKey), {
manual: false,
throttleWait: 500,
refreshDeps: [searchKey]
});
const { tmbList, groupList, orgList } = useMemo(() => {
const tmbList = collaboratorList.filter(
(item) =>
Object.keys(item).includes('tmbId') &&
(!searchKey || searchResult?.members.find((member) => member.tmbId === item.tmbId))
);
const groupList = collaboratorList.filter(
(item) =>
Object.keys(item).includes('groupId') &&
(!searchKey || searchResult?.groups.find((group) => group.groupId === item.groupId))
);
const orgList = collaboratorList.filter(
(item) =>
Object.keys(item).includes('orgId') &&
(!searchKey || searchResult?.orgs.find((org) => org._id === item.orgId))
);
return {
tmbList,
groupList,
orgList
};
}, [collaboratorList]);
}, [collaboratorList, searchResult, searchKey]);
const { runAsync: onUpdatePermission, loading: addLoading } = useRequest2(
async ({ id, type, per }: { id: string; type: 'add' | 'remove'; per: 'write' | 'manage' }) => {
@@ -131,12 +142,12 @@ function PermissionManage({
<Flex justify={'space-between'} align={'center'} pb={'1rem'}>
{Tabs}
<Box ml="auto">
{/* <SearchInput
<SearchInput
placeholder={t('user:search_group_org_user')}
w="200px"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
/> */}
/>
</Box>
{userInfo?.team.permission.hasManagePer && (
<Button

View File

@@ -11,6 +11,8 @@ import { useTranslation } from 'next-i18next';
import { getGroupList } from '@/web/support/user/team/group/api';
import { MemberGroupListType } from '@fastgpt/global/support/permission/memberGroup/type';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
import { getOrgList } from '@/web/support/user/team/org/api';
import { OrgType } from '@fastgpt/global/support/user/team/org/type';
const EditInfoModal = dynamic(() => import('./EditInfoModal'));
@@ -18,6 +20,7 @@ type TeamModalContextType = {
myTeams: TeamTmbItemType[];
members: TeamMemberItemType[];
groups: MemberGroupListType;
orgs: OrgType[];
isLoading: boolean;
onSwitchTeam: (teamId: string) => void;
setEditTeamData: React.Dispatch<React.SetStateAction<EditTeamFormDataType | undefined>>;
@@ -25,6 +28,7 @@ type TeamModalContextType = {
refetchMembers: () => void;
refetchTeams: () => void;
refetchGroups: () => void;
refetchOrgs: () => void;
teamSize: number;
MemberScrollData: ReturnType<typeof useScrollPagination>['ScrollData'];
};
@@ -33,6 +37,7 @@ export const TeamContext = createContext<TeamModalContextType>({
myTeams: [],
groups: [],
members: [],
orgs: [],
isLoading: false,
onSwitchTeam: function (_teamId: string): void {
throw new Error('Function not implemented.');
@@ -49,7 +54,9 @@ export const TeamContext = createContext<TeamModalContextType>({
refetchGroups: function (): void {
throw new Error('Function not implemented.');
},
refetchOrgs: function (): void {
throw new Error('Function not implemented.');
},
teamSize: 0,
MemberScrollData: () => <></>
});
@@ -68,6 +75,15 @@ export const TeamModalContextProvider = ({ children }: { children: ReactNode })
refreshDeps: [userInfo?._id]
});
const {
data: orgs = [],
loading: isLoadingOrgs,
refresh: refetchOrgs
} = useRequest2(getOrgList, {
manual: false,
refreshDeps: [userInfo?.team?.teamId]
});
// member action
const {
data: members = [],
@@ -75,7 +91,12 @@ export const TeamModalContextProvider = ({ children }: { children: ReactNode })
refreshList: refetchMembers,
total: memberTotal,
ScrollData: MemberScrollData
} = useScrollPagination(getTeamMembers, {});
} = useScrollPagination(getTeamMembers, {
pageSize: 20,
params: {
withLeaved: true
}
});
const { runAsync: onSwitchTeam, loading: isSwitchingTeam } = useRequest2(
async (teamId: string) => {
@@ -96,13 +117,16 @@ export const TeamModalContextProvider = ({ children }: { children: ReactNode })
refreshDeps: [userInfo?.team?.teamId]
});
const isLoading = isLoadingTeams || isSwitchingTeam || loadingMembers || isLoadingGroups;
const isLoading =
isLoadingTeams || isSwitchingTeam || loadingMembers || isLoadingGroups || isLoadingOrgs;
const contextValue = {
myTeams,
refetchTeams,
isLoading,
onSwitchTeam,
orgs,
refetchOrgs,
// create | update team
setEditTeamData,

View File

@@ -111,7 +111,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
provider: OAuthEnum.wecom,
icon: 'common/wecom',
redirectUrl: isWecomWorkTerminal
? `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${feConfigs?.oauth?.wecom?.corpid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&agentid=${feConfigs?.oauth?.wecom?.agentid}&state=${state.current}#wechat_redirect`
? `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${feConfigs?.oauth?.wecom?.corpid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_privateinfo&agentid=${feConfigs?.oauth?.wecom?.agentid}&state=${state.current}#wechat_redirect`
: `https://login.work.weixin.qq.com/wwlogin/sso/login?login_type=CorpApp&appid=${feConfigs?.oauth?.wecom?.corpid}&agentid=${feConfigs?.oauth?.wecom?.agentid}&redirect_uri=${redirectUri}&state=${state.current}`
}
]

View File

@@ -49,9 +49,7 @@ const StandDetailModal = dynamic(
);
const ConversionModal = dynamic(() => import('@/pageComponents/account/info/ConversionModal'));
const UpdatePswModal = dynamic(() => import('@/pageComponents/account/info/UpdatePswModal'));
const UpdateNotification = dynamic(
() => import('@/components/support/user/inform/UpdateNotificationModal')
);
const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'));
const CommunityModal = dynamic(() => import('@/components/CommunityModal'));
const ModelPriceModal = dynamic(() =>
@@ -129,9 +127,9 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
onOpen: onOpenUpdatePsw
} = useDisclosure();
const {
isOpen: isOpenUpdateNotification,
onClose: onCloseUpdateNotification,
onOpen: onOpenUpdateNotification
isOpen: isOpenUpdateContact,
onClose: onCloseUpdateContact,
onOpen: onOpenUpdateContact
} = useDisclosure();
const {
File,
@@ -259,25 +257,14 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
)}
{feConfigs?.isPlus && (
<Flex mt={6} alignItems={'center'}>
<Box {...labelStyles}>{t('account_info:notification_receiving')}:&nbsp;</Box>
<Box
flex={1}
{...(!userInfo?.team.notificationAccount && userInfo?.permission.isOwner
? { color: 'red.600' }
: {})}
>
{userInfo?.team.notificationAccount
? userInfo?.team.notificationAccount
: userInfo?.permission.isOwner
? t('account_info:please_bind_notification_receiving_path')
: t('account_info:reminder_create_bound_notification_account')}
<Box {...labelStyles}>{t('account_info:contact')}:&nbsp;</Box>
<Box flex={1} {...(!userInfo?.contact ? { color: 'red.600' } : {})}>
{userInfo?.contact ? userInfo?.contact : t('account_info:please_bind_contact')}
</Box>
{userInfo?.permission.isOwner && (
<Button size={'sm'} variant={'whitePrimary'} onClick={onOpenUpdateNotification}>
<Button size={'sm'} variant={'whitePrimary'} onClick={onOpenUpdateContact}>
{t('account_info:change')}
</Button>
)}
</Flex>
)}
{feConfigs.isPlus && (
@@ -310,7 +297,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
<ConversionModal onClose={onCloseConversionModal} onOpenContact={onOpenContact} />
)}
{isOpenUpdatePsw && <UpdatePswModal onClose={onCloseUpdatePsw} />}
{isOpenUpdateNotification && <UpdateNotification onClose={onCloseUpdateNotification} />}
{isOpenUpdateContact && <UpdateContact onClose={onCloseUpdateContact} mode="contact" />}
<File
onSelect={(e) =>
onSelectImage(e, {

View File

@@ -104,7 +104,8 @@ const Team = () => {
setEditTeamData({
id: userInfo.team.teamId,
name: userInfo.team.teamName,
avatar: userInfo.team.avatar
avatar: userInfo.team.avatar,
notificationAccount: userInfo.team.notificationAccount
});
}}
/>

View File

@@ -0,0 +1,29 @@
import { NextAPI } from '@/service/middleware/entry';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { MongoUser } from '@fastgpt/service/support/user/schema';
import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema';
import { NextApiRequest, NextApiResponse } from 'next';
/*
* 复制 Team 表中的 notificationAccount 到 User 表的 contact 中
*/
async function handler(req: NextApiRequest, _res: NextApiResponse) {
await authCert({ req, authRoot: true });
const users = await MongoUser.find();
const teams = await MongoTeam.find();
for await (const user of users) {
try {
const team = teams.find((team) => String(team.ownerId) === String(user._id));
if (team && !user.contact) {
user.contact = team.notificationAccount;
}
await user.save();
} catch (error) {
console.error(error);
}
}
return { success: true };
}
export default NextAPI(handler);

View File

@@ -7,7 +7,8 @@ import { UserType } from '@fastgpt/global/support/user/type.d';
import type {
FastLoginProps,
OauthLoginProps,
PostLoginProps
PostLoginProps,
SearchResult
} from '@fastgpt/global/support/user/api.d';
import {
AccountRegisterBody,
@@ -70,6 +71,10 @@ export const updatePasswordByOld = ({ oldPsw, newPsw }: { oldPsw: string; newPsw
export const updateNotificationAccount = (data: { account: string; verifyCode: string }) =>
PUT('/proApi/support/user/team/updateNotificationAccount', data);
export const updateContact = (data: { contact: string; verifyCode: string }) => {
return PUT('/proApi/support/user/account/updateContact', data);
};
export const postLogin = ({ password, ...props }: PostLoginProps) =>
POST<ResLogin>('/support/user/account/loginByPassword', {
...props,
@@ -92,3 +97,14 @@ export const getCaptchaPic = (username: string) =>
}>('/proApi/support/user/account/captcha/getImgCaptcha', { username });
export const postSyncMembers = () => POST('/proApi/support/user/team/org/sync');
export const GetSearchUserGroupOrg = (
searchKey: string,
options?: {
members?: boolean;
orgs?: boolean;
groups?: boolean;
}
) => GET<SearchResult>('/proApi/support/user/search', { searchKey, ...options });
export const ExportMembers = () => GET<{ csv: string }>('/proApi/support/user/team/member/export');

View File

@@ -9,6 +9,7 @@ import {
InviteMemberProps,
InviteMemberResponse,
UpdateInviteProps,
UpdateStatusProps,
UpdateTeamProps
} from '@fastgpt/global/support/user/team/controller.d';
import type { TeamTagItemType, TeamTagSchema } from '@fastgpt/global/support/user/team/type';
@@ -31,7 +32,7 @@ export const putSwitchTeam = (teamId: string) =>
PUT<string>(`/proApi/support/user/team/switch`, { teamId });
/* --------------- team member ---------------- */
export const getTeamMembers = (props: PaginationProps) =>
export const getTeamMembers = (props: PaginationProps<{ withLeaved?: boolean }>) =>
GET<PaginationResponse<TeamMemberItemType>>(`/proApi/support/user/team/member/list`, props);
export const postInviteTeamMember = (data: InviteMemberProps) =>
POST<InviteMemberResponse>(`/proApi/support/user/team/member/invite`, data);
@@ -41,6 +42,8 @@ export const delRemoveMember = (tmbId: string) =>
DELETE(`/proApi/support/user/team/member/delete`, { tmbId });
export const updateInviteResult = (data: UpdateInviteProps) =>
PUT('/proApi/support/user/team/member/updateInvite', data);
export const updateStatus = (data: UpdateStatusProps) =>
PUT('/proApi/support/user/team/member/updateStatus', data);
export const delLeaveTeam = () => DELETE('/proApi/support/user/team/member/leave');
/* -------------- team collaborator -------------------- */