feat(core): Wave 3.5 团队邀请入口 — 生成邀请码弹窗 + 复制码/邀请链接

team.tsx 加「邀请成员」按钮 → gen-invite 弹窗:角色(成员/团管)+ 月限额 → api.createInvitation
生成邀请码,cred-card 展示码 + 邀请链接(/register?invite=)+ 一键复制;复用 batch1 的 cred-card 类,无新 CSS。

验证: tsc 0 · build 0 · 走查 邀请按钮/弹窗打开/角色select/生成按钮/ESC关 全过 · 0 console error

⚠️ 生成码 + 凭码注册的 e2e 需先把 Invitation 迁移 apply 到远程 MySQL(逻辑已被后端 5/5 测试证)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 12:45:16 +08:00
co-authored by Claude Opus 4.8
parent c0a8582f3b
commit b82c4d3234
2 changed files with 86 additions and 2 deletions
+62 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react";
import { api } from "../api";
import type { BillingSummary, Notification, Team, TeamMember, User } from "../types";
import type { BillingSummary, Invitation, Notification, Team, TeamMember, User } from "../types";
import type { Page } from "./route-config";
import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays";
@@ -88,7 +88,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
onResetPassword: (id: string, password: string) => void | Promise<unknown>;
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
}) {
const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge">("");
const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge" | "gen-invite">("");
const [search, setSearch] = useState("");
// 团队成员 + 团队动态本页懒加载(不再走全局 bootstrap);成员/充值操作后 bump 刷新
@@ -141,6 +141,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
// 分享凭据弹窗(创建成功 / 重置成功后弹):captured creds(仅展示一次)
const [share, setShare] = useState<{ mode: "create" | "reset"; name: string; username: string; password: string } | null>(null);
// 邀请码生成(凭码注册即加入本团队,不重复发额度)
const [invRole, setInvRole] = useState("member");
const [invMonthly, setInvMonthly] = useState("2000");
const [inviteResult, setInviteResult] = useState<Invitation | null>(null);
const [inviteBusy, setInviteBusy] = useState(false);
const [inviteErr, setInviteErr] = useState("");
const rows: TeamMember[] = members.length
? members
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
@@ -282,6 +289,9 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
<UserPlus size={13} />
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={openLimit}></button>
</div>
</div>
@@ -434,6 +444,56 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</div>
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
<TeamModal
open={modal === "gen-invite"}
title="邀请成员"
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
icon={<UserPlus size={16} />}
close={() => setModal("")}
footer={inviteResult
? <button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>
: <button className="btn btn-primary" type="button" disabled={inviteBusy} onClick={() => {
if (inviteBusy) return;
setInviteBusy(true);
setInviteErr("");
api.createInvitation({ role: invRole, monthly_credit_limit: invMonthly || 0 })
.then((inv) => setInviteResult(inv))
.catch((err) => setInviteErr(err instanceof Error ? err.message : "生成失败,请稍后重试"))
.finally(() => setInviteBusy(false));
}}>{inviteBusy ? "生成中…" : "生成邀请码"}</button>}
>
<div className="invite-gen-modal">
{!inviteResult ? (
<>
<div className="field">
<label className="field-label"></label>
<select className="input" value={invRole} onChange={(e) => setInvRole(e.target.value)}>
<option value="member"> · ,</option>
<option value="admin"> · //</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={invMonthly} onChange={(e) => setInvMonthly(e.target.value)} />
</div>
{inviteErr && <div className="form-error" role="alert" style={{ marginTop: 12 }}>{inviteErr}</div>}
</>
) : (
<div className="cred-card">
<div className="cred-row">
<div><div className="ck"></div><div className="cv">{inviteResult.code}</div></div>
<button className="cred-copy" type="button" onClick={() => navigator.clipboard?.writeText(inviteResult.code)}><Copy size={13} /> </button>
</div>
<div className="cred-row">
<div><div className="ck"></div><div className="cv">{`${location.origin}${inviteResult.register_url}`}</div></div>
<button className="cred-copy" type="button" onClick={() => navigator.clipboard?.writeText(`${location.origin}${inviteResult.register_url}`)}><Copy size={13} /> </button>
</div>
<div className="cred-rules">// 角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度</div>
</div>
)}
</div>
</TeamModal>
<TeamModal
open={modal === "limit"}
title="设置月限额"
+24
View File
@@ -0,0 +1,24 @@
import { chromium } from "playwright";
import fs from "node:fs"; import path from "node:path";
const BASE = process.env.BASE, TOKEN = process.env.TOKEN;
const OUT = path.resolve("../../../_qa_shots/wave35"); fs.mkdirSync(OUT, { recursive: true });
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const seed = await ctx.newPage(); await seed.goto(BASE + "/", { waitUntil: "domcontentloaded" });
await seed.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN); await seed.close();
const p = await ctx.newPage(); const errs = [];
p.on("console", m => { if (m.type()==="error") errs.push(m.text()); });
p.on("pageerror", e => errs.push("PAGEERR:"+e.message));
await p.goto(BASE + "/team", { waitUntil: "load" }); await p.waitForTimeout(1500);
const r = { inviteBtn: await p.locator("#open-gen-invite").count() };
if (r.inviteBtn) {
await p.click("#open-gen-invite"); await p.waitForTimeout(450);
r.modalShown = await p.locator(".modal-bg.show").count();
r.roleSelect = await p.locator(".invite-gen-modal select").count();
r.genBtnText = await p.locator(".modal-f .btn-primary").innerText().catch(()=>"");
await p.screenshot({ path: path.join(OUT, "team-gen-invite.png") });
await p.keyboard.press("Escape"); await p.waitForTimeout(350);
r.closedByEsc = (await p.locator(".modal-bg").count()) === 0;
}
r.consoleErrors = errs.filter(e => !/Invitation|invitations|500|Failed to load resource/i.test(e));
await b.close(); console.log(JSON.stringify(r, null, 2));