From 12cd774816a31a2df0417c3a20027f513e24359a Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 26 Sep 2026 00:03:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=A4=9A=E9=80=89=E5=B9=B6=E5=B0=86=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E4=B8=8E=E5=88=86=E7=BB=84=E5=8E=9F=E5=AD=90=E4=BF=9D?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/user.go | 33 ++++++++++++-- .../UserManagement/AccountGroups.js | 24 +++++++++-- .../UserManagement/UserManagement.css | 43 ++++++++++++++++++- .../UserManagement/UserManagement.js | 24 ++++++++++- .../UserManagement/UserManagement.vue | 40 ++++++++++++++--- 5 files changed, 150 insertions(+), 14 deletions(-) diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 5c60fc1..67c4bf9 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -52,6 +52,12 @@ type userCreateRequest struct { Role string `json:"role"` } +type userEditRequest struct { + userCreateRequest + GroupIDs *[]int `json:"group_ids"` + MembershipVersion int `json:"membership_version"` +} + func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { targetRole := access.RoleUser var reqData userCreateRequest @@ -283,7 +289,7 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - var reqData userCreateRequest + var reqData userEditRequest if err := decodeUserRequest(w, req, &reqData); err != nil { response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) return @@ -321,6 +327,15 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } targetRole := user.EffectiveRole() writeGuard := accountWriteGuard(ctx, user) + if reqData.GroupIDs != nil { + if !requireAccountGroupManagement(ctx, w) { + return + } + if reqData.MembershipVersion < 1 || reqData.MembershipVersion != user.SecurityVersion { + writeAccountGroupError(w, accountgroup.ErrConflict) + return + } + } if !requireAccountScope(ctx, w, user.ID) { return } @@ -394,7 +409,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { now := time.Now().UTC() user.PasswordChangedAt = &now } - if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole { + if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole || reqData.GroupIDs != nil { user.SessionVersion = originalSessionVersion + 1 user.SecurityVersion = originalSecurityVersion + 1 } @@ -430,7 +445,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if reqData.Password != "" { updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at") } - if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole { + if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole || reqData.GroupIDs != nil { updateColumns = append(updateColumns, "session_version", "security_version") } update := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", originalSessionVersion, originalSecurityVersion).Cols(updateColumns...) @@ -453,6 +468,18 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } } + // 角色和分组在同一事务中校验与更新,任何失败均不能留下部分修改。 + if reqData.GroupIDs != nil { + ids, err := accountgroup.ValidateMembership(tx, user.EffectiveRole(), *reqData.GroupIDs) + if err == nil { + err = accountgroup.ReplaceMembership(tx, user.ID, ids) + } + if err != nil { + _ = tx.Rollback() + writeAccountGroupError(w, err) + return + } + } if user.EffectiveRole() != access.RoleAdmin { count, err := tx.Where("user_id = ?", user.ID).Count(&models.AccountGroupMember{}) if err != nil { diff --git a/frontend/src/views/components/UserManagement/AccountGroups.js b/frontend/src/views/components/UserManagement/AccountGroups.js index 57b6ec5..1c4aa65 100644 --- a/frontend/src/views/components/UserManagement/AccountGroups.js +++ b/frontend/src/views/components/UserManagement/AccountGroups.js @@ -12,7 +12,13 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { const membershipVisible = ref(false) const membershipUser = ref(null) const membershipSelection = ref([]) + const membershipDraft = ref(false) + let applyMembershipDraft = null const membershipLimit = computed(() => membershipUser.value?.Role === 'admin' ? 100 : 1) + const membershipSingleSelection = computed({ + get: () => membershipSelection.value.length === 1 ? membershipSelection.value[0] : null, + set: value => { membershipSelection.value = [value] } + }) let generation = 0 let alive = true onBeforeUnmount(() => { alive = false; generation++ }) @@ -107,16 +113,27 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { } } - const editMembership = user => { + const editMembership = (user, options = {}) => { if (!canManageGroups.value || groupSaving.value) return - membershipUser.value = user - membershipSelection.value = [...(user.GroupIDs || [0])] + membershipUser.value = { ...user, Role: options.role || user.Role } + membershipSelection.value = [...(options.groupIDs || user.GroupIDs || [0])] + applyMembershipDraft = options.onApply || null + membershipDraft.value = Boolean(applyMembershipDraft) membershipVisible.value = true } const saveMembership = async () => { if (!canManageGroups.value || groupSaving.value || !membershipUser.value) return if (membershipSelection.value.length === 0) membershipSelection.value = [0] + if (membershipSelection.value.length > membershipLimit.value) { + ElMessage.error('普通用户只能选择一个分组,请重新选择') + return + } + if (applyMembershipDraft) { + applyMembershipDraft([...membershipSelection.value]) + membershipVisible.value = false + return + } groupSaving.value = true try { const response = await requestWithRecentAuthentication(() => http.post('/api/v1/user/groups/membership', { @@ -145,6 +162,7 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { return { accountGroups, groupsLoading, groupsError, canManageGroups, groupSaving, membershipVisible, membershipUser, membershipSelection, membershipLimit, + membershipDraft, membershipSingleSelection, refreshGroups, loadGroup, toggleGroup, saveGroup, editMembership, saveMembership } } diff --git a/frontend/src/views/components/UserManagement/UserManagement.css b/frontend/src/views/components/UserManagement/UserManagement.css index bd6758a..a94e443 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.css +++ b/frontend/src/views/components/UserManagement/UserManagement.css @@ -214,8 +214,49 @@ outline-offset: 4px; } -.membership-select { +.membership-options { width: 100%; + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 8px 12px; + box-sizing: border-box; + border: 1px solid var(--pm-border-color); + border-radius: var(--pm-radius-sm); + background: var(--pm-bg-secondary); +} + +.membership-options-title { + padding: 0 6px; + color: var(--pm-text-secondary); + font-size: 12px; +} + +.membership-option { + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + overflow-wrap: anywhere; +} + +.membership-option:hover { + background: var(--el-fill-color); +} + +.membership-option input { + flex: 0 0 auto; + width: 16px; + height: 16px; + accent-color: var(--el-color-primary); +} + +.membership-option:focus-within { + outline: 2px solid var(--el-color-primary); + outline-offset: -2px; } .membership-summary { diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index cb22792..1aed045 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -57,20 +57,31 @@ export function usePage() { const editModel = ref("edit") const selectedUser = ref(null) + const editingGroupIDs = ref([0]) + const editingMembershipVersion = ref(0) + const membershipChanged = computed(() => { + const saved = selectedUser.value?.GroupIDs || [0] + return saved.length !== editingGroupIDs.value.length + || saved.some(id => !editingGroupIDs.value.includes(id)) + }) const selectedMembershipGroups = computed(() => { const user = selectedUser.value if (!user) return [] return groups.accountGroups.value.filter(group => groups.canManageGroups.value - ? (user.GroupIDs || [0]).includes(group.id) + ? editingGroupIDs.value.includes(group.id) : group.rows.some(member => member.ID === user.ID) ) }) const openMembershipDialog = () => { if (submitting.value || !selectedUser.value) return - groups.editMembership(selectedUser.value) + groups.editMembership(selectedUser.value, { + role: editUserInfo.role, + groupIDs: editingGroupIDs.value, + onApply: ids => { editingGroupIDs.value = ids } + }) } const recoveryCodeDialogVisible = ref(false) @@ -122,6 +133,8 @@ export function usePage() { const handleEdit = function (idx, row) { if (!canEditUser(row)) return selectedUser.value = row + editingGroupIDs.value = [...(row.GroupIDs || [0])] + editingMembershipVersion.value = row.MembershipVersion editUserInfo.account = row.Account editUserInfo.name = row.Name editUserInfo.disabled = row.Disabled === 1 @@ -193,6 +206,13 @@ export function usePage() { } if (creating || roleChanged.value) newData.role = editUserInfo.role if (creating || editUserInfo.password !== '') newData.password = editUserInfo.password + if (!creating && groups.canManageGroups.value && (membershipChanged.value || roleChanged.value)) { + if (editUserInfo.role !== 'admin' && editingGroupIDs.value.length !== 1) { + throw new Error('普通用户只能属于一个分组,请在分组管理中选择一个分组') + } + newData.group_ids = [...editingGroupIDs.value] + newData.membership_version = editingMembershipVersion.value + } const res = await requestWithRecentAuthentication(() => creating ? http.post('/api/v1/user/create', newData) : http.post('/api/v1/user/edit', newData)) diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue index 0bb50d4..96d7f37 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -123,16 +123,44 @@

账号:{{ membershipUser?.Account }}

- - - +
+ + {{ membershipLimit > 1 ? '管理员可勾选多个分组' : '请选择一个分组' }} + + +

管理员可以多选;普通用户只能选择一个分组。

+

+ 此处仅应用到编辑草稿,点击编辑用户的“确认”后,权限级别和分组才会一起保存。 +

@@ -213,7 +241,7 @@

- 分组单独保存,不会提交本窗口其他修改;选择数量按账号已保存的权限级别校验。 + 分组选择跟随当前权限级别,点击“确认”后与其他修改一起保存;取消则不保存。

仅显示你有权查看的分组,成员归属由超级管理员维护。 @@ -312,6 +340,8 @@ const { membershipUser, membershipSelection, membershipLimit, + membershipDraft, + membershipSingleSelection, loadGroup, toggleGroup, saveGroup,