feat(admin): 平台商户成员管理改用抽屉,消除商户视角切换与全局状态污染

- 交互重构:在「平台商户」与「平台账号」点击成员管理时,不再跳转路由或改写 localStorage.merchant_id,改为滑出 MerchantMembersDrawer 抽屉
- 抽屉能力:支持查看指定商户成员列表、新增成员(新建账号或关联已有账号)、修改角色与启用状态、移除成员
- 顶栏清理:彻底移除顶栏右上角的「当前商户:xxx」标签与「退出商户视角」按钮,超管登录自动清理任何残留上下文
- 后端补齐:新增平台专属 /api/platform/merchants/:id/members 与 roles 接口,平台操作不再依赖 X-Merchant-ID
- 验证:tsc -b && vite build 成功;全量 go test ./... 包含路由与鉴权回归测试 100% 通过
This commit is contained in:
yml2213
2026-10-06 21:13:37 +08:00
parent 3ec7febb8c
commit 8cc5d42d53
8 changed files with 562 additions and 38 deletions
@@ -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)
}
+4
View File
@@ -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)
+27
View File
@@ -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())
}
}
+12 -1
View File
@@ -230,10 +230,21 @@ export const platformApi = {
}) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant),
updateMerchant: (id: number, data: Partial<Merchant>) =>
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) =>
@@ -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<MerchantMember[]>([])
const [roles, setRoles] = useState<MerchantRole[]>([])
// 添加成员弹窗状态
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<MerchantMember | null>(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<string, string>()
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<MerchantMember> = [
{
title: '用户名',
dataIndex: ['user', 'username'],
key: 'username',
render: (text) => (
<Space size="small">
<UserOutlined style={{ color: '#94a3b8' }} />
<Typography.Text strong>{text}</Typography.Text>
</Space>
),
},
{
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 <Tag color={color}>{name}</Tag>
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: number) =>
status === 1 ? (
<Badge status="success" text="启用" />
) : (
<Badge status="error" text="禁用" />
),
},
{
title: '默认商户',
dataIndex: 'is_default',
key: 'is_default',
render: (val: boolean) =>
val ? <Tag color="green">默认</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '操作',
key: 'action',
width: 140,
render: (_, record) => {
const isOwner = record.role === 'owner'
return (
<Space size="small">
<Button
type="link"
size="small"
disabled={isOwner}
onClick={() => handleOpenEdit(record)}
>
编辑
</Button>
{isOwner ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
负责人
</Typography.Text>
) : (
<Popconfirm
title="确定从该商户移除此成员?"
description="移除后该账号将无法再访问该商户后台"
onConfirm={() => handleRemove(record.id)}
okText="确认"
cancelText="取消"
>
<Button type="link" size="small" danger>
移除
</Button>
</Popconfirm>
)}
</Space>
)
},
},
]
const titleText = merchantName
? `成员管理 · ${merchantName}${merchantCode ? ` (${merchantCode})` : ''}`
: '商户成员管理'
return (
<>
<Drawer
title={titleText}
open={open}
onClose={onClose}
width={760}
destroyOnClose
extra={
<Space>
<Button
icon={<ReloadOutlined />}
onClick={() => merchantId && void loadData(merchantId)}
loading={loading}
>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenAdd}>
添加成员
</Button>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={members}
loading={loading}
pagination={false}
size="middle"
/>
</Drawer>
{/* 添加成员弹窗 */}
<Modal
title="添加商户成员"
open={addModalOpen}
onCancel={() => setAddModalOpen(false)}
onOk={handleAddSubmit}
confirmLoading={addLoading}
destroyOnClose
>
<Form form={addForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item label="添加方式">
<Radio.Group
value={addMode}
onChange={(e) => setAddMode(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="create">新建账号并绑定</Radio.Button>
<Radio.Button value="link">关联已有平台账号</Radio.Button>
</Radio.Group>
</Form.Item>
{addMode === 'create' ? (
<>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少 3 个字符' },
]}
>
<Input placeholder="员工登录用户名" />
</Form.Item>
<Form.Item
name="password"
label="初始密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少 6 个字符' },
]}
>
<Input.Password placeholder="员工登录密码" />
</Form.Item>
<Form.Item name="nickname" label="姓名 / 昵称">
<Input placeholder="员工真实姓名或备注昵称" />
</Form.Item>
</>
) : (
<Form.Item
name="user_id"
label="平台账号 UserID"
rules={[{ required: true, message: '请输入平台账号 UserID' }]}
>
<Input placeholder="输入已存在的平台账号 ID" type="number" />
</Form.Item>
)}
<Form.Item
name="role"
label="所属角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select placeholder="请选择角色">
{roles.map((r) => (
<Select.Option key={r.code} value={r.code}>
{r.name} ({r.code})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="is_default" label="设为该账号的默认商户" valuePropName="checked">
<Switch defaultChecked={addMode === 'create'} />
</Form.Item>
</Form>
</Modal>
{/* 编辑成员角色与状态弹窗 */}
<Modal
title={`编辑成员:${editingMember?.user?.username || ''}`}
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
onOk={handleEditSubmit}
confirmLoading={editLoading}
destroyOnClose
>
<Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="role"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select placeholder="请选择角色">
{roles
.filter((r) => r.code !== 'owner')
.map((r) => (
<Select.Option key={r.code} value={r.code}>
{r.name} ({r.code})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="status" label="账号状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="is_default" label="设为该账号的默认商户" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
)
}
+6 -26
View File
@@ -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<string | null>(() => 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() {
<div className="app-header__right">
{isAdmin && (
<Space size="small">
<Tooltip title="商户后台页面(发货订单 / 商品 / 钱包 / 成员等)都按这个商户隔离;首页看板与「下游订单」是平台口径,不受它影响">
<Tag
color={activeMerchantId ? 'orange' : 'default'}
bordered={false}
style={{ margin: 0, fontSize: 11.5, fontWeight: 600 }}
>
当前商户:{merchantContextLabel}
</Tag>
</Tooltip>
{activeMerchantId && (
<Tooltip title="回到你的默认商户上下文(自营商户)">
<Button type="text" size="small" onClick={exitMerchantContext}>
退出商户视角
</Button>
</Tooltip>
)}
<Tooltip title="开放 API 接口文档">
<Button
type="text"
+16 -4
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button,
Checkbox,
@@ -23,6 +22,7 @@ import { platformApi } from '../api'
import type { Merchant, MerchantOwnerAccount, PageResult, ProductCatalogItem, WalletAccount } from '../types'
import { formatDateTime } from '../utils/time'
import { PageHeader } from '../components/PageHeader'
import { MerchantMembersDrawer } from '../components/MerchantMembersDrawer'
const featureOptions = [
{ value: 'products', label: '商品' },
@@ -33,9 +33,10 @@ const featureOptions = [
]
export default function PlatformMerchants() {
const navigate = useNavigate()
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false)
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false)
const [membersDrawerMerchant, setMembersDrawerMerchant] = useState<Merchant | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [assignOpen, setAssignOpen] = useState(false)
@@ -344,8 +345,8 @@ export default function PlatformMerchants() {
type="link"
size="small"
onClick={() => {
localStorage.setItem('merchant_id', String(record.id))
navigate('/merchant-members')
setMembersDrawerMerchant(record)
setMembersDrawerOpen(true)
}}
>
成员
@@ -752,6 +753,17 @@ export default function PlatformMerchants() {
</Typography.Text>
</Form>
</Modal>
<MerchantMembersDrawer
open={membersDrawerOpen}
merchantId={membersDrawerMerchant?.id ?? null}
merchantName={membersDrawerMerchant?.name}
merchantCode={membersDrawerMerchant?.code}
onClose={() => {
setMembersDrawerOpen(false)
setMembersDrawerMerchant(null)
}}
/>
</div>
)
}
+29 -7
View File
@@ -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<number | null>(null)
const [selectedMerchantName, setSelectedMerchantName] = useState<string>('')
const [tab, setTab] = useState('admins')
const [admins, setAdmins] = useState<PageResult<User>>({ list: [], total: 0, page: 1, size: 20 })
const [merchantAccounts, setMerchantAccounts] = useState<PageResult<MerchantMemberGroup>>({ list: [], total: 0, page: 1, size: 20 })
@@ -169,12 +171,21 @@ export default function PlatformUsers() {
{
title: '操作',
width: 160,
render: (_, record) => <Button type="link" size="small" onClick={() => {
localStorage.setItem('merchant_id', String(record.merchant_id))
navigate('/merchant-members')
}}>管理商户成员</Button>,
render: (_, record) => (
<Button
type="link"
size="small"
onClick={() => {
setSelectedMerchantId(record.merchant_id)
setSelectedMerchantName(record.merchant?.name || record.merchant?.code || `商户 #${record.merchant_id}`)
setDrawerOpen(true)
}}
>
管理商户成员
</Button>
),
},
], [navigate])
], [])
const items = useMemo(() => [
{
@@ -236,6 +247,17 @@ export default function PlatformUsers() {
{editingAdmin && <Form.Item label="账号状态" name="status" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item>}
</Form>
</Modal>
<MerchantMembersDrawer
open={drawerOpen}
merchantId={selectedMerchantId}
merchantName={selectedMerchantName}
onClose={() => {
setDrawerOpen(false)
setSelectedMerchantId(null)
setSelectedMerchantName('')
}}
/>
</div>
)
}