diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 412ca9d..de3b462 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark, Boxe import { TopBar } from "./shell/TopBar"; import { LeftNav, type ViewKey } from "./shell/LeftNav"; import { ApprovalBar } from "./shell/ApprovalBar"; +import { SpaceMembers } from "./shell/SpaceMembers"; import { StudioView } from "./studio/StudioView"; import { MemoryView } from "./views/MemoryView"; import { KbView } from "./views/KbView"; @@ -50,6 +51,7 @@ export default function App() { const identity = useMemo(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]); const [run, setRun] = useState(emptyRun); const [cmdOpen, setCmdOpen] = useState(false); + const [membersOpen, setMembersOpen] = useState(false); const closeRef = useRef<(() => void) | null>(null); const execCloseRef = useRef<(() => void) | null>(null); @@ -298,7 +300,7 @@ export default function App() { style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }} /> - setCmdOpen(true)} onOpenUsage={() => setView("usage")} /> + setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
@@ -323,6 +325,14 @@ export default function App() {
setCmdOpen(false)} commands={commands} /> + setMembersOpen(false)} + spaceId={space?.space?.id ?? ""} + spaceName={space?.space?.kind === "personal" ? "个人空间" : space?.space?.name ?? ""} + canManage={space?.role === "owner" || space?.role === "admin"} + selfUserId={user?.id ?? ""} + /> ); diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index 7a163c7..ae0adec 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -212,6 +212,40 @@ export async function createSpace(name: string, kind = "project"): Promise<{ id: return (await res.json()) as { id: string }; } +// ---- 空间成员管理(邀请/列表/改角色/移除)---- +export interface SpaceMemberInfo { + user_id: string; + email: string; + name: string; + role: string; // owner/admin/member/viewer + status: string; +} + +export async function spaceMembers(spaceId: string): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces/${spaceId}/members`, { headers: bearer() })); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(d.error ?? `members failed: ${res.status}`); + } + const d = (await res.json()) as { members?: SpaceMemberInfo[] }; + return d.members ?? []; +} + +async function spaceMemberWrite(method: string, url: string, body?: unknown): Promise { + const res = guard401(await fetch(`${GATEWAY}${url}`, { method, headers: { "Content-Type": "application/json", ...bearer() }, body: body ? JSON.stringify(body) : undefined })); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(d.error ?? `${method} ${url} failed: ${res.status}`); + } +} + +export const addSpaceMember = (spaceId: string, email: string, role: string) => + spaceMemberWrite("POST", `/api/v1/spaces/${spaceId}/members`, { email, role }); +export const setSpaceMemberRole = (spaceId: string, userId: string, role: string) => + spaceMemberWrite("PUT", `/api/v1/spaces/${spaceId}/members/${userId}`, { role }); +export const removeSpaceMember = (spaceId: string, userId: string) => + spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`); + // ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)---- export interface UsageDay { day: string; // YYYYMMDD diff --git a/sundynix-desktop/frontend/src/shell/SpaceMembers.tsx b/sundynix-desktop/frontend/src/shell/SpaceMembers.tsx new file mode 100644 index 0000000..27fcb7e --- /dev/null +++ b/sundynix-desktop/frontend/src/shell/SpaceMembers.tsx @@ -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 = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读" }; +const ROLE_TONE: Record = { 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([]); + 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 ( + + {canManage && ( +
+ setEmail(e.target.value)} + placeholder="邀请已注册用户的邮箱" + onKeyDown={(e) => e.key === "Enter" && invite()} + /> + + +
+ )} + {!canManage &&

你在此空间为只读/成员,仅管理员可增删成员。

} + +
    + {loading &&
  • 加载中…
  • } + {!loading && rows.length === 0 &&
  • 暂无成员
  • } + {rows.map((m) => { + const isOwner = m.role === "owner"; + const isSelf = m.user_id === selfUserId; + return ( +
  • +
    +
    + {m.name || m.email} {isSelf && (你)} +
    +
    {m.email}
    +
    + {canManage && !isOwner ? ( + + ) : ( + {ROLE_LABEL[m.role] ?? m.role} + )} + {canManage && !isOwner && ( + + )} +
  • + ); + })} +
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index 9019443..ca5bbc5 100644 --- a/sundynix-desktop/frontend/src/shell/TopBar.tsx +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -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
+ {space?.space && space.space.kind !== "personal" && ( + + )} {tenant?.tenant && }