feat(space): 桌面端空间成员管理界面 —— 补齐"临时组队协作"前端闭环

此前能建项目空间/切换/启用全员空间,但邀请队友进空间只有后端 API、无界面,
协作故事前端断了一半。本次补上成员管理 UI,让 Space 协作端到端可用。

- api.ts: spaceMembers/addSpaceMember/setSpaceMemberRole/removeSpaceMember + SpaceMemberInfo
- SpaceMembers 弹窗: 列成员(名/邮箱/角色 badge); 空间 admin/owner 可邀请(邮箱+角色)/
  下拉改角色/移除, owner 受保护(无下拉/移除); 非管理员只读提示。复用 Dialog/Button/Select
- 顶栏加"管理成员"入口(UserCog): 活跃空间为 project/tenant 时显示; App 管弹窗开关,
  传 spaceId/spaceName/canManage(空间角色 admin/owner)/selfUserId

实机验证(Wails 桌面 + 全栈后端): demospace(owner)在项目空间邀请 teammate → 成员列表
即时出现(切换器 1人→2人) → 下拉改 teammate 成员→管理员(服务端确认 owner/admin) →
owner 自身受保护无改删入口。tsc + 48 前端测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-13 14:29:28 +08:00
parent 9dab25a5f0
commit 8f766a75a7
4 changed files with 191 additions and 3 deletions
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from "react";
import { UserPlus, Trash2 } from "lucide-react";
import { Dialog, Button, Input, Select, Badge, useToast } from "../ui";
import { spaceMembers, addSpaceMember, setSpaceMemberRole, removeSpaceMember, type SpaceMemberInfo } from "../lib/api";
const ROLE_LABEL: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读" };
const ROLE_TONE: Record<string, "brand" | "accent" | "neutral" | "warn"> = { owner: "brand", admin: "accent", member: "neutral", viewer: "warn" };
// SpaceMembers 空间成员管理弹窗:列成员;空间 admin/owner 可邀请/改角色/移除(owner 受保护)。
// 非管理员只读。作用于当前活跃空间(spaceId),个人空间不该打开此弹窗(调用方控制)。
export function SpaceMembers({ open, onClose, spaceId, spaceName, canManage, selfUserId }: {
open: boolean;
onClose: () => void;
spaceId: string;
spaceName: string;
canManage: boolean;
selfUserId: string;
}) {
const toast = useToast();
const [rows, setRows] = useState<SpaceMemberInfo[]>([]);
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState("");
const [inviteRole, setInviteRole] = useState("member");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!spaceId) return;
setLoading(true);
try {
setRows(await spaceMembers(spaceId));
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setLoading(false);
}
}, [spaceId, toast]);
useEffect(() => {
if (open) void refresh();
}, [open, refresh]);
const invite = async () => {
const em = email.trim();
if (!em) return;
setBusy(true);
try {
await addSpaceMember(spaceId, em, inviteRole);
setEmail("");
toast.push("success", `已邀请 ${em}`);
await refresh();
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
const changeRole = async (uid: string, role: string) => {
try {
await setSpaceMemberRole(spaceId, uid, role);
await refresh();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const remove = async (m: SpaceMemberInfo) => {
if (!window.confirm(`${m.name || m.email} 移出「${spaceName}」?`)) return;
try {
await removeSpaceMember(spaceId, m.user_id);
toast.push("success", "已移除");
await refresh();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
return (
<Dialog open={open} onClose={onClose} title={`成员 · ${spaceName}`}>
{canManage && (
<div className="mb-3 flex items-end gap-2">
<Input
className="flex-1"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="邀请已注册用户的邮箱"
onKeyDown={(e) => e.key === "Enter" && invite()}
/>
<Select className="w-24" value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
<option value="member"></option>
<option value="admin"></option>
<option value="viewer"></option>
</Select>
<Button variant="primary" size="sm" icon={UserPlus} onClick={invite} disabled={busy || !email.trim()}>
</Button>
</div>
)}
{!canManage && <p className="mb-2 text-[11px] text-slate-500">/</p>}
<ul className="max-h-72 space-y-1 overflow-auto">
{loading && <li className="px-1 py-2 text-[11px] text-slate-500"></li>}
{!loading && rows.length === 0 && <li className="px-1 py-2 text-[11px] text-slate-600"></li>}
{rows.map((m) => {
const isOwner = m.role === "owner";
const isSelf = m.user_id === selfUserId;
return (
<li key={m.user_id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-slate-200">
{m.name || m.email} {isSelf && <span className="text-[10px] text-slate-500"></span>}
</div>
<div className="truncate text-[10px] text-slate-500">{m.email}</div>
</div>
{canManage && !isOwner ? (
<Select className="h-8 w-20 text-xs" value={m.role} onChange={(e) => changeRole(m.user_id, e.target.value)}>
<option value="member"></option>
<option value="admin"></option>
<option value="viewer"></option>
</Select>
) : (
<Badge tone={ROLE_TONE[m.role] ?? "neutral"}>{ROLE_LABEL[m.role] ?? m.role}</Badge>
)}
{canManage && !isOwner && (
<button onClick={() => remove(m)} className="text-slate-600 transition hover:text-danger" title="移出空间">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</li>
);
})}
</ul>
</Dialog>
);
}
+11 -2
View File
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe } from "lucide-react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog } from "lucide-react";
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
import { useHealth } from "../lib/health";
import { isMacDesktop } from "../lib/desktop";
@@ -116,7 +116,7 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpa
}
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; onManageMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
const h = useHealth();
const { theme, toggle } = useTheme();
return (
@@ -161,6 +161,15 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
{space?.space && space.space.kind !== "personal" && (
<button
onClick={onManageMembers}
title="管理当前空间成员"
className="flex items-center rounded-md border border-line bg-ink-800 px-1.5 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
>
<UserCog className="h-3.5 w-3.5" />
</button>
)}
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
<button
onClick={toggle}