feat(space): 全员空间(tenant-kind) —— 整租户共享的"开关",自动纳入全部成员

补齐 Space 三类型的最后一种:tenant-kind「全员空间」= 用户最初想要的"共享工作区开关"。
与 project 空间的本质区别:自动纳入全租户成员(含之后新加入的),而非手动逐个邀请。

后端:
- EnableTenantSpace(幂等):建 kind=tenant 空间 + 把全部 active 租户成员按租户角色纳入
  (owner→owner…billing_admin→viewer);tenantSpaceID 查询;spaceRoleFromTenant 角色映射
- AddMemberByEmail 加租户成员时 autoJoinTenantSpace:若已启用全员空间则自动补纳新成员
- SpaceCreate handler:kind=tenant 路由到 EnableTenantSpace,且须租户 admin/owner(inline 403)

桌面端:
- 空间切换器加"启用全员空间"入口(Globe 图标):仅租户 admin/owner 且尚未启用时显示,
  带二次确认;启用后该按钮自动隐藏(hasTenantSpace),全员空间进切换器

验证(gateway+docker):member 启用 403 / owner 启用纳入全部现有成员(owner·member·viewer
按租户角色映射) / 新加租户成员自动纳入 / 幂等(再启用返回同一个不重复建) / viewer 在全员空间
入库 403(只读) / owner 建库 200;浏览器实机:启用后全员空间进切换器 + 启用按钮消失

至此 Space 三类型(个人/项目/全员)全部落地。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-13 13:55:29 +08:00
parent a8c0bb42a7
commit 9dab25a5f0
5 changed files with 112 additions and 11 deletions
+17 -4
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 } from "lucide-react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe } from "lucide-react";
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
import { useHealth } from "../lib/health";
import { isMacDesktop } from "../lib/desktop";
@@ -67,9 +67,11 @@ function TenantSwitcher({ tenants, activeId, onSwitch }: { tenants: MyTenant[];
// 工作区切换器:切换活跃 Space(共享工作区 — 个人/项目/全员)。含"新建项目空间"。
// 个人空间恒有,故 ≥1 即显示(让用户随时能建/进项目协作空间)。
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void }) {
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpace, tenantRole }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void; onEnableTenantSpace?: () => void; tenantRole?: string }) {
if (spaces.length === 0) return null;
const label = (s: MySpace) => (s.kind === "personal" ? "个人空间" : s.name) + (s.kind !== "personal" ? ` · ${s.members}` : "");
const canManage = tenantRole === "owner" || tenantRole === "admin";
const hasTenantSpace = spaces.some((s) => s.kind === "tenant");
return (
<div className="flex items-center gap-1" style={NODRAG}>
<div className="relative">
@@ -98,12 +100,23 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpa
>
<Plus className="h-3.5 w-3.5" />
</button>
{canManage && !hasTenantSpace && (
<button
onClick={() => {
if (window.confirm("启用「全员空间」?将把本租户全部成员纳入一个共享工作区(整租户共享编排/知识库)。")) onEnableTenantSpace?.();
}}
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"
>
<Globe className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, 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; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
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 }) {
const h = useHealth();
const { theme, toggle } = useTheme();
return (
@@ -147,7 +160,7 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
</div>
<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} />
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
<button
onClick={toggle}