feat: manage merchant members
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ApiCredential | null>(null)
|
||||
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(null)
|
||||
const [memberOpen, setMemberOpen] = useState(false)
|
||||
const [editingMember, setEditingMember] = useState<MerchantMember | null>(null)
|
||||
const [roleOpen, setRoleOpen] = useState(false)
|
||||
const [editingRole, setEditingRole] = useState<MerchantRole | null>(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
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title={editingMember ? `编辑员工:${editingMember.user?.nickname || editingMember.user?.username || ''}` : '编辑员工'} open={!!editingMember} onOk={submitMemberEdit} onCancel={() => setEditingMember(null)} destroyOnClose>
|
||||
<Form form={memberEditForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item label="账号"><Input value={editingMember?.user?.username} disabled /></Form.Item>
|
||||
<Form.Item name="role" label="团队角色" rules={[{ required: true }]}>
|
||||
<Select options={roles.map((item) => ({ value: item.code, label: item.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 1, label: '启用' }, { value: 0, label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_default" label="设为默认商户" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title={editingRole ? `编辑角色:${editingRole.name}` : '新建团队角色'} open={roleOpen} onOk={submitRole} onCancel={() => { setRoleOpen(false); setEditingRole(null) }} destroyOnClose>
|
||||
<Form form={roleForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="角色名称" rules={[{ required: true }]}><Input maxLength={64} placeholder="例如:客服" /></Form.Item>
|
||||
|
||||
@@ -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<MerchantMember> = [
|
||||
@@ -685,6 +693,25 @@ export function MembersTab({
|
||||
{ title: '角色', dataIndex: 'role', width: 140, render: (value) => <Tag color="blue">{roleNames.get(value) || roleText(value)}</Tag> },
|
||||
{ title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? <Tag color="blue">默认</Tag> : '-') },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 210,
|
||||
render: (_, member) => {
|
||||
const protectedMember = member.role === 'owner' || member.user_id === currentUserID
|
||||
if (!canManage) return '-'
|
||||
if (protectedMember) return <Typography.Text type="secondary">受保护</Typography.Text>
|
||||
const action = member.status === 1 ? '停用' : '启用'
|
||||
return <Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEditMember(member)}>编辑</Button>
|
||||
<Popconfirm title={`确认${action}该员工?`} onConfirm={() => onToggleMemberStatus(member)}>
|
||||
<Button type="link" size="small">{action}</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm title="确认移除该员工?" description="仅移除当前商户成员关系,账号不会被删除。" onConfirm={() => onRemoveMember(member)} okButtonProps={{ danger: true }}>
|
||||
<Button type="link" danger size="small">移除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user