团队管理:重命名与删除团队

- 新接口:PATCH /api/teams/:id 重命名(1-40 字符)、DELETE /api/teams/:id 删除(均仅创建者);删除随外键级联清理成员/共享空间/Mock 接口
- TeamDialog:卡片右上 ✎ 内联重命名、🗑 删除二次确认(红色警示条,明示连带删除范围)
- 删除后若当前停留在该团队空间自动切回本地空间
- curl E2E:改名/越权 403/删除/空间级联失效全通过;浏览器验证改名与删除全流程;53 前端测试 + Go 构建通过
This commit is contained in:
logan committed 2026-09-18 13:40:32 +08:00
1 parent c6868b0ddd
commit 36df1cec83
8 files changed
+223 -10

No files matched your search

+8
View File
@@ -60,6 +60,14 @@ export function createTeam(token: string, name: string) {
return call<AccountTeam>("/teams", token, { method: "POST", body: JSON.stringify({ name }) });
}
export function renameTeam(token: string, teamId: number, name: string) {
return call<{ ok: boolean }>(`/teams/${teamId}`, token, { method: "PATCH", body: JSON.stringify({ name }) });
}
export function deleteTeam(token: string, teamId: number) {
return call<{ ok: boolean }>(`/teams/${teamId}`, token, { method: "DELETE" });
}
export function addTeamMember(token: string, teamId: number, email: string, specialty?: string) {
return call<{ ok: boolean; member: AccountUser }>(`/teams/${teamId}/members`, token, {
method: "POST",
@@ -193,6 +193,8 @@ function SpecialtyEditor({
export function TeamDialog() {
const account = useWorkspace((s) => s.account);
const createTeam = useWorkspace((s) => s.createTeam);
const renameTeam = useWorkspace((s) => s.renameTeam);
const deleteTeam = useWorkspace((s) => s.deleteTeam);
const addTeamMember = useWorkspace((s) => s.addTeamMember);
const setDialog = useWorkspace((s) => s.setDialog);
@@ -203,6 +205,9 @@ export function TeamDialog() {
const [membersLoading, setMembersLoading] = useState(false);
const [editing, setEditing] = useState<{ teamId: number; userId: number } | null>(null);
const [editValue, setEditValue] = useState("");
const [renaming, setRenaming] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [okMsg, setOkMsg] = useState("");
@@ -249,6 +254,41 @@ export function TeamDialog() {
}
};
const doRename = async (teamId: number) => {
if (!renameValue.trim()) return;
setBusy(true);
setError("");
try {
await renameTeam(teamId, renameValue);
setRenaming(null);
setOkMsg("团队已重命名");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const doDelete = async (teamId: number) => {
setBusy(true);
setError("");
try {
const name = account.teams.find((t) => t.id === teamId)?.name ?? "";
await deleteTeam(teamId);
setConfirmDelete(null);
setMembers((m) => {
const next = { ...m };
delete next[teamId];
return next;
});
setOkMsg(`团队「${name}」已删除(含共享空间与 Mock 数据)`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const doInvite = async (teamId: number) => {
const email = (inviteEmail[teamId] ?? "").trim();
if (!email) return;
@@ -325,18 +365,86 @@ export function TeamDialog() {
return (
<div key={t.id} className="rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}>
<Icon name="library" size={13} />
</span>
<span className="text-[13px] font-medium">{t.name}</span>
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}>
{t.role === "owner" ? "创建者" : "成员"}
</span>
<span className="ops-faint ml-auto text-[11px]">
{list ? `${list.length} 名成员 · 创建者 ${t.owner}` : `创建者 ${t.owner}`}
</span>
{renaming === t.id ? (
<div className="flex flex-1 items-center gap-2">
<input
className="ops-input ops-input-sm flex-1"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doRename(t.id)}
autoFocus
/>
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => doRename(t.id)}>
保存
</button>
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => setRenaming(null)}>
取消
</button>
</div>
) : (
<>
<span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}>
<Icon name="library" size={13} />
</span>
<span className="text-[13px] font-medium">{t.name}</span>
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}>
{t.role === "owner" ? "创建者" : "成员"}
</span>
<span className="ops-faint ml-auto text-[11px]">
{list ? `${list.length} 名成员 · 创建者 ${t.owner}` : `创建者 ${t.owner}`}
</span>
{t.role === "owner" && (
<>
<button
className="ops-btn ops-btn-ghost !px-1.5 !py-0.5 text-[11px]"
title="重命名团队"
onClick={() => {
setRenaming(t.id);
setRenameValue(t.name);
setConfirmDelete(null);
}}
>
<Icon name="pencil" size={12} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger !px-1.5 !py-0.5 text-[11px]"
title="删除团队(含共享空间与 Mock 数据)"
onClick={() => setConfirmDelete(confirmDelete === t.id ? null : t.id)}
>
<Icon name="trash" size={12} />
</button>
</>
)}
</>
)}
</div>
{confirmDelete === t.id && (
<div
className="mt-2 rounded-lg px-2.5 py-2"
style={{
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
border: "1px solid color-mix(in srgb, var(--danger) 32%, transparent)",
}}
>
<div className="ops-danger text-[12px]">
将永久删除团队「{t.name}」及其共享空间、成员关系、已发布 Mock 接口,不可恢复。确定删除?
</div>
<div className="mt-1.5 flex justify-end gap-2">
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => setConfirmDelete(null)}>
取消
</button>
<button
className="ops-btn ops-btn-danger !py-1 text-[12px]"
disabled={busy}
onClick={() => void doDelete(t.id)}
>
确认删除
</button>
</div>
</div>
)}
{/* 成员与擅长 */}
<div className="mt-1.5 space-y-1">
{list === undefined &&
+23
View File
@@ -41,6 +41,8 @@ import {
authLogout as authLogoutApi,
authRegister as authRegisterApi,
createTeam as createTeamApi,
deleteTeam as deleteTeamApi,
renameTeam as renameTeamApi,
fetchMe,
fetchWorkspace,
loadToken,
@@ -150,6 +152,8 @@ type WorkspaceStore = PersistedShape & {
authLogout(): Promise<void>;
refreshAccount(): Promise<void>;
createTeam(name: string): Promise<void>;
renameTeam(teamId: number, name: string): Promise<void>;
deleteTeam(teamId: number): Promise<void>;
addTeamMember(teamId: number, email: string, specialty?: string): Promise<void>;
/** 切换空间:null=本地;服务端空间为空时用当前数据播种 */
switchSpace(spaceId: number | null): Promise<void>;
@@ -516,6 +520,25 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
await get().refreshAccount();
},
async renameTeam(teamId, name) {
const { token } = get().account;
if (!token) throw new Error("未登录");
await renameTeamApi(token, teamId, name.trim());
await get().refreshAccount();
},
async deleteTeam(teamId) {
const acc = get().account;
if (!acc.token) throw new Error("未登录");
await deleteTeamApi(acc.token, teamId);
await get().refreshAccount();
// 当前正停在被删团队的共享空间时,切回本地空间
const active = get().account.activeSpaceId;
if (active !== null && !get().account.spaces.some((sp) => sp.id === active)) {
await get().switchSpace(null);
}
},
async addTeamMember(teamId, email, specialty) {
const { token } = get().account;
if (!token) throw new Error("未登录");