feat(invite): 租户成员二维码邀请(可复用团队码,扫码关注即入组)
owner/admin 在桌面端生成一张微信带参二维码发给团队,成员用微信扫码关注 即自动加入租户,并收到「✅ 已加入团队【X】」被动回复。与登录二维码同一微信机制, scene 加 inv_ 前缀分流;扫码入组走被动回复,不需要 access_token、不碰 IP 白名单。 - store: TenantInvite(可复用码=有效期+人数上限+可撤销三道闸);RedeemInvite 幂等入组、 同一人重复扫不重复消耗名额、复活已移除者;带单测钉死过期/撤销/满员/幂等。 - handler: 建码/列表/撤销端点(RequireTenantRole admin);WxMPEvent inv_ 分支。 - 顺手修潜在生产 bug:微信用户此前都建成空邮箱,User.Email 整列唯一索引下第二个微信 用户就撞唯一约束建号失败(登录"全通"只因当前仅一个微信用户)。改为按 openid 合成占位 邮箱 wx-<openid>@wx.local,绕开冲突且不动索引。邀请功能会批量建微信用户,非修不可。 - desktop: 顶栏「邀请成员」入口(租户 admin/owner 可见) + 二维码弹窗(生成/大图展示/撤销)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<RunState>(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%)" }}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<ApprovalBar run={run} />
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<LeftNav active={view} onSelect={setView} />
|
||||
@@ -357,6 +359,7 @@ export default function App() {
|
||||
canManage={space?.role === "owner" || space?.role === "admin"}
|
||||
selfUserId={user?.id ?? ""}
|
||||
/>
|
||||
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
@@ -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<TenantInvite[]> {
|
||||
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<TenantInvite> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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<string, string> = { 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<TenantInvite[]>([]);
|
||||
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<TenantInvite | null>(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 (
|
||||
<Dialog open={open} onClose={onClose} title={`邀请成员 · ${tenantName}`}>
|
||||
{/* 生成表单 */}
|
||||
<div className="mb-3 flex items-end gap-2">
|
||||
<label className="text-[11px] text-slate-500">
|
||||
角色
|
||||
<Select className="mt-1 h-8 w-20 text-xs" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="member">成员</option>
|
||||
<option value="viewer">只读</option>
|
||||
<option value="admin">管理员</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="text-[11px] text-slate-500">
|
||||
有效期
|
||||
<Select className="mt-1 h-8 w-20 text-xs" value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
||||
<option value={1}>1 天</option>
|
||||
<option value={7}>7 天</option>
|
||||
<option value={30}>30 天</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="text-[11px] text-slate-500">
|
||||
人数上限
|
||||
<Select className="mt-1 h-8 w-24 text-xs" value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))}>
|
||||
<option value={0}>不限</option>
|
||||
<option value={1}>1 人</option>
|
||||
<option value={5}>5 人</option>
|
||||
<option value={20}>20 人</option>
|
||||
</Select>
|
||||
</label>
|
||||
<Button variant="primary" size="sm" icon={busy ? Loader2 : QrCode} onClick={generate} disabled={busy}>
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 刚生成的码:大图展示,便于当场扫 */}
|
||||
{fresh && (
|
||||
<div className="mb-3 flex flex-col items-center rounded-lg border border-brand/40 bg-ink-800/60 p-3">
|
||||
{fresh.qr_image ? (
|
||||
<img src={fresh.qr_image} alt="邀请二维码" className="h-44 w-44 rounded bg-white p-1" />
|
||||
) : (
|
||||
<div className="flex h-44 w-44 items-center justify-center text-xs text-slate-500">二维码加载中…</div>
|
||||
)}
|
||||
<p className="mt-2 text-center text-[11px] text-slate-400">
|
||||
让对方用<b className="text-slate-200">微信扫一扫</b>关注公众号即自动加入
|
||||
<br />
|
||||
身份:{ROLE_LABEL[fresh.role] ?? fresh.role} · {usesLabel(fresh)} · {expLabel(fresh.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 有效邀请码列表 */}
|
||||
<div className="mb-1 text-[11px] text-slate-500">有效邀请码</div>
|
||||
<ul className="max-h-56 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((inv) => (
|
||||
<li key={inv.id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
|
||||
<button onClick={() => setFresh(inv)} title="查看二维码" className="shrink-0 text-slate-500 transition hover:text-brand-400">
|
||||
<QrCode className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-200">
|
||||
<Badge tone={inv.role === "admin" ? "accent" : inv.role === "viewer" ? "warn" : "neutral"}>{ROLE_LABEL[inv.role] ?? inv.role}</Badge>
|
||||
<span className="text-[11px] text-slate-400">{usesLabel(inv)}</span>
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-slate-500">{expLabel(inv.expires_at)}</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(inv)} className="text-slate-600 transition hover:text-danger" title="撤销">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<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} />
|
||||
{(tenantRole === "owner" || tenantRole === "admin") && (
|
||||
<button
|
||||
onClick={onInviteMembers}
|
||||
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"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{space?.space && space.space.kind !== "personal" && (
|
||||
<button
|
||||
onClick={onManageMembers}
|
||||
|
||||
Reference in New Issue
Block a user