diff --git a/src/api/space-user.ts b/src/api/space-user.ts index 80a50bc..5983d39 100644 --- a/src/api/space-user.ts +++ b/src/api/space-user.ts @@ -97,6 +97,12 @@ export const listSpaceUserMembers = ( export const removeSpaceUserMembers = (spaceId: string, uids: string[]) => api.post(`/v1/space/${spaceId}/members/remove`, { uids }) +export const updateSpaceUserMemberRole = ( + spaceId: string, + uid: string, + role: SpaceUserMember['role'], +) => api.put(`/v1/space/${spaceId}/members/${uid}/role`, { role }) + export const createSpaceUserInvite = ( spaceId: string, data: { max_uses?: number; expires_at?: string } = {}, diff --git a/src/hooks/useSpaceScope.ts b/src/hooks/useSpaceScope.ts index 8a47164..f691f9e 100644 --- a/src/hooks/useSpaceScope.ts +++ b/src/hooks/useSpaceScope.ts @@ -4,7 +4,8 @@ import * as manager from '../api/space' import * as user from '../api/space-user' export type SpaceScopeKind = 'super' | 'space' -export type SpaceScopeRole = 'super' | 0 | 1 | 2 +export type SpaceMemberRole = 0 | 1 | 2 +export type SpaceScopeRole = 'super' | SpaceMemberRole export interface InviteListItem { invite_code: string @@ -25,7 +26,7 @@ export interface InviteListResp { export interface MemberItem { uid: string name: string - role: 0 | 1 | 2 + role: SpaceMemberRole status?: number robot?: 0 | 1 created_at?: string @@ -85,6 +86,7 @@ export interface SpaceScope { listMembers: (spaceId: string, params: ScopedMemberParams) => Promise addMembers?: (spaceId: string, uids: string[]) => Promise removeMembers: (spaceId: string, uids: string[]) => Promise + updateMemberRole: (spaceId: string, uid: string, role: SpaceMemberRole) => Promise listInvites: (spaceId: string, params: ScopedInviteParams) => Promise createInvite: ( spaceId: string, @@ -123,6 +125,8 @@ function buildSuperScope(): SpaceScope { }, addMembers: (spaceId, uids) => manager.addSpaceMembers(spaceId, uids), removeMembers: (spaceId, uids) => manager.removeSpaceMembers(spaceId, uids), + updateMemberRole: (spaceId, uid, role) => + manager.updateSpaceMemberRole(spaceId, uid, role), listInvites: async (spaceId, params) => { const res = await manager.listSpaceInvites(spaceId, { page_index: params.page_index, @@ -153,7 +157,7 @@ function buildSuperScope(): SpaceScope { } } -function buildUserScope(role: 0 | 1 | 2): SpaceScope { +function buildUserScope(role: SpaceMemberRole): SpaceScope { const isManager = role >= 1 return { kind: 'space', @@ -192,6 +196,8 @@ function buildUserScope(role: 0 | 1 | 2): SpaceScope { } }, removeMembers: (spaceId, uids) => user.removeSpaceUserMembers(spaceId, uids), + updateMemberRole: (spaceId, uid, nextRole) => + user.updateSpaceUserMemberRole(spaceId, uid, nextRole), listInvites: async (spaceId, params) => { const resp = await user.listSpaceUserInvites(spaceId, params) return { count: resp.count, list: resp.list } @@ -230,7 +236,7 @@ function buildUserScope(role: 0 | 1 | 2): SpaceScope { } } -export function useSpaceScope(role?: 0 | 1 | 2): SpaceScope { +export function useSpaceScope(role?: SpaceMemberRole): SpaceScope { const scope = useAuthStore((s) => s.scope) return useMemo(() => { if (scope === 'super') return buildSuperScope() diff --git a/src/pages/SpaceAdmin/SpaceAdminLayout.tsx b/src/pages/SpaceAdmin/SpaceAdminLayout.tsx index 75ff005..e93517d 100644 --- a/src/pages/SpaceAdmin/SpaceAdminLayout.tsx +++ b/src/pages/SpaceAdmin/SpaceAdminLayout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Outlet, useNavigate, useParams, useLocation } from 'react-router-dom' import { @@ -156,6 +156,27 @@ export default function SpaceAdminLayout() { } }, [spaceId, currentSpaceId, setCurrentSpaceId, setMySpaces, navigate]) + // 角色变更等操作后重新校验当前用户在本空间的角色,避免 scope.role 过期 + // (例如 owner 转让所有权后被降级为 admin,角色操作应及时消失)。 + const refreshDetail = useCallback(async () => { + if (!spaceId) return + try { + const [list, d] = await Promise.all([ + getMySpaces().catch(() => [] as MySpace[]), + getSpaceUserDetail(spaceId), + ]) + const managed = (list || []).filter((s) => s.role >= 1) + setMySpaces(managed) + if (!managed.some((s) => s.space_id === spaceId)) { + navigate('/space', { replace: true }) + return + } + setDetail(d) + } catch (error) { + message.error((error as Error).message) + } + }, [spaceId, setMySpaces, navigate]) + const handleLogout = () => { logout() navigate('/login') @@ -465,7 +486,7 @@ export default function SpaceAdminLayout() { destroyInactiveTabPane tabBarStyle={{ marginBottom: 16 }} /> - + )} diff --git a/src/pages/SpaceAdmin/tabs.tsx b/src/pages/SpaceAdmin/tabs.tsx index 58f4a71..7ffd4cb 100644 --- a/src/pages/SpaceAdmin/tabs.tsx +++ b/src/pages/SpaceAdmin/tabs.tsx @@ -8,18 +8,25 @@ import AppBotsPage from '../AppBots' interface Ctx { detail: SpaceUserDetail + refreshDetail: () => void } function useSpaceCtx() { const { spaceId } = useParams<{ spaceId: string }>() - const { detail } = useOutletContext() + const { detail, refreshDetail } = useOutletContext() const scope = useSpaceScope(detail.role) - return { spaceId: spaceId!, detail, scope } + return { spaceId: spaceId!, detail, scope, refreshDetail } } export function MembersTab() { - const { spaceId, scope } = useSpaceCtx() - return + const { spaceId, scope, refreshDetail } = useSpaceCtx() + return ( + + ) } export function InvitesTab() { diff --git a/src/pages/Spaces/SpaceMembersPanel.tsx b/src/pages/Spaces/SpaceMembersPanel.tsx index 52e3ceb..b33ba09 100644 --- a/src/pages/Spaces/SpaceMembersPanel.tsx +++ b/src/pages/Spaces/SpaceMembersPanel.tsx @@ -14,13 +14,18 @@ import type { ColumnsType } from 'antd/es/table' import { useTranslation } from 'react-i18next' const { Text } = Typography -import { updateSpaceMemberRole, type SpaceMemberRole } from '../../api/space' -import type { MemberItem, SpaceScope } from '../../hooks/useSpaceScope' +import { useAuthStore } from '../../store/auth' +import type { + MemberItem, + SpaceScope, + SpaceMemberRole, +} from '../../hooks/useSpaceScope' interface Props { spaceId: string scope: SpaceScope readOnly?: boolean + onRoleChanged?: () => void } const ROLE_LABEL: Record = { @@ -31,8 +36,9 @@ const ROLE_LABEL: Record s.uid) const [loading, setLoading] = useState(false) const [data, setData] = useState([]) const [total, setTotal] = useState(0) @@ -44,7 +50,7 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: const canAdd = !readOnly && scope.canAddMembers const canRemove = !readOnly && scope.canRemoveMembers - const canChangeRole = !readOnly && scope.kind === 'super' + const canChangeRole = !readOnly && (scope.kind === 'super' || scope.role === 2) const fetchData = async (nextPage = page, kw = keyword) => { setLoading(true) @@ -84,9 +90,12 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: : undefined, onOk: async () => { try { - await updateSpaceMemberRole(spaceId, uid, role) + await scope.api.updateMemberRole(spaceId, uid, role) message.success(t('members.changeRole.success')) fetchData() + // 角色变更可能改变当前用户自身权限(如转让所有权后被降级), + // 通知上层重新校验 scope,避免操作入口残留到刷新前。 + onRoleChanged?.() } catch (error) { message.error((error as Error).message) } @@ -148,7 +157,9 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: if (record.status !== undefined && record.status !== 1) return const items: MenuProps['items'] = [] - if (canChangeRole) { + // 不对当前登录用户自己的行展示角色操作:owner 无法自降级、 + // 也无需对自己做转让/升降级,这些请求注定被后端拒绝。 + if (canChangeRole && record.uid !== viewerUid) { if (record.role !== 2) { items.push({ key: 'owner',