diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 114187d..cf26d2f 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { TopBar } from "./shell/TopBar"; import { LeftNav, type ViewKey } from "./shell/LeftNav"; import { ApprovalBar } from "./shell/ApprovalBar"; import { SpaceMembers } from "./shell/SpaceMembers"; +import { InviteMembers } from "./shell/InviteMembers"; import { StudioView } from "./studio/StudioView"; import { MemoryView } from "./views/MemoryView"; import { KbView } from "./views/KbView"; @@ -46,6 +47,7 @@ export default function App() { const [run, setRun] = useState(emptyRun); const [cmdOpen, setCmdOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); const closeRef = useRef<(() => void) | null>(null); const execCloseRef = useRef<(() => void) | null>(null); @@ -326,7 +328,7 @@ export default function App() { style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }} /> - setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} /> + setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
@@ -357,6 +359,7 @@ export default function App() { canManage={space?.role === "owner" || space?.role === "admin"} selfUserId={user?.id ?? ""} /> + setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
); diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index 27d4e72..9d6e53e 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -271,6 +271,47 @@ export const setSpaceMemberRole = (spaceId: string, userId: string, role: string export const removeSpaceMember = (spaceId: string, userId: string) => spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`); +// ---- 租户成员「二维码邀请」(可复用团队码;扫码关注即入组)---- +export interface TenantInvite { + id: string; + role: string; // member/viewer/admin + qr_image: string; // 微信二维码图 URL + expires_at: string; + max_uses: number; // 0=不限 + used_count: number; + status: string; + created_at: string; +} + +export async function listInvites(): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, { headers: bearer() })); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(d.error ?? `invites failed: ${res.status}`); + } + const d = (await res.json()) as { invites?: TenantInvite[] }; + return d.invites ?? []; +} + +export async function createInvite(role: string, expiresDays: number, maxUses: number): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, { + method: "POST", + headers: { "Content-Type": "application/json", ...bearer() }, + body: JSON.stringify({ role, expires_days: expiresDays, max_uses: maxUses }), + })); + const d = (await res.json().catch(() => ({}))) as { invite?: TenantInvite; error?: string }; + if (!res.ok || !d.invite) throw new Error(d.error ?? `create invite failed: ${res.status}`); + return d.invite; +} + +export async function revokeInvite(id: string): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites/${id}`, { method: "DELETE", headers: bearer() })); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(d.error ?? `revoke failed: ${res.status}`); + } +} + // ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)---- export interface UsageDay { day: string; // YYYYMMDD diff --git a/sundynix-desktop/frontend/src/shell/InviteMembers.tsx b/sundynix-desktop/frontend/src/shell/InviteMembers.tsx new file mode 100644 index 0000000..40e0f63 --- /dev/null +++ b/sundynix-desktop/frontend/src/shell/InviteMembers.tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useState } from "react"; +import { QrCode, Trash2, Loader2 } from "lucide-react"; +import { Dialog, Button, Select, Badge, useToast } from "../ui"; +import { listInvites, createInvite, revokeInvite, type TenantInvite } from "../lib/api"; + +const ROLE_LABEL: Record = { admin: "管理员", member: "成员", viewer: "只读" }; + +// InviteMembers 租户成员「二维码邀请」弹窗:owner/admin 生成可复用团队码, +// 成员用微信扫码关注即自动入组(后端 WxMPEvent inv_ 分支)。三道闸:有效期 + 人数 + 撤销。 +// 作用于当前活跃租户(后端取 ctx 租户,前端不传 id)。 +export function InviteMembers({ open, onClose, tenantName }: { open: boolean; onClose: () => void; tenantName: string }) { + const toast = useToast(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [role, setRole] = useState("member"); + const [days, setDays] = useState(7); + const [maxUses, setMaxUses] = useState(0); + const [busy, setBusy] = useState(false); + const [fresh, setFresh] = useState(null); // 刚生成的,置顶大图展示 + + const refresh = useCallback(async () => { + setLoading(true); + try { + setRows(await listInvites()); + } catch (e) { + toast.push("error", (e as Error).message); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + if (open) { + setFresh(null); + void refresh(); + } + }, [open, refresh]); + + const generate = async () => { + setBusy(true); + try { + const inv = await createInvite(role, days, maxUses); + setFresh(inv); + toast.push("success", "已生成邀请二维码"); + await refresh(); + } catch (e) { + toast.push("error", (e as Error).message); + } finally { + setBusy(false); + } + }; + + const revoke = async (inv: TenantInvite) => { + if (!window.confirm("撤销这张邀请码?已加入的成员不受影响,但此码将无法再扫码加入。")) return; + try { + await revokeInvite(inv.id); + if (fresh?.id === inv.id) setFresh(null); + toast.push("success", "已撤销"); + await refresh(); + } catch (e) { + toast.push("error", (e as Error).message); + } + }; + + const usesLabel = (inv: TenantInvite) => (inv.max_uses > 0 ? `${inv.used_count}/${inv.max_uses} 人` : `${inv.used_count} 人(不限)`); + const expLabel = (iso: string) => new Date(iso).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 到期"; + + return ( + + {/* 生成表单 */} +
+ + + + +
+ + {/* 刚生成的码:大图展示,便于当场扫 */} + {fresh && ( +
+ {fresh.qr_image ? ( + 邀请二维码 + ) : ( +
二维码加载中…
+ )} +

+ 让对方用微信扫一扫关注公众号即自动加入 +
+ 身份:{ROLE_LABEL[fresh.role] ?? fresh.role} · {usesLabel(fresh)} · {expLabel(fresh.expires_at)} +

+
+ )} + + {/* 有效邀请码列表 */} +
有效邀请码
+
    + {loading &&
  • 加载中…
  • } + {!loading && rows.length === 0 &&
  • 还没有邀请码,生成一张发给团队。
  • } + {rows.map((inv) => ( +
  • + +
    +
    + {ROLE_LABEL[inv.role] ?? inv.role} + {usesLabel(inv)} +
    +
    {expLabel(inv.expires_at)}
    +
    + +
  • + ))} +
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index e35ba16..5d1b618 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, UserCog } from "lucide-react"; +import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog, QrCode } from "lucide-react"; import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api"; import { useHealth } from "../lib/health"; import { isMacDesktop } from "../lib/desktop"; @@ -124,7 +124,7 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpa } // 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。 -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 }) { +export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, onInviteMembers, 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; onInviteMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) { const h = useHealth(); const { theme, toggle } = useTheme(); return ( @@ -154,6 +154,15 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
+ {(tenantRole === "owner" || tenantRole === "admin") && ( + + )} {space?.space && space.space.kind !== "personal" && (