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:
co-authored by
Claude Opus 4.8
parent
c0a8582f3b
commit
b82c4d3234
@@ -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="设置月限额"
|
||||
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user