diff --git a/backend/internal/handler/merchant_platform.go b/backend/internal/handler/merchant_platform.go index 4ce4650..d453335 100644 --- a/backend/internal/handler/merchant_platform.go +++ b/backend/internal/handler/merchant_platform.go @@ -180,3 +180,71 @@ func (h *MerchantHandler) AdminUpdateMerchantAccount(c *gin.Context) { } response.OK(c, account) } + +// ListPlatformMerchantMembers GET /api/platform/merchants/:id/members +func (h *MerchantHandler) ListPlatformMerchantMembers(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + response.BadRequest(c, "商户 ID 无效") + return + } + members, err := h.merchantSvc.ListMembers(uint(id)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, members) +} + +// UpdatePlatformMerchantMember PATCH /api/platform/merchants/:id/members/:mid +func (h *MerchantHandler) UpdatePlatformMerchantMember(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + mid, _ := strconv.ParseUint(c.Param("mid"), 10, 64) + if id == 0 || mid == 0 { + response.BadRequest(c, "参数无效") + return + } + var req updateMemberReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:角色必填") + return + } + member, err := h.merchantSvc.UpdateMember(uint(id), uint(mid), 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) +} + +// DeletePlatformMerchantMember DELETE /api/platform/merchants/:id/members/:mid +func (h *MerchantHandler) DeletePlatformMerchantMember(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + mid, _ := strconv.ParseUint(c.Param("mid"), 10, 64) + if id == 0 || mid == 0 { + response.BadRequest(c, "参数无效") + return + } + if err := h.merchantSvc.RemoveMember(uint(id), uint(mid), middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + +// ListPlatformMerchantRoles GET /api/platform/merchants/:id/roles +func (h *MerchantHandler) ListPlatformMerchantRoles(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + response.BadRequest(c, "商户 ID 无效") + return + } + roles, err := h.merchantSvc.ListRoles(uint(id)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, roles) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 507a38a..c171d3c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -243,7 +243,11 @@ func Setup(h *Handlers) *gin.Engine { platform.GET("/platform/orders", h.Merchant.ListPlatformOrders) platform.POST("/platform/merchants", h.Merchant.CreateMerchant) platform.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) + platform.GET("/platform/merchants/:id/members", h.Merchant.ListPlatformMerchantMembers) platform.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) + platform.PATCH("/platform/merchants/:id/members/:mid", h.Merchant.UpdatePlatformMerchantMember) + platform.DELETE("/platform/merchants/:id/members/:mid", h.Merchant.DeletePlatformMerchantMember) + platform.GET("/platform/merchants/:id/roles", h.Merchant.ListPlatformMerchantRoles) platform.GET("/platform/product-catalog", h.Merchant.ListProductCatalog) platform.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin) platform.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct) diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index edb48e9..6e6f995 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -213,3 +213,30 @@ func TestRequestLoggerSuppressesPollingOnInfo(t *testing.T) { t.Errorf("expected GET order to appear as [DEBUG] in DEBUG mode, got: %s", buf.String()) } } + +// TestPlatformMerchantMembersRoutes 验证平台管理员访问商户成员与角色接口完全独立于 X-Merchant-ID。 +func TestPlatformMerchantMembersRoutes(t *testing.T) { + engine, jm := newTestRouter(t) + token, err := jm.Generate(1, "admin", model.RoleAdmin) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + // 1. 获取商户 1 的成员列表 + req := httptest.NewRequest("GET", "/api/platform/merchants/1/members", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("平台管理员查询商户成员应返回 200,实际 %d: %s", w.Code, w.Body.String()) + } + + // 2. 获取商户 1 的角色列表 + req = httptest.NewRequest("GET", "/api/platform/merchants/1/roles", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("平台管理员查询商户角色应返回 200,实际 %d: %s", w.Code, w.Body.String()) + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a434068..e82dc0b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -230,10 +230,21 @@ export const platformApi = { }) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant), updateMerchant: (id: number, data: Partial) => request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data), + members: (merchantId: number) => + request.get(`/platform/merchants/${merchantId}/members`).then((r) => r.data.data as MerchantMember[]), addMember: ( merchantId: number, - data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }, + data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }, ) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember), + updateMember: ( + merchantId: number, + memberId: number, + data: { role: string; status: number; is_default: boolean }, + ) => request.patch(`/platform/merchants/${merchantId}/members/${memberId}`, data).then((r) => r.data.data as MerchantMember), + removeMember: (merchantId: number, memberId: number) => + request.delete(`/platform/merchants/${merchantId}/members/${memberId}`).then((r) => r.data.data), + roles: (merchantId: number) => + request.get(`/platform/merchants/${merchantId}/roles`).then((r) => r.data.data as MerchantRole[]), productCatalog: () => request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]), merchantProducts: (merchantId: number) => diff --git a/frontend/src/components/MerchantMembersDrawer.tsx b/frontend/src/components/MerchantMembersDrawer.tsx new file mode 100644 index 0000000..208fa50 --- /dev/null +++ b/frontend/src/components/MerchantMembersDrawer.tsx @@ -0,0 +1,400 @@ +import { useEffect, useState } from 'react' +import { + Drawer, + Table, + Button, + Space, + Tag, + Badge, + Modal, + Form, + Input, + Select, + Radio, + Switch, + Popconfirm, + Typography, + message, +} from 'antd' +import { PlusOutlined, ReloadOutlined, UserOutlined } from '@ant-design/icons' +import { platformApi } from '../api' +import type { MerchantMember, MerchantRole } from '../types' +import type { ColumnsType } from 'antd/es/table' + +interface MerchantMembersDrawerProps { + open: boolean + merchantId: number | null + merchantName?: string + merchantCode?: string + onClose: () => void +} + +export function MerchantMembersDrawer({ + open, + merchantId, + merchantName, + merchantCode, + onClose, +}: MerchantMembersDrawerProps) { + const [loading, setLoading] = useState(false) + const [members, setMembers] = useState([]) + const [roles, setRoles] = useState([]) + + // 添加成员弹窗状态 + const [addModalOpen, setAddModalOpen] = useState(false) + const [addMode, setAddMode] = useState<'create' | 'link'>('create') + const [addLoading, setAddLoading] = useState(false) + const [addForm] = Form.useForm() + + // 编辑成员弹窗状态 + const [editModalOpen, setEditModalOpen] = useState(false) + const [editingMember, setEditingMember] = useState(null) + const [editLoading, setEditLoading] = useState(false) + const [editForm] = Form.useForm() + + const loadData = async (mid: number) => { + setLoading(true) + try { + const [membersData, rolesData] = await Promise.all([ + platformApi.members(mid), + platformApi.roles(mid), + ]) + setMembers(membersData || []) + setRoles(rolesData || []) + } catch (e) { + message.error(e instanceof Error ? e.message : '获取成员列表失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (open && merchantId) { + void loadData(merchantId) + } else { + setMembers([]) + setRoles([]) + } + }, [open, merchantId]) + + const roleNameMap = new Map() + roles.forEach((r) => roleNameMap.set(r.code, r.name)) + + const handleOpenAdd = () => { + addForm.resetFields() + setAddMode('create') + setAddModalOpen(true) + } + + const handleAddSubmit = async () => { + if (!merchantId) return + try { + const values = await addForm.validateFields() + setAddLoading(true) + const payload: { + role: string + is_default?: boolean + user_id?: number + username?: string + password?: string + nickname?: string + } = { + role: values.role, + is_default: !!values.is_default, + } + if (addMode === 'link') { + payload.user_id = Number(values.user_id) + } else { + payload.username = values.username + payload.password = values.password + payload.nickname = values.nickname + } + await platformApi.addMember(merchantId, payload) + message.success('成员添加成功') + setAddModalOpen(false) + void loadData(merchantId) + } catch (e) { + message.error(e instanceof Error ? e.message : '添加成员失败') + } finally { + setAddLoading(false) + } + } + + const handleOpenEdit = (record: MerchantMember) => { + setEditingMember(record) + editForm.setFieldsValue({ + role: record.role, + status: record.status === 1, + is_default: record.is_default, + }) + setEditModalOpen(true) + } + + const handleEditSubmit = async () => { + if (!merchantId || !editingMember) return + try { + const values = await editForm.validateFields() + setEditLoading(true) + await platformApi.updateMember(merchantId, editingMember.id, { + role: values.role, + status: values.status ? 1 : 0, + is_default: !!values.is_default, + }) + message.success('成员更新成功') + setEditModalOpen(false) + void loadData(merchantId) + } catch (e) { + message.error(e instanceof Error ? e.message : '更新成员失败') + } finally { + setEditLoading(false) + } + } + + const handleRemove = async (memberId: number) => { + if (!merchantId) return + try { + await platformApi.removeMember(merchantId, memberId) + message.success('已移除成员') + void loadData(merchantId) + } catch (e) { + message.error(e instanceof Error ? e.message : '移除失败') + } + } + + const columns: ColumnsType = [ + { + title: '用户名', + dataIndex: ['user', 'username'], + key: 'username', + render: (text) => ( + + + {text} + + ), + }, + { + title: '姓名 / 昵称', + dataIndex: ['user', 'nickname'], + key: 'nickname', + render: (text) => text || '-', + }, + { + title: '角色', + dataIndex: 'role', + key: 'role', + render: (role: string) => { + const name = roleNameMap.get(role) || (role === 'owner' ? '负责人' : role) + const color = role === 'owner' ? 'gold' : 'blue' + return {name} + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status: number) => + status === 1 ? ( + + ) : ( + + ), + }, + { + title: '默认商户', + dataIndex: 'is_default', + key: 'is_default', + render: (val: boolean) => + val ? 默认 : -, + }, + { + title: '操作', + key: 'action', + width: 140, + render: (_, record) => { + const isOwner = record.role === 'owner' + return ( + + + {isOwner ? ( + + 负责人 + + ) : ( + handleRemove(record.id)} + okText="确认" + cancelText="取消" + > + + + )} + + ) + }, + }, + ] + + const titleText = merchantName + ? `成员管理 · ${merchantName}${merchantCode ? ` (${merchantCode})` : ''}` + : '商户成员管理' + + return ( + <> + + + + + } + > + + + + {/* 添加成员弹窗 */} + setAddModalOpen(false)} + onOk={handleAddSubmit} + confirmLoading={addLoading} + destroyOnClose + > +
+ + setAddMode(e.target.value)} + buttonStyle="solid" + > + 新建账号并绑定 + 关联已有平台账号 + + + + {addMode === 'create' ? ( + <> + + + + + + + + + + + ) : ( + + + + )} + + + + + + + + + +
+ + {/* 编辑成员角色与状态弹窗 */} + setEditModalOpen(false)} + onOk={handleEditSubmit} + confirmLoading={editLoading} + destroyOnClose + > +
+ + + + + + + + + + + + +
+ + ) +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index fd4a8db..f58da0a 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -229,16 +229,12 @@ export default function MainLayout() { return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' }) const { user, logout, refreshProfile, isAdmin, merchant, merchantPermissions } = useAuth() - // 管理员的商户上下文来自 localStorage(即请求头 X-Merchant-ID 的来源), - // 之前只会在「平台商户」页点「成员」时被写入,界面上没有任何提示—— - // 管理员很容易以为自己看的是全平台数据,实际被静默框定在某个商户里。 - const [activeMerchantId] = useState(() => localStorage.getItem('merchant_id')) - const merchantContextLabel = merchant?.name || (activeMerchantId ? `商户 #${activeMerchantId}` : '默认商户') - const exitMerchantContext = () => { - localStorage.removeItem('merchant_id') - // 整页刷新:上下文变了,当前页已经加载的数据也必须按新上下文重新取一遍。 - window.location.reload() - } + useEffect(() => { + // 管理员始终保持平台全局视角,清除任何残留的商户上下文 + if (isAdmin && localStorage.getItem('merchant_id')) { + localStorage.removeItem('merchant_id') + } + }, [isAdmin]) const [passwordOpen, setPasswordOpen] = useState(false) const [passwordSaving, setPasswordSaving] = useState(false) const [passwordForm] = Form.useForm() @@ -431,22 +427,6 @@ export default function MainLayout() {
{isAdmin && ( - - - 当前商户:{merchantContextLabel} - - - {activeMerchantId && ( - - - - )}
) } diff --git a/frontend/src/pages/PlatformUsers.tsx b/frontend/src/pages/PlatformUsers.tsx index 785d38e..84efb51 100644 --- a/frontend/src/pages/PlatformUsers.tsx +++ b/frontend/src/pages/PlatformUsers.tsx @@ -8,7 +8,7 @@ import type { MerchantMember, MerchantMemberGroup, PageResult, User } from '../t import { formatDateTime } from '../utils/time' import { useAuth } from '../store/authContext' import { roleText } from './merchantCenterUtils' -import { useNavigate } from 'react-router-dom' +import { MerchantMembersDrawer } from '../components/MerchantMembersDrawer' type AdminFormValues = { username: string @@ -19,7 +19,9 @@ type AdminFormValues = { export default function PlatformUsers() { const { user: currentUser } = useAuth() - const navigate = useNavigate() + const [drawerOpen, setDrawerOpen] = useState(false) + const [selectedMerchantId, setSelectedMerchantId] = useState(null) + const [selectedMerchantName, setSelectedMerchantName] = useState('') 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 }) @@ -169,12 +171,21 @@ export default function PlatformUsers() { { title: '操作', width: 160, - render: (_, record) => , + render: (_, record) => ( + + ), }, - ], [navigate]) + ], []) const items = useMemo(() => [ { @@ -236,6 +247,17 @@ export default function PlatformUsers() { {editingAdmin && } + + { + setDrawerOpen(false) + setSelectedMerchantId(null) + setSelectedMerchantName('') + }} + /> ) }