diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index f9174b6..cc96811 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -29,6 +29,17 @@ func (h *UserHandler) ListPlatformAdmins(c *gin.Context) { response.Page(c, list, total, page, size) } +func (h *UserHandler) ListMerchantAccountGroups(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.svc.ListMerchantAccountGroups(page, size) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + type createPlatformAdminReq struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required,min=6"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fbae9e8..e9d8900 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -193,6 +193,7 @@ func Setup(h *Handlers) *gin.Engine { admin.Use(middleware.RequireRole(model.RoleAdmin)) { admin.GET("/platform/admins", h.User.ListPlatformAdmins) + admin.GET("/platform/merchant-accounts", h.User.ListMerchantAccountGroups) admin.POST("/platform/admins", h.User.CreatePlatformAdmin) admin.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin) admin.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin) diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index c235dc8..68b4492 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -266,6 +266,40 @@ func TestPlatformAdminCRUDDoesNotCreateMerchantMembership(t *testing.T) { } } +func TestListMerchantAccountGroupsExcludesPlatformAdmins(t *testing.T) { + db := newServiceTestDB(t) + merchantA := model.Merchant{Code: "merchant-accounts-a", Name: "商户账号 A", Status: model.MerchantStatusActive} + merchantB := model.Merchant{Code: "merchant-accounts-b", Name: "商户账号 B", Status: model.MerchantStatusActive} + employeeA := model.User{Username: "merchant-account-a", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + employeeB := model.User{Username: "merchant-account-b", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + admin := model.User{Username: "merchant-account-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: "support", 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).ListMerchantAccountGroups(1, 20) + if err != nil { + t.Fatalf("list merchant account groups: %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 || len(groups[0].Members) != 2 { + t.Fatalf("unexpected merchant account group: %+v", groups[0]) + } +} + 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 6d1c039..b3f13c2 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -27,6 +27,14 @@ type UserListQuery struct { Status *int } +// MerchantMemberGroup presents merchant accounts in their actual ownership +// hierarchy: one merchant and all of its employee accounts. +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 @@ -123,6 +131,55 @@ func (s *UserService) ListPlatformAdmins(page, size int) ([]model.User, int64, e return s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin}) } +// ListMerchantAccountGroups returns merchant employee accounts grouped by +// merchant. Platform administrators are excluded from this independent view. +func (s *UserService) ListMerchantAccountGroups(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.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 + if 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"). + Find(&members).Error; err != nil { + return nil, 0, err + } + + groups := make([]MerchantMemberGroup, 0, len(merchantIDs)) + byMerchantID := make(map[uint]*MerchantMemberGroup, len(merchantIDs)) + for _, merchantID := range merchantIDs { + groups = append(groups, MerchantMemberGroup{MerchantID: merchantID, Members: []model.MerchantMember{}}) + byMerchantID[merchantID] = &groups[len(groups)-1] + } + for _, member := range members { + group := byMerchantID[member.MerchantID] + if group.Merchant == nil { + group.Merchant = member.Merchant + } + group.Members = append(group.Members, member) + } + return groups, total, nil +} + // CreatePlatformAdmin creates an account with platform-only privileges. It // deliberately does not assign the account to any merchant. func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d7080cd..1d09c8a 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, @@ -176,6 +177,8 @@ export const deliveryApi = { } export const platformApi = { + merchantAccounts: (params?: Record) => + request.get('/platform/merchant-accounts', { 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 ea61f0a..ed442ac 100644 --- a/frontend/src/pages/PlatformUsers.tsx +++ b/frontend/src/pages/PlatformUsers.tsx @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useState } from 'react' -import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, message } from 'antd' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tabs, Tag, Typography, message } from 'antd' import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' -import { userApi } from '../api' +import { platformApi, userApi } from '../api' import { PageHeader } from '../components/PageHeader' -import type { 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' +import { useNavigate } from 'react-router-dom' type AdminFormValues = { username: string @@ -17,7 +19,10 @@ type AdminFormValues = { export default function PlatformUsers() { const { user: currentUser } = useAuth() + const navigate = useNavigate() + const [tab, setTab] = useState('admins') const [admins, setAdmins] = useState>({ list: [], total: 0, page: 1, size: 20 }) + const [merchantAccounts, setMerchantAccounts] = useState>({ list: [], total: 0, page: 1, size: 20 }) const [loading, setLoading] = useState(false) const [submitting, setSubmitting] = useState(false) const [editingAdmin, setEditingAdmin] = useState(null) @@ -35,9 +40,21 @@ export default function PlatformUsers() { } }, [admins.page, admins.size]) + const loadMerchantAccounts = useCallback(async (page = merchantAccounts.page, size = merchantAccounts.size) => { + setLoading(true) + try { + setMerchantAccounts(await platformApi.merchantAccounts({ page, size })) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [merchantAccounts.page, merchantAccounts.size]) + useEffect(() => { - void loadAdmins() - }, [loadAdmins]) + if (tab === 'admins') void loadAdmins() + else void loadMerchantAccounts() + }, [loadAdmins, loadMerchantAccounts, tab]) const openCreate = () => { setEditingAdmin(null) @@ -106,21 +123,75 @@ export default function PlatformUsers() { }, ] - return ( -
- } - /> - = [ + { title: '员工账号', dataIndex: ['user', 'username'], width: 260, render: (_, record) => {record.user?.username || `#${record.user_id}`} }, + { title: '昵称', dataIndex: ['user', 'nickname'], width: 220, render: (_, record) => record.user?.nickname || '-' }, + { title: '商户角色', dataIndex: 'role', width: 160, render: (role) => {roleText(role)} }, + { title: '成员状态', dataIndex: 'status', width: 130, render: (status) => status === 1 ? 启用 : 停用 }, + ] + + const merchantColumns: ColumnsType = [ + { + title: '商户', + width: 320, + render: (_, record) => {record.merchant?.name || `商户#${record.merchant_id}`}{record.merchant?.code || '-'}, + }, + { title: '下游账号', width: 130, 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: 160, + render: (_, record) => , + }, + ] + + const items = useMemo(() => [ + { + key: 'admins', + label: `平台管理员 (${admins.total})`, + children:
void loadAdmins(page, size) }} + />, + }, + { + key: 'merchant-accounts', + label: `商户账号 (${merchantAccounts.total})`, + children:
, + rowExpandable: (record) => record.members.length > 0, + }} + pagination={{ current: merchantAccounts.page, pageSize: merchantAccounts.size, total: merchantAccounts.total, showSizeChanger: true, onChange: (page, size) => void loadMerchantAccounts(page, size) }} + />, + }, + ], [admins, columns, loadAdmins, loadMerchantAccounts, loading, memberColumns, merchantAccounts, merchantColumns]) + + return ( +
+ {tab === 'admins' && }} /> +