From 36df1cec833a516768a4ca926e697600908b06a7 Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 18 Sep 2026 13:40:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=A2=E9=98=9F=E7=AE=A1=E7=90=86=EF=BC=9A?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=8E=E5=88=A0=E9=99=A4=E5=9B=A2?= =?UTF-8?q?=E9=98=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新接口:PATCH /api/teams/:id 重命名(1-40 字符)、DELETE /api/teams/:id 删除(均仅创建者);删除随外键级联清理成员/共享空间/Mock 接口 - TeamDialog:卡片右上 ✎ 内联重命名、🗑 删除二次确认(红色警示条,明示连带删除范围) - 删除后若当前停留在该团队空间自动切回本地空间 - curl E2E:改名/越权 403/删除/空间级联失效全通过;浏览器验证改名与删除全流程;53 前端测试 + Go 构建通过 --- apps/server/data/opsxapi.db-shm | Bin 32768 -> 32768 bytes apps/server/data/opsxapi.db-wal | Bin 749872 -> 927032 bytes apps/server/internal/api/api.go | 47 +++++++ .../internal/docs/content/20-account.md | 1 + apps/server/internal/store/store.go | 26 ++++ packages/ui/src/account.ts | 8 ++ .../src/components/dialogs/AccountDialogs.tsx | 128 ++++++++++++++++-- packages/ui/src/workspaceStore.ts | 23 ++++ 8 files changed, 223 insertions(+), 10 deletions(-) diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm index 50a4c8d846a14b70574e186cd885fec98ef265f7..cb705a646dbb1aa35a9937211f54a1579d896b2c 100644 GIT binary patch delta 413 zcmZo@U}|V!s+V}A%K!p=K+MR%An*`KO9Jut+Vw09f;ROf&aDk0orhH!VlDc6SyuOu zsvc+*7=XV7SC^i{Um<@;TGw58)#0hZs&VTxPffRz3M$6d(Ix zhSLmJ814cEUNTL76RXC#o#7b68HVc&_kcp@nI^wVkYL`;upgq~MKT}z5r(r2R~ha@ qG(1ZaVL#4rj^PHwgUyWT|Cu;4)!Jbe2A delta 261 zcmZo@U}|V!s+V}A%K!qrK+MR%Ag~QcO9HV_+A^&Zjk1y@&aDk0orhJW=CYSE&J8jm zRXxxsFaVkR9|=H(nHZ*RY`o93S%l?)?PLX(Et{`625@Z_V!6aL`J0~vknxOZ@|Qpe zgOig%lc9~FpJC2sL6)yflRt!u02RDqn*1(`56JkyH2F=e8jx{=Y4WQCsNNUJP^o8W TP{mI&Wq@iPF>QX7BPR?15m;2! diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal index e0c877d45d47b15d7d83702c0f06cecf766d8ee1..b30bd1e0de51c0667fe745ff5f821b96567944e5 100644 GIT binary patch delta 3641 zcmchac~BE)7>9S0-CVoN8%8POL6J719FmZ`r7AMEp43*UOsyk1NKt47@3B(_4Ty;K zsB}@hP!MHsNEMArz3LRF6qu@Lu{h{hxxA`lt8KxtyIDv>Ktb&I$Nu@f-}nB$=Y4k9 zb|d6yg)YU4T6tE!6|>@2fmLV~S;baKDIre|Ll8v5JjlYxw3O*<&dR1oG}|uM+&xVR zNklB5_=1CQ3HA!3g@J-B!KVV9fI5`y_Ye;nAr>Jr1f{T$#*B?kTdhs|kGIgJ_}t>E zB&9~L(5o~$jnSaisP!f~$xHGJI>>Gl5)VZ~J($gWI(BQ?VBQ*)@sE~0f*2zBPy`Y7 ztA2zUy!!J)Q1?n=`s66d0lKbENI^07S?!J%A5V%|N|MR+?{dPMMtqF2e4 zI-O2#QS;{(S1+;`yhBXm4@|Tr1do*OD`AgHlE)AT^_T^VsI}xVyEK9*BQAACl|hJ-;L0*z1uXCaSSwh@N-VK|~kvBs~{TCz_y9$wpAlHF8h z4QbqbtYO1CdU>^Y++dtp=wu_~N9)S@0i&+6)hJTth#Vu6Nf}d)Pp+6qIYKw6&1Pem zQm+X!X~N7VqeW-1m^DVdMX58&m0G!45vNru6-u?jXs}o`3WLR@GAb2ntwC!JGg)*z zHuOGRpid7sIPB)3T?W~*+a7GD6uH3V20dD!j5<#)pvI9;?a~dBXzn&wylgAfIE=xt z&rFEgF>Hq-aM@Ab<;W+2wT=d$v;&ILZn4FO6V@_hi{xdP`b>ookga^`@`(6k-9~?)5P^{r(T0_RJ zmM!@Wxw~6(b~chB*ah@ z#rZ-yakeDf_uUh}&iJV@^8~k|mJ6`HBR*!u_wBPJq`#Uck#T7)&zM}N`mLzBQ(9`a z1p7FLqxkgU>xXU(iDktp!RxybCtlC)fPWk!5iz;3>U4_^cVwqmTNHXFGdBvO$zV`v zUfi^WjA_Mwp*c@dR@DVeb(|E=vCTeUBytY;CFZ&)F}Bomomg{sp5uhi zQ=*_liHq(;ikA*I{>`oEr4otZ^#y4O9@b8=s0k((wv3aVEvEd``eCGvQ(|p*N@Vf- z4v$q5A|@bKiQQ%-V8xhg(Wy)(tyyj~tMwYA$=p{l?w1d&n!mncD0@~(4`L*`it$Lu z7G6yF<6GSFE@EVX4t5cv`>BZ@#OMUN=)EC9M=CRw98{T3s34QcY#~S0U@59cJ$sbNi1$~UvYJ=d%4 zaJqTbd|^aht2XMJ=Xmwi($t+@r=|OSf`w10=%rm{URRW3?*-3cBk9?$-N1;IeEE1q SxTj<8I)0$s?AkXNi~a@K512#% delta 31 mcmdmS%3{MK-G&y%7N!>F7M2#)7Pc1l7LFFq7OpMa2895`3JSRZ diff --git a/apps/server/internal/api/api.go b/apps/server/internal/api/api.go index 8948f81..4be826a 100644 --- a/apps/server/internal/api/api.go +++ b/apps/server/internal/api/api.go @@ -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) diff --git a/apps/server/internal/docs/content/20-account.md b/apps/server/internal/docs/content/20-account.md index 33936e7..ef62048 100644 --- a/apps/server/internal/docs/content/20-account.md +++ b/apps/server/internal/docs/content/20-account.md @@ -29,6 +29,7 @@ 活动栏账号菜单或空间菜单 → **团队管理**: - **新建团队**:创建后自动获得同名共享空间; +- **重命名 / 删除**:团队卡片右上角的 ✎ / 🗑(仅创建者可见)。重命名就地保存;删除需二次确认,将同时删除成员关系、共享空间与已发布 Mock 数据,不可恢复(若当前正停在该团队空间,会自动切回本地空间); - **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间; - **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。 diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go index 4ecdeca..4ef3f8f 100644 --- a/apps/server/internal/store/store.go +++ b/apps/server/internal/store/store.go @@ -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 { diff --git a/packages/ui/src/account.ts b/packages/ui/src/account.ts index 5721420..54b72f6 100644 --- a/packages/ui/src/account.ts +++ b/packages/ui/src/account.ts @@ -60,6 +60,14 @@ export function createTeam(token: string, name: string) { return call("/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", diff --git a/packages/ui/src/components/dialogs/AccountDialogs.tsx b/packages/ui/src/components/dialogs/AccountDialogs.tsx index 0177c70..cf352f3 100644 --- a/packages/ui/src/components/dialogs/AccountDialogs.tsx +++ b/packages/ui/src/components/dialogs/AccountDialogs.tsx @@ -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(null); + const [renameValue, setRenameValue] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(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 (
- - - - {t.name} - - {t.role === "owner" ? "创建者" : "成员"} - - - {list ? `${list.length} 名成员 · 创建者 ${t.owner}` : `创建者 ${t.owner}`} - + {renaming === t.id ? ( +
+ setRenameValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && doRename(t.id)} + autoFocus + /> + + +
+ ) : ( + <> + + + + {t.name} + + {t.role === "owner" ? "创建者" : "成员"} + + + {list ? `${list.length} 名成员 · 创建者 ${t.owner}` : `创建者 ${t.owner}`} + + {t.role === "owner" && ( + <> + + + + )} + + )}
+ {confirmDelete === t.id && ( +
+
+ 将永久删除团队「{t.name}」及其共享空间、成员关系、已发布 Mock 接口,不可恢复。确定删除? +
+
+ + +
+
+ )} + {/* 成员与擅长 */}
{list === undefined && diff --git a/packages/ui/src/workspaceStore.ts b/packages/ui/src/workspaceStore.ts index d7455e6..fde70bf 100644 --- a/packages/ui/src/workspaceStore.ts +++ b/packages/ui/src/workspaceStore.ts @@ -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; refreshAccount(): Promise; createTeam(name: string): Promise; + renameTeam(teamId: number, name: string): Promise; + deleteTeam(teamId: number): Promise; addTeamMember(teamId: number, email: string, specialty?: string): Promise; /** 切换空间:null=本地;服务端空间为空时用当前数据播种 */ switchSpace(spaceId: number | null): Promise; @@ -516,6 +520,25 @@ export const useWorkspace = create((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("未登录");