feat(admin): 新增角色权限与客服群管理

This commit is contained in:
yml2213
2026-10-30 16:08:54 +08:00
parent 81a19431fa
commit a54680f59a
9 changed files with 1114 additions and 1 deletions
@@ -0,0 +1,418 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Checkbox,
Form,
Input,
Modal,
Space,
Spin,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined, SafetyOutlined } from '@ant-design/icons'
import {
assignAdminRolePermissions,
createAdminRole,
deleteAdminRole,
fetchAdminPermissions,
fetchAdminRole,
fetchAdminRoles,
updateAdminRole,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import type { AdminPermission, AdminRoleDetail } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatText } from '@/shared/utils/format'
interface RoleFormValues {
code: string
name: string
description: string
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
const resourceLabels: Record<string, string> = {
dashboard: '仪表盘',
order: '订单',
listing: '商品',
user: '用户',
wallet: '钱包',
withdrawal: '提现',
dispute: '申诉',
finance: '财务',
announcement: '公告',
system_config: '系统配置',
audit_log: '审计日志',
distribution: '分销',
qrcode: '二维码',
chat: '聊天',
role: '角色',
admin_user: '管理员',
payment_config: '支付配置',
cs_manual_entry: '手工录单',
}
export function AdminRolesPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<RoleFormValues>()
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AdminRoleDetail>()
const [submitting, setSubmitting] = useState(false)
const [permissionRole, setPermissionRole] = useState<AdminRoleDetail>()
const [selectedPermissionIds, setSelectedPermissionIds] = useState<number[]>([])
const [savingPermissions, setSavingPermissions] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('role:manage'))
const rolesQuery = useQuery({
queryKey: ['admin-roles'],
queryFn: fetchAdminRoles,
enabled: canManage,
})
const permissionListQuery = useQuery({
queryKey: ['admin-permissions'],
queryFn: fetchAdminPermissions,
enabled: canManage,
staleTime: 5 * 60_000,
})
const roleDetailQuery = useQuery({
queryKey: ['admin-role', permissionRole?.id],
queryFn: () => fetchAdminRole(permissionRole?.id as number),
enabled: canManage && Boolean(permissionRole?.id),
})
const roles = rolesQuery.data ?? []
const permissionGroups = useMemo(() => {
const grouped = new Map<string, AdminPermission[]>()
for (const permission of permissionListQuery.data ?? []) {
const list = grouped.get(permission.resource) ?? []
list.push(permission)
grouped.set(permission.resource, list)
}
return Array.from(grouped.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [permissionListQuery.data])
useEffect(() => {
if (!roleDetailQuery.data) return
setSelectedPermissionIds((roleDetailQuery.data.permissions ?? []).map(item => item.id))
}, [roleDetailQuery.data])
const openCreate = () => {
setEditing(undefined)
form.setFieldsValue({ code: '', name: '', description: '' })
setEditorOpen(true)
}
const openEdit = (role: AdminRoleDetail) => {
setEditing(role)
form.setFieldsValue({
code: role.code,
name: role.name,
description: role.description,
})
setEditorOpen(true)
}
const save = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
if (editing) {
await updateAdminRole(editing.id, {
name: values.name.trim(),
description: values.description?.trim(),
})
message.success('角色已更新')
} else {
await createAdminRole({
code: values.code.trim(),
name: values.name.trim(),
description: values.description?.trim(),
})
message.success('角色已创建')
}
setEditorOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const openPermissions = (role: AdminRoleDetail) => {
setPermissionRole(role)
setSelectedPermissionIds([])
}
const savePermissions = async () => {
if (!permissionRole) return
setSavingPermissions(true)
try {
await assignAdminRolePermissions(permissionRole.id, selectedPermissionIds)
message.success('角色权限已更新')
setPermissionRole(undefined)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-roles'] }),
queryClient.invalidateQueries({ queryKey: ['admin-role', permissionRole.id] }),
])
} catch (error) {
message.error(errorText(error))
} finally {
setSavingPermissions(false)
}
}
const remove = (role: AdminRoleDetail) => {
modal.confirm({
title: `确认删除角色「${role.name}」?`,
content: '系统内置角色不可删除;删除前请确认没有管理员仍在使用该角色。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminRole(role.id)
message.success('角色已删除')
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
} catch (error) {
message.error(errorText(error))
}
},
})
}
const columns: TableColumnsType<AdminRoleDetail> = [
{
title: '角色',
key: 'role',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.name}</strong>
<span className="admin-mono">{row.code}</span>
</div>
),
},
{
title: '说明',
dataIndex: 'description',
render: value => formatText(value),
},
{
title: '权限数',
dataIndex: 'perm_count',
width: 110,
align: 'right',
render: value => <Tag color="geekblue">{value}</Tag>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 230,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
<Button
type="link"
size="small"
icon={<SafetyOutlined />}
disabled={!canManage}
onClick={() => openPermissions(row)}
>
权限
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canManage}
onClick={() => openEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canManage}
onClick={() => remove(row)}
>
删除
</Button>
</Space>
),
},
]
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 权限"
title="角色权限"
description="维护后台角色与权限矩阵;系统内置角色不可删除。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {roles.length} 个角色</Tag>
<Tag>权限项 {permissionListQuery.data?.length ?? 0}</Tag>
</Space>
}
extra={
<Space>
{canManage ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建角色
</Button>
) : null}
<Button onClick={() => void rolesQuery.refetch()} loading={rolesQuery.isFetching}>
刷新
</Button>
</Space>
}
/>
{!canManage ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有角色管理权限"
description="需要 role:manage 权限。"
/>
) : null}
{rolesQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="角色列表没有加载出来"
description={errorText(rolesQuery.error)}
/>
) : null}
<DataTable<AdminRoleDetail>
title="后台角色"
rowKey="id"
columns={columns}
dataSource={roles}
loading={rolesQuery.isFetching}
emptyDescription={canManage ? '还没有角色' : '当前账号没有查看权限'}
/>
<Modal
open={editorOpen}
title={editing ? `编辑角色 · ${editing.name}` : '新建角色'}
width={560}
maskClosable={!submitting}
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
footer={
<Space>
<Button onClick={() => setEditorOpen(false)} disabled={submitting}>
取消
</Button>
<Button type="primary" loading={submitting} onClick={() => void save()}>
保存
</Button>
</Space>
}
>
<Form<RoleFormValues> form={form} layout="vertical" disabled={submitting}>
<Form.Item
name="code"
label="角色编码"
rules={[{ required: true, message: '请输入角色编码' }]}
extra="创建后不可修改"
>
<Input maxLength={64} disabled={Boolean(editing)} />
</Form.Item>
<Form.Item name="name" label="角色名称" rules={[{ required: true }]}>
<Input maxLength={128} />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
open={Boolean(permissionRole)}
title={permissionRole ? `分配权限 · ${permissionRole.name}` : '分配权限'}
width={820}
maskClosable={!savingPermissions}
onCancel={() => (savingPermissions ? undefined : setPermissionRole(undefined))}
footer={
<Space>
<Button onClick={() => setPermissionRole(undefined)} disabled={savingPermissions}>
取消
</Button>
<Button type="primary" loading={savingPermissions} onClick={() => void savePermissions()}>
保存权限
</Button>
</Space>
}
>
{roleDetailQuery.isPending ? (
<div className="admin-role-permission-loading">
<Spin />
</div>
) : (
<>
<Alert
type="info"
showIcon
className="admin-table-alert"
message={`已选 ${selectedPermissionIds.length} 项权限`}
description="权限按资源分组;保存后立即影响该角色下所有管理员。"
/>
<div className="admin-permission-groups">
{permissionGroups.map(([resource, items]) => (
<section key={resource} className="admin-permission-group">
<Typography.Title level={5}>
{resourceLabels[resource] ?? resource}
</Typography.Title>
<Checkbox.Group
value={selectedPermissionIds}
onChange={values =>
setSelectedPermissionIds(current => {
const other = current.filter(
id => !items.some(item => item.id === id)
)
return [...other, ...values.map(Number)]
})
}
options={items.map(item => ({
value: item.id,
label: `${item.name}(${item.code})`,
}))}
/>
</section>
))}
</div>
</>
)}
</Modal>
</div>
)
}
@@ -0,0 +1,370 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import {
CustomerServiceOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
createAdminSupportGroup,
deleteAdminSupportGroup,
fetchAdminSupportAdmins,
fetchAdminSupportGroups,
updateAdminSupportGroup,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import type {
AdminSupportGroup,
AdminSupportGroupPayload,
} from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatText } from '@/shared/utils/format'
interface GroupFormValues {
code: string
name: string
description: string
status: string
sort_order: number
member_ids: number[]
}
const statusOptions = [
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
export function AdminSupportGroupsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<GroupFormValues>()
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AdminSupportGroup>()
const [submitting, setSubmitting] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('chat:manage'))
const groupsQuery = useQuery({
queryKey: ['admin-support-groups'],
queryFn: fetchAdminSupportGroups,
enabled: canManage,
})
const adminsQuery = useQuery({
queryKey: ['admin-support-admins'],
queryFn: fetchAdminSupportAdmins,
enabled: canManage,
})
const groups = groupsQuery.data ?? []
const admins = adminsQuery.data ?? []
const openCreate = () => {
setEditing(undefined)
form.setFieldsValue({
code: '',
name: '',
description: '',
status: 'active',
sort_order: 0,
member_ids: [],
})
setEditorOpen(true)
}
const openEdit = (group: AdminSupportGroup) => {
setEditing(group)
form.setFieldsValue({
code: group.code,
name: group.name,
description: group.description,
status: group.status,
sort_order: group.sort_order,
member_ids: group.members.map(member => member.id),
})
setEditorOpen(true)
}
const save = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
const payload: AdminSupportGroupPayload = {
name: values.name.trim(),
description: values.description?.trim(),
status: values.status,
sort_order: values.sort_order ?? 0,
member_ids: values.member_ids ?? [],
}
if (editing) {
await updateAdminSupportGroup(editing.id, payload)
message.success('客服群已更新')
} else {
await createAdminSupportGroup({ ...payload, code: values.code.trim() })
message.success('客服群已创建')
}
setEditorOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin-support-groups'] })
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const remove = (group: AdminSupportGroup) => {
modal.confirm({
title: `确认删除客服群「${group.name}」?`,
content: '删除后该分组不再出现在客服会话分配中。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminSupportGroup(group.id)
message.success('客服群已删除')
await queryClient.invalidateQueries({ queryKey: ['admin-support-groups'] })
} catch (error) {
message.error(errorText(error))
}
},
})
}
const columns: TableColumnsType<AdminSupportGroup> = [
{
title: '客服群',
key: 'group',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<Space size={6} wrap>
<strong>{row.name}</strong>
<Tag color={row.status === 'active' ? 'success' : 'default'}>
{row.status === 'active' ? '启用' : '停用'}
</Tag>
</Space>
<span className="admin-mono">{row.code}</span>
<span>{formatText(row.description)}</span>
</div>
),
},
{
title: '成员',
key: 'members',
render: (_, row) =>
row.members.length === 0 ? (
<Typography.Text type="secondary">未分配</Typography.Text>
) : (
<Space size={4} wrap>
{row.members.map(member => (
<Tag key={member.id} color={member.support_status === 'online' ? 'green' : 'default'}>
{member.nickname || member.username}
</Tag>
))}
</Space>
),
},
{
title: '排序',
dataIndex: 'sort_order',
width: 90,
align: 'right',
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canManage}
onClick={() => openEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canManage}
onClick={() => remove(row)}
>
删除
</Button>
</Space>
),
},
]
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 客服"
title="客服群组"
description="维护客服分组、在线成员与排序,用于会话分配与订单客服群。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {groups.length} 个分组</Tag>
<Tag color="green">在线客服 {admins.filter(item => item.support_status === 'online').length}</Tag>
</Space>
}
extra={
<Space>
{canManage ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建客服群
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
loading={groupsQuery.isFetching}
disabled={!canManage}
onClick={() => {
void groupsQuery.refetch()
void adminsQuery.refetch()
}}
>
刷新
</Button>
</Space>
}
/>
{!canManage ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有客服群管理权限"
description="需要 chat:manage 权限。"
/>
) : null}
{groupsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="客服群没有加载出来"
description={errorText(groupsQuery.error)}
/>
) : null}
<DataTable<AdminSupportGroup>
title="客服分组"
rowKey="id"
columns={columns}
dataSource={groups}
loading={groupsQuery.isFetching}
emptyDescription={canManage ? '还没有客服群' : '当前账号没有查看权限'}
/>
<Modal
open={editorOpen}
title={editing ? `编辑客服群 · ${editing.name}` : '新建客服群'}
width={640}
maskClosable={!submitting}
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
footer={
<Space>
<Button onClick={() => setEditorOpen(false)} disabled={submitting}>
取消
</Button>
<Button type="primary" loading={submitting} onClick={() => void save()}>
保存
</Button>
</Space>
}
>
<Form<GroupFormValues> form={form} layout="vertical" disabled={submitting}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="code"
label="分组编码"
rules={[{ required: true, message: '请输入分组编码' }]}
extra="创建后不可修改"
>
<Input maxLength={64} disabled={Boolean(editing)} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="name" label="分组名称" rules={[{ required: true }]}>
<Input maxLength={128} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="status" label="状态">
<Select options={statusOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sort_order" label="排序" extra="数值越小越靠前">
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
<Form.Item name="member_ids" label="客服成员">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择客服管理员"
options={admins.map(member => ({
value: member.id,
label: `${member.nickname || member.username} · ${member.username} · ${member.support_status}`,
}))}
/>
</Form.Item>
<Typography.Paragraph type="secondary" className="admin-form-hint">
<CustomerServiceOutlined /> 成员可同时属于多个分组;在线状态由管理员账号维护。
</Typography.Paragraph>
</Form>
</Modal>
</div>
)
}
+85
View File
@@ -51,8 +51,14 @@ import type {
AdminPaymentConfigPage,
AdminPaymentConfigPayload,
AdminPaymentConfigQuery,
AdminPermission,
AdminReceivedListingPage,
AdminReceivedListingQuery,
AdminRoleDetail,
AdminRolePayload,
AdminSupportGroup,
AdminSupportGroupPayload,
AdminSupportMember,
AdminSyncSiteAccountsPayload,
AdminSyncSiteAccountsResult,
AdminSystemConfig,
@@ -997,6 +1003,85 @@ export async function deleteAdminPaymentConfig(id: number) {
await adminClient.delete(`/admin/payment-configs/${id}`)
}
export async function fetchAdminRoles() {
const { data } = await adminClient.get<ApiResponse<AdminRoleDetail[]>>('/admin/roles')
return data.data ?? []
}
export async function fetchAdminRole(id: number) {
const { data } = await adminClient.get<ApiResponse<AdminRoleDetail>>(`/admin/roles/${id}`)
return data.data
}
export async function createAdminRole(payload: AdminRolePayload) {
const { data } = await adminClient.post<ApiResponse<AdminRoleDetail>>('/admin/roles', payload)
return data.data
}
export async function updateAdminRole(id: number, payload: AdminRolePayload) {
const { data } = await adminClient.put<ApiResponse<AdminRoleDetail>>(
`/admin/roles/${id}`,
payload
)
return data.data
}
export async function deleteAdminRole(id: number) {
await adminClient.delete(`/admin/roles/${id}`)
}
export async function fetchAdminPermissions() {
const { data } = await adminClient.get<ApiResponse<AdminPermission[]>>('/admin/permissions')
return data.data ?? []
}
export async function assignAdminRolePermissions(id: number, permissionIds: number[]) {
await adminClient.put(`/admin/roles/${id}/permissions`, { permission_ids: permissionIds })
}
export async function fetchAdminSupportGroups() {
const { data } = await adminClient.get<ApiResponse<AdminSupportGroup[]>>(
'/admin/chat-support-groups'
)
return data.data ?? []
}
export async function fetchAdminSupportAdmins() {
const { data } = await adminClient.get<ApiResponse<AdminSupportMember[]>>(
'/admin/chat-support-groups/support-admins'
)
return data.data ?? []
}
export async function createAdminSupportGroup(payload: AdminSupportGroupPayload) {
const { data } = await adminClient.post<ApiResponse<AdminSupportGroup>>(
'/admin/chat-support-groups',
payload
)
return data.data
}
export async function updateAdminSupportGroup(
id: number,
payload: AdminSupportGroupPayload
) {
const { data } = await adminClient.put<ApiResponse<AdminSupportGroup>>(
`/admin/chat-support-groups/${id}`,
payload
)
return data.data
}
export async function deleteAdminSupportGroup(id: number) {
await adminClient.delete(`/admin/chat-support-groups/${id}`)
}
export async function assignAdminSupportGroupMembers(id: number, memberIds: number[]) {
await adminClient.put(`/admin/chat-support-groups/${id}/members`, {
member_ids: memberIds,
})
}
export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<FinanceDisbursementPage>>>(
'/admin/finance/disbursements',
+53
View File
@@ -1355,3 +1355,56 @@ export interface AdminPaymentConfigPayload {
environment?: string
business_tags?: string[]
}
export interface AdminPermission {
id: number
code: string
name: string
resource: string
action: string
}
export interface AdminRoleDetail {
id: number
code: string
name: string
description: string
perm_count: number
permissions?: AdminPermission[]
created_at: string
updated_at: string
}
export interface AdminRolePayload {
code?: string
name: string
description?: string
}
export interface AdminSupportMember {
id: number
username: string
nickname: string
support_status: string
}
export interface AdminSupportGroup {
id: number
code: string
name: string
description: string
status: string
sort_order: number
members: AdminSupportMember[]
created_at: string
updated_at: string
}
export interface AdminSupportGroupPayload {
code?: string
name: string
description?: string
status?: string
sort_order?: number
member_ids?: number[]
}