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="设置月限额"