import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess'; import type { AccountInvitation } from '@/core-bridge/account'; import { errorMessage } from '@/core-bridge/errors'; import type { SenderInfo } from '@/core-bridge/types'; import { useToast } from '@/store/toast'; import { useWorkspace } from '@/store/workspace'; import { formatSeen } from '@/lib/format'; import { OWNER_ROLE_ID, permissionsOf } from '@/lib/roles'; import { useAccount } from '@/store/account'; import { accountApi } from '@/core-bridge/account'; import { Button } from '@/ui/Button'; import { AvatarPreview } from '../common/AvatarPreview'; import { NameWithBadges } from './RoleBadges'; import styles from './MembersPane.module.css'; interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void } function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) { return person?.avatar ? : {Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}; } function Section({ title, count, children }: { title: string; count: number; children: ReactNode }) { return count ? {title} — {count}{children} : null; } /** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */ const PRESENCE_POLL_MS = 8000; export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { const channelId = useWorkspace((s) => s.channel?.id); const maxMembers = useWorkspace((s) => s.channel?.maxMembers); const people = useWorkspace((s) => s.people); const roles = useWorkspace((s) => s.roles); const creator = useWorkspace((s) => s.channel?.creatorAccount); const self = useAccount((s) => s.username); const listInvitations = useWorkspace((s) => s.listInvitations); const revoke = useWorkspace((s) => s.revokeInvitation); const [members, setMembers] = useState([]); const [pending, setPending] = useState([]); const [error, setError] = useState(''); const [viewing, setViewing] = useState(); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { if (!channelId) return; try { setMembers(await channelAccessApi.members(channelId)); setPending(canRemove ? await listInvitations() : []); setError(''); } catch { setError('成员清单读取失败'); } }, [channelId, canRemove, listInvitations]); useEffect(() => { void refresh(); // 在线状态不走订阅流,成员页可见期间定时读取 const timer = window.setInterval(() => { void refresh(); }, PRESENCE_POLL_MS); return () => window.clearInterval(timer); }, [refresh, refreshKey]); async function remove(username: string, invited: boolean) { if (!invited && !window.confirm(`移除成员 ${username}?对方的设备会被断开,已读取的内容无法收回。`)) return; setBusy(true); try { await revoke(username); useToast.getState().push({ kind: 'ok', text: invited ? `已撤销对 ${username} 的邀请` : `已移除 ${username}` }); await refresh(); } catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); } finally { setBusy(false); } } const online = members.filter((member) => member.online); const offline = members.filter((member) => !member.online); // 所在的身份组名(空间主在固定的 Owner 组里);待接受的邀请还没有身份 const groupsOf = (username: string) => roles.filter((role) => (role.id === OWNER_ROLE_ID ? username === creator : role.members.includes(username))).map((role) => role.name); const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => { const person = people[username]; const name = person?.displayName || username; const groups = invited ? [] : groupsOf(username); return setViewing(username)} /> {name}{groups.map((group) => {group})} {detail || name !== username ? {[name !== username ? username : '', detail].filter(Boolean).join(' · ')} : null} {isOnline ? : null} {removable ? { void remove(username, invited); }}>{invited ? '撤销' : '移除'} : null} ; }; // 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人 const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove'))); // 在线的不用写时间;离线的写最后一次活跃(没登录过设备的老账号没有记录) const member = (item: ChannelMember) => row(item.username, !item.online && item.last_seen > 0 ? `最后在线 ${formatSeen(item.last_seen)}` : undefined, false, removable(item), item.role === 'owner', item.online); const viewed = viewing ? people[viewing] : undefined; return {viewing && viewed?.avatar ? accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null} 成员 {members.length}{maxMembers ? `/${maxMembers}` : ''} {owner ? 邀请成员 : null} {online.map(member)} {offline.map(member)} {pending.map((invitation) => row(invitation.username, undefined, true, owner))} {error ? {error} : null} {!owner ? {canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'} : null} ; }
{error}
{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}