diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index 3efc49b..dbb5435 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -438,6 +438,42 @@ func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) { response.OK(c, member) } +type updateMemberReq struct { + Role string `json:"role" binding:"required"` + Status int `json:"status"` + IsDefault bool `json:"is_default"` +} + +func (h *MerchantHandler) UpdateCurrentMerchantMember(c *gin.Context) { + memberID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req updateMemberReq + if memberID == 0 || c.ShouldBindJSON(&req) != nil { + response.BadRequest(c, "参数错误:角色必填") + return + } + member, err := h.merchantSvc.UpdateMember(middleware.GetMerchantID(c), uint(memberID), service.UpdateMemberInput{ + Role: req.Role, Status: req.Status, IsDefault: req.IsDefault, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, member) +} + +func (h *MerchantHandler) RemoveCurrentMerchantMember(c *gin.Context) { + memberID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if memberID == 0 { + response.BadRequest(c, "参数错误") + return + } + if err := h.merchantSvc.RemoveMember(middleware.GetMerchantID(c), uint(memberID), middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + func (h *MerchantHandler) ListRoles(c *gin.Context) { roles, err := h.merchantSvc.ListRoles(middleware.GetMerchantID(c)) if err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index efe2880..67fa17c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -179,6 +179,8 @@ func Setup(h *Handlers) *gin.Engine { merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionCallbacksManage), h.Merchant.CreateCallback) merchant.GET("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListMembers) merchant.POST("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.AddCurrentMerchantMember) + merchant.PATCH("/members/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateCurrentMerchantMember) + merchant.DELETE("/members/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.RemoveCurrentMerchantMember) merchant.GET("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListRoles) merchant.POST("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.CreateRole) merchant.PATCH("/roles/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateRole) diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go index 0d0f736..a164d49 100644 --- a/backend/internal/service/merchant_member.go +++ b/backend/internal/service/merchant_member.go @@ -23,6 +23,12 @@ type AddMemberInput struct { IsDefault bool } +type UpdateMemberInput struct { + Role string + Status int + IsDefault bool +} + type MerchantRoleInput struct { Code string Name string @@ -224,3 +230,67 @@ func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error return members, err } + +// UpdateMember updates a member's relationship with one merchant without changing the login account. +func (s *MerchantService) UpdateMember(merchantID, memberID uint, in UpdateMemberInput, actorUserID uint) (*model.MerchantMember, error) { + in.Role = strings.TrimSpace(in.Role) + if in.Role == "" { + return nil, errors.New("商户成员角色不能为空") + } + if in.Status != 0 && in.Status != 1 { + return nil, errors.New("成员状态无效") + } + var member model.MerchantMember + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ? AND merchant_id = ?", memberID, merchantID).First(&member).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("成员不存在") + } + return err + } + if member.Role == model.MemberRoleOwner { + return errors.New("负责人不能在成员管理中修改") + } + if member.UserID == actorUserID { + return errors.New("不能修改当前登录账号") + } + if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil { + return err + } + updates := map[string]interface{}{"role": in.Role, "status": in.Status, "is_default": in.IsDefault} + if err := tx.Model(&member).Updates(updates).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.update", "merchant_member", fmt.Sprint(memberID), map[string]string{"role": in.Role, "status": fmt.Sprint(in.Status)}) + }) + if err != nil { + return nil, err + } + if err := s.db.Preload("User").First(&member, member.ID).Error; err != nil { + return nil, err + } + return &member, nil +} + +// RemoveMember removes only the selected merchant relationship and retains the account for audit and other merchants. +func (s *MerchantService) RemoveMember(merchantID, memberID, actorUserID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var member model.MerchantMember + if err := tx.Where("id = ? AND merchant_id = ?", memberID, merchantID).First(&member).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("成员不存在") + } + return err + } + if member.Role == model.MemberRoleOwner { + return errors.New("负责人不能移除") + } + if member.UserID == actorUserID { + return errors.New("不能移除当前登录账号") + } + if err := tx.Delete(&member).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.remove", "merchant_member", fmt.Sprint(memberID), nil) + }) +} diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index 471efc2..fde73e2 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -129,3 +129,48 @@ func TestDefaultMerchantRolesCanBeEdited(t *testing.T) { t.Fatalf("unexpected updated role: %+v", updated) } } + +func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) { + db := newServiceTestDB(t) + merchant := model.Merchant{Code: "member-crud", Name: "成员管理商户", Status: model.MerchantStatusActive} + actor := model.User{Username: "member-crud-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + employee := model.User{Username: "member-crud-employee", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + if err := db.Create(&actor).Error; err != nil { + t.Fatalf("create actor: %v", err) + } + if err := db.Create(&employee).Error; err != nil { + t.Fatalf("create employee: %v", err) + } + if err := createDefaultMerchantRoles(db, merchant.ID); err != nil { + t.Fatalf("create default roles: %v", err) + } + member := model.MerchantMember{MerchantID: merchant.ID, UserID: employee.ID, Role: "support", Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + + svc := NewMerchantService(db, nil, NewTenantService(db)) + updated, err := svc.UpdateMember(merchant.ID, member.ID, UpdateMemberInput{ + Role: model.MemberRoleFinance, Status: 0, IsDefault: true, + }, actor.ID) + if err != nil { + t.Fatalf("update member: %v", err) + } + if updated.Role != model.MemberRoleFinance || updated.Status != 0 || !updated.IsDefault { + t.Fatalf("unexpected updated member: %+v", updated) + } + if err := svc.RemoveMember(merchant.ID, member.ID, actor.ID); err != nil { + t.Fatalf("remove member: %v", err) + } + var user model.User + if err := db.First(&user, employee.ID).Error; err != nil { + t.Fatalf("employee account should remain: %v", err) + } + var count int64 + if err := db.Model(&model.MerchantMember{}).Where("id = ?", member.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("member relationship should be removed, count=%d err=%v", count, err) + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 14bed32..7e80439 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -106,6 +106,10 @@ export const merchantApi = { request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]), addMember: (data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }) => request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember), + updateMember: (id: number, data: { role: string; status: number; is_default: boolean }) => + request.patch(`/merchant/members/${id}`, data).then((r) => r.data.data as MerchantMember), + removeMember: (id: number) => + request.delete(`/merchant/members/${id}`).then((r) => r.data.data), roles: () => request.get('/merchant/roles').then((r) => r.data.data as MerchantRole[]), createRole: (data: { code: string; name: string; permissions: string[] }) => request.post('/merchant/roles', data).then((r) => r.data.data as MerchantRole), diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 5fccaad..078b41e 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -7,6 +7,7 @@ import { InputNumber, Modal, Select, + Switch, Tabs, Typography, message, @@ -53,7 +54,7 @@ import { } from './merchantCenterTabs' export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) { - const { isAdmin } = useAuth() + const { isAdmin, user } = useAuth() const location = useLocation() const navigate = useNavigate() const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search]) @@ -75,6 +76,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [apiCredential, setApiCredential] = useState(null) const [callbackCredential, setCallbackCredential] = useState(null) const [memberOpen, setMemberOpen] = useState(false) + const [editingMember, setEditingMember] = useState(null) const [roleOpen, setRoleOpen] = useState(false) const [editingRole, setEditingRole] = useState(null) const [manualOrderOpen, setManualOrderOpen] = useState(false) @@ -85,6 +87,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [apiClientForm] = Form.useForm() const [callbackForm] = Form.useForm() const [memberForm] = Form.useForm() + const [memberEditForm] = Form.useForm() const [roleForm] = Form.useForm() const [manualOrderForm] = Form.useForm() @@ -420,6 +423,47 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer } } + const submitMemberEdit = async () => { + if (!editingMember) return + try { + const values = await memberEditForm.validateFields() + await merchantApi.updateMember(editingMember.id, { + role: values.role, + status: values.status, + is_default: values.is_default || false, + }) + setEditingMember(null) + message.success('成员已更新') + loadMembers() + } catch (e) { + message.error(e instanceof Error ? e.message : '更新失败') + } + } + + const toggleMemberStatus = async (member: MerchantMember) => { + try { + await merchantApi.updateMember(member.id, { + role: member.role, + status: member.status === 1 ? 0 : 1, + is_default: member.is_default, + }) + message.success(member.status === 1 ? '员工已停用' : '员工已启用') + loadMembers() + } catch (e) { + message.error(e instanceof Error ? e.message : '操作失败') + } + } + + const removeMember = async (member: MerchantMember) => { + try { + await merchantApi.removeMember(member.id) + message.success('员工已从当前商户移除') + loadMembers() + } catch (e) { + message.error(e instanceof Error ? e.message : '移除失败') + } + } + const submitRole = async () => { const values = await roleForm.validateFields() try { @@ -494,6 +538,13 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer }) setRoleOpen(true) }} + onEditMember={(member) => { + setEditingMember(member) + memberEditForm.setFieldsValue({ role: member.role, status: member.status, is_default: member.is_default }) + }} + onToggleMemberStatus={toggleMemberStatus} + onRemoveMember={removeMember} + currentUserID={user?.id} /> ) }, ] @@ -614,6 +665,21 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer + setEditingMember(null)} destroyOnClose> +
+ + + + + + + +
+
+ { setRoleOpen(false); setEditingRole(null) }} destroyOnClose>
diff --git a/frontend/src/pages/merchantCenterTabs.tsx b/frontend/src/pages/merchantCenterTabs.tsx index 4c30a26..62fbc73 100644 --- a/frontend/src/pages/merchantCenterTabs.tsx +++ b/frontend/src/pages/merchantCenterTabs.tsx @@ -668,6 +668,10 @@ export function MembersTab({ onOpenAdd, onOpenCreateRole, onEditRole, + onEditMember, + onToggleMemberStatus, + onRemoveMember, + currentUserID, }: { loading: boolean members: MerchantMember[] @@ -677,6 +681,10 @@ export function MembersTab({ onOpenAdd: () => void onOpenCreateRole: () => void onEditRole: (role: MerchantRole) => void + onEditMember: (member: MerchantMember) => void + onToggleMemberStatus: (member: MerchantMember) => void + onRemoveMember: (member: MerchantMember) => void + currentUserID?: number }) { const roleNames = new Map(roles.map((item) => [item.code, item.name])) const memberColumns: ColumnsType = [ @@ -685,6 +693,25 @@ export function MembersTab({ { title: '角色', dataIndex: 'role', width: 140, render: (value) => {roleNames.get(value) || roleText(value)} }, { title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? 默认 : '-') }, { title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? 启用 : 禁用) }, + { + title: '操作', + width: 210, + render: (_, member) => { + const protectedMember = member.role === 'owner' || member.user_id === currentUserID + if (!canManage) return '-' + if (protectedMember) return 受保护 + const action = member.status === 1 ? '停用' : '启用' + return + + onToggleMemberStatus(member)}> + + + onRemoveMember(member)} okButtonProps={{ danger: true }}> + + + + }, + }, ] return (