diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm index 50a4c8d..cb705a6 100644 Binary files a/apps/server/data/opsxapi.db-shm and b/apps/server/data/opsxapi.db-shm differ diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal index e0c877d..b30bd1e 100644 Binary files a/apps/server/data/opsxapi.db-wal and b/apps/server/data/opsxapi.db-wal differ 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("未登录");