feat(space): 共享工作区增量3a —— Agent 编排按 Space 共享(纯 PG 打样)

引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。

后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
  (如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
  owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
  索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)

桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
  重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除

验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-13 10:51:33 +08:00
parent bf5be08e96
commit addaa1b34f
16 changed files with 1077 additions and 46 deletions
+42 -4
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2 } from "lucide-react";
import type { AuthUser, TenantCtx, MyTenant } from "../lib/api";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus } from "lucide-react";
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
import { useHealth } from "../lib/health";
import { isMacDesktop } from "../lib/desktop";
import { useTheme } from "../lib/theme";
@@ -65,8 +65,45 @@ function TenantSwitcher({ tenants, activeId, onSwitch }: { tenants: MyTenant[];
);
}
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
// 工作区切换器:切换活跃 Space(共享工作区 — 个人/项目/全员)。含"新建项目空间"
// 个人空间恒有,故 ≥1 即显示(让用户随时能建/进项目协作空间)。
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void }) {
if (spaces.length === 0) return null;
const label = (s: MySpace) => (s.kind === "personal" ? "个人空间" : s.name) + (s.kind !== "personal" ? ` · ${s.members}` : "");
return (
<div className="flex items-center gap-1" style={NODRAG}>
<div className="relative">
<UsersIcon className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<select
value={activeId ?? ""}
onChange={(e) => onSwitch?.(e.target.value)}
title="切换当前工作区(共享资源作用域)"
className="appearance-none rounded-md border border-line bg-ink-800 py-1 pl-7 pr-7 text-xs text-slate-300 focus:border-brand focus:outline-none"
>
{spaces.map((s) => (
<option key={s.id} value={s.id}>
{label(s)}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
</div>
<button
onClick={() => {
const name = window.prompt("新建项目空间名称(团队成员可共享此空间的编排/知识库)");
if (name && name.trim()) onCreate?.(name.trim());
}}
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"
>
<Plus 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 }) {
const h = useHealth();
const { theme, toggle } = useTheme();
return (
@@ -110,6 +147,7 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, onLogout, o
</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} />
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
<button
onClick={toggle}