团队管理:重命名与删除团队
- 新接口:PATCH /api/teams/:id 重命名(1-40 字符)、DELETE /api/teams/:id 删除(均仅创建者);删除随外键级联清理成员/共享空间/Mock 接口 - TeamDialog:卡片右上 ✎ 内联重命名、🗑 删除二次确认(红色警示条,明示连带删除范围) - 删除后若当前停留在该团队空间自动切回本地空间 - curl E2E:改名/越权 403/删除/空间级联失效全通过;浏览器验证改名与删除全流程;53 前端测试 + Go 构建通过
This commit is contained in:
1 parent
c6868b0ddd
commit
36df1cec83
8 files changed
+223
-10
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -35,6 +35,8 @@ func (a *API) Register(r *gin.Engine) {
|
||||
g.POST("/auth/logout", a.auth(a.handleLogout))
|
||||
g.GET("/me", a.auth(a.handleMe))
|
||||
g.POST("/teams", a.auth(a.handleCreateTeam))
|
||||
g.PATCH("/teams/:id", a.auth(a.handleRenameTeam))
|
||||
g.DELETE("/teams/:id", a.auth(a.handleDeleteTeam))
|
||||
g.POST("/teams/:id/members", a.auth(a.handleAddMember))
|
||||
g.GET("/teams/:id/members", a.auth(a.handleListMembers))
|
||||
g.PUT("/teams/:id/members/:uid", a.auth(a.handleUpdateSpecialty))
|
||||
@@ -192,6 +194,51 @@ func (a *API) handleAddMember(c *gin.Context, user *store.User) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "member": member})
|
||||
}
|
||||
|
||||
// handleRenameTeam 重命名团队(仅创建者)
|
||||
func (a *API) handleRenameTeam(c *gin.Context, user *store.User) {
|
||||
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
|
||||
return
|
||||
}
|
||||
owner, err := a.store.IsTeamOwner(teamID, user.ID)
|
||||
if err != nil || !owner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可重命名团队"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=40"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "团队名称需 1-40 个字符"})
|
||||
return
|
||||
}
|
||||
if err := a.store.RenameTeam(teamID, strings.TrimSpace(req.Name)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重命名失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// handleDeleteTeam 删除团队(仅创建者):成员、共享空间与 Mock 数据一并删除
|
||||
func (a *API) handleDeleteTeam(c *gin.Context, user *store.User) {
|
||||
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
|
||||
return
|
||||
}
|
||||
owner, err := a.store.IsTeamOwner(teamID, user.ID)
|
||||
if err != nil || !owner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可删除团队"})
|
||||
return
|
||||
}
|
||||
if err := a.store.DeleteTeam(teamID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// handleListMembers 团队成员列表(创建者与成员均可查看)
|
||||
func (a *API) handleListMembers(c *gin.Context, user *store.User) {
|
||||
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
活动栏账号菜单或空间菜单 → **团队管理**:
|
||||
|
||||
- **新建团队**:创建后自动获得同名共享空间;
|
||||
- **重命名 / 删除**:团队卡片右上角的 ✎ / 🗑(仅创建者可见)。重命名就地保存;删除需二次确认,将同时删除成员关系、共享空间与已发布 Mock 数据,不可恢复(若当前正停在该团队空间,会自动切回本地空间);
|
||||
- **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间;
|
||||
- **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。
|
||||
|
||||
|
||||
@@ -367,6 +367,32 @@ func (s *Store) IsTeamOwner(teamID, userID int64) (bool, error) {
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// RenameTeam 重命名团队(权限由 API 层校验)
|
||||
func (s *Store) RenameTeam(teamID int64, name string) error {
|
||||
res, err := s.db.Exec(`UPDATE teams SET name = ? WHERE id = ?`, name, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTeam 删除团队:成员 / 团队空间 / 其 Mock 接口随外键级联清理
|
||||
func (s *Store) DeleteTeam(teamID int64) error {
|
||||
res, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/* ---------------- 工作区(云端空间) ---------------- */
|
||||
|
||||
type Workspace struct {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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("未登录");
|
||||
|
||||
Reference in new issue
Block a user