diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index c9a98a9..278ba19 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -256,6 +256,43 @@ func TestUserServicePromotesMerchantAccountToPlatformAdmin(t *testing.T) { } } +func TestListMerchantMembersGroupsEmployeesByMerchant(t *testing.T) { + db := newServiceTestDB(t) + merchantA := model.Merchant{Code: "grouped-members-a", Name: "分组商户 A", Status: model.MerchantStatusActive} + merchantB := model.Merchant{Code: "grouped-members-b", Name: "分组商户 B", Status: model.MerchantStatusActive} + employeeA := model.User{Username: "grouped-employee-a", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + employeeB := model.User{Username: "grouped-employee-b", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + admin := model.User{Username: "grouped-admin", PasswordHash: "hash", Role: model.RoleAdmin, Status: 1} + for _, entity := range []interface{}{&merchantA, &merchantB, &employeeA, &employeeB, &admin} { + if err := db.Create(entity).Error; err != nil { + t.Fatalf("create fixture: %v", err) + } + } + for _, member := range []model.MerchantMember{ + {MerchantID: merchantA.ID, UserID: employeeA.ID, Role: model.MemberRoleOperator, Status: 1}, + {MerchantID: merchantA.ID, UserID: employeeB.ID, Role: model.MemberRoleFinance, Status: 1}, + {MerchantID: merchantB.ID, UserID: admin.ID, Role: model.MemberRoleOwner, Status: 1}, + } { + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create membership: %v", err) + } + } + + groups, total, err := NewUserService(db, nil).ListMerchantMembers(1, 20) + if err != nil { + t.Fatalf("list grouped members: %v", err) + } + if total != 1 || len(groups) != 1 { + t.Fatalf("expected one merchant group, total=%d groups=%+v", total, groups) + } + if groups[0].MerchantID != merchantA.ID || groups[0].Merchant == nil || groups[0].Merchant.Name != merchantA.Name { + t.Fatalf("unexpected merchant group: %+v", groups[0]) + } + if len(groups[0].Members) != 2 { + t.Fatalf("expected two employees in the merchant group, got %+v", groups[0].Members) + } +} + func TestEnsureAdminUsesConfiguredCredentialsOnlyForEmptyDatabase(t *testing.T) { db := newServiceTestDB(t) tenant := NewTenantService(db) diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 74dbae9..e94e349 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -28,6 +28,15 @@ type UserListQuery struct { Status *int } +// MerchantMemberGroup keeps employee assignments grouped by merchant for +// platform account management. Pagination is intentionally based on merchants +// so one merchant's employees are never split across pages. +type MerchantMemberGroup struct { + MerchantID uint `json:"merchant_id"` + Merchant *model.Merchant `json:"merchant,omitempty"` + Members []model.MerchantMember `json:"members"` +} + func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { if q.Page < 1 { q.Page = 1 @@ -59,21 +68,51 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { return list, total, err } -// ListMerchantMembers returns employee assignments across merchants for platform operations. -func (s *UserService) ListMerchantMembers(page, size int) ([]model.MerchantMember, int64, error) { +// ListMerchantMembers returns employee assignments grouped by merchant for platform operations. +func (s *UserService) ListMerchantMembers(page, size int) ([]MerchantMemberGroup, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.MerchantMember{}). Joins("JOIN users ON users.id = merchant_members.user_id AND users.deleted_at IS NULL"). Where("users.role = ?", model.RoleMerchant) var total int64 - if err := tx.Count(&total).Error; err != nil { + if err := tx.Distinct("merchant_members.merchant_id").Count(&total).Error; err != nil { return nil, 0, err } + var merchantIDs []uint + if err := tx.Distinct("merchant_members.merchant_id"). + Order("merchant_members.merchant_id ASC"). + Offset((page-1)*size).Limit(size).Pluck("merchant_members.merchant_id", &merchantIDs).Error; err != nil { + return nil, 0, err + } + if len(merchantIDs) == 0 { + return []MerchantMemberGroup{}, total, nil + } var members []model.MerchantMember - err := tx.Preload("Merchant").Preload("User"). + err := s.db.Model(&model.MerchantMember{}). + Joins("JOIN users ON users.id = merchant_members.user_id AND users.deleted_at IS NULL"). + Where("users.role = ? AND merchant_members.merchant_id IN ?", model.RoleMerchant, merchantIDs). + Preload("Merchant").Preload("User"). Order("merchant_members.merchant_id ASC, merchant_members.id ASC"). - Offset((page - 1) * size).Limit(size).Find(&members).Error - return members, total, err + Find(&members).Error + if err != nil { + return nil, 0, err + } + + groupsByMerchant := make(map[uint]*MerchantMemberGroup, len(merchantIDs)) + groups := make([]MerchantMemberGroup, 0, len(merchantIDs)) + for _, merchantID := range merchantIDs { + group := MerchantMemberGroup{MerchantID: merchantID, Members: []model.MerchantMember{}} + groups = append(groups, group) + groupsByMerchant[merchantID] = &groups[len(groups)-1] + } + for _, member := range members { + group := groupsByMerchant[member.MerchantID] + if group.Merchant == nil { + group.Merchant = member.Merchant + } + group.Members = append(group.Members, member) + } + return groups, total, nil } func (s *UserService) Create(username, password, nickname, role string, merchantID uint) (*model.User, error) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d4dda64..cfefef5 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -18,6 +18,7 @@ import type { LowBalanceAlertConfig, Merchant, MerchantMember, + MerchantMemberGroup, MerchantRole, MerchantProduct, PageResult, @@ -179,7 +180,7 @@ export const deliveryApi = { export const platformApi = { members: (params?: Record) => - request.get('/platform/members', { params }).then((r) => r.data.data as PageResult), + request.get('/platform/members', { params }).then((r) => r.data.data as PageResult), merchants: (params?: Record) => request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult), createMerchant: (data: { diff --git a/frontend/src/pages/PlatformUsers.tsx b/frontend/src/pages/PlatformUsers.tsx index 63b296a..528f157 100644 --- a/frontend/src/pages/PlatformUsers.tsx +++ b/frontend/src/pages/PlatformUsers.tsx @@ -4,7 +4,7 @@ import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant- import type { ColumnsType } from 'antd/es/table' import { platformApi, userApi } from '../api' import { PageHeader } from '../components/PageHeader' -import type { MerchantMember, PageResult, User } from '../types' +import type { MerchantMember, MerchantMemberGroup, PageResult, User } from '../types' import { formatDateTime } from '../utils/time' import { useAuth } from '../store/auth' import { roleText } from './merchantCenterUtils' @@ -15,7 +15,7 @@ export default function PlatformUsers() { const navigate = useNavigate() const [tab, setTab] = useState('admins') const [admins, setAdmins] = useState>({ list: [], total: 0, page: 1, size: 20 }) - const [members, setMembers] = useState>({ list: [], total: 0, page: 1, size: 20 }) + const [members, setMembers] = useState>({ list: [], total: 0, page: 1, size: 20 }) const [loading, setLoading] = useState(false) const loadAdmins = useCallback(async (page = admins.page, size = admins.size) => { @@ -82,10 +82,9 @@ export default function PlatformUsers() { ] const memberColumns: ColumnsType = [ - { title: '商户', width: 240, render: (_, record) => {record.merchant?.name || `商户#${record.merchant_id}`}{record.merchant?.code} }, - { title: '员工账号', dataIndex: ['user', 'username'], width: 200, render: (_, record) => record.user?.username || `#${record.user_id}` }, - { title: '昵称', dataIndex: ['user', 'nickname'], width: 160, render: (_, record) => record.user?.nickname || '-' }, - { title: '商户角色', dataIndex: 'role', width: 130, render: (role) => {roleText(role)} }, + { title: '员工账号', dataIndex: ['user', 'username'], width: 220, render: (_, record) => {record.user?.username || `#${record.user_id}`} }, + { title: '昵称', dataIndex: ['user', 'nickname'], width: 180, render: (_, record) => record.user?.nickname || '-' }, + { title: '商户角色', dataIndex: 'role', width: 140, render: (role) => {roleText(role)} }, { title: '成员状态', dataIndex: 'status', width: 110, render: (status) => status === 1 ? 启用 : 停用 }, { title: '操作', @@ -97,15 +96,47 @@ export default function PlatformUsers() { )} - ), }, ] + const merchantColumns: ColumnsType = [ + { + title: '商户', + width: 310, + render: (_, record) => {record.merchant?.name || `商户#${record.merchant_id}`}{record.merchant?.code || '-'}, + }, + { + title: '员工', + width: 120, + render: (_, record) => {record.members.length} 人, + }, + { + title: '角色分布', + render: (_, record) => { + const roles = [...new Set(record.members.map((member) => member.role))] + return {roles.map((role) => {roleText(role)})} + }, + }, + { + title: '状态', + width: 150, + render: (_, record) => { + const enabled = record.members.filter((member) => member.status === 1).length + return <>启用 {enabled}{enabled < record.members.length && 停用 {record.members.length - enabled}} + }, + }, + { + title: '操作', + width: 150, + render: (_, record) => , + }, + ] + const items = useMemo(() => [ { key: 'admins', @@ -115,7 +146,17 @@ export default function PlatformUsers() { { key: 'members', label: `商户员工 (${members.total})`, - children: loadMembers(page, size) }} />, + children:
, + rowExpandable: (record) => record.members.length > 0, + }} + pagination={{ current: members.page, pageSize: members.size, total: members.total, showSizeChanger: true, onChange: (page, size) => loadMembers(page, size) }} + />, }, ], [adminColumns, admins, loadAdmins, loadMembers, loading, memberColumns, members]) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6b1aea7..17a0402 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -33,6 +33,12 @@ export interface MerchantMember { created_at: string } +export interface MerchantMemberGroup { + merchant_id: number + merchant?: Merchant + members: MerchantMember[] +} + export interface MerchantRole { id: number merchant_id: number