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,110 @@
# Phase 2 验收报告(角色权限与客服群)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:后台角色权限矩阵、客服群组管理、菜单与路由接入
> 结论:**页面、接口、类型检查与构建全部通过;写操作未在验收中执行**
---
## 1. 角色权限
- 页面:`frontend/src/features/admin/AdminRolesPage.tsx`
- 路由:`/console-jy8k3m/roles`
- 权限:`role:manage`
- 接口:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/admin/roles` | 角色列表(含 `perm_count`) |
| GET | `/api/admin/roles/:id` | 角色详情与权限 ID |
| POST | `/api/admin/roles` | 创建角色 |
| PUT | `/api/admin/roles/:id` | 编辑角色 |
| DELETE | `/api/admin/roles/:id` | 删除角色(内置角色返回 `ErrProtectedRole`) |
| GET | `/api/admin/permissions` | 全量权限字典 |
| PUT | `/api/admin/roles/:id/permissions` | 覆盖式保存角色权限 |
- 能力:
- 角色列表、创建、编辑、删除;内置角色删除由后端拦截并在前端透出后端错误信息。
- 权限分配弹窗按 `resource` 分组,使用 `Checkbox.Group`;
- 勾选变更只替换当前资源组的权限,其他资源已选权限原样保留后整体提交。
- 弹窗顶部展示已选数量,并提示「保存后立即影响该角色下所有管理员」。
- 样式:`.admin-role-permission-loading`、`.admin-permission-groups`、`.admin-permission-group` 落在 `frontend/src/styles/admin-misc.css`,权限项在宽屏双列以上自适应、768px 以下单列。
## 2. 客服群组
- 页面:`frontend/src/features/admin/AdminSupportGroupsPage.tsx`
- 路由:`/console-jy8k3m/chat-support-groups`
- 权限:`chat:manage`
- 接口:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/admin/chat-support-groups` | 客服群列表 |
| POST | `/api/admin/chat-support-groups` | 创建客服群 |
| PUT | `/api/admin/chat-support-groups/:id` | 编辑客服群 |
| DELETE | `/api/admin/chat-support-groups/:id` | 删除客服群 |
| GET | `/api/admin/chat-support-groups/support-admins` | 可选坐席(管理员)列表 |
- 能力:群编码/名称/描述/状态/排序维护,成员多选绑定坐席;创建、编辑、删除后失效 `admin-support-groups` 查询缓存。
- 成员来源为 `support-admins`,接口当前返回空数组时页面按空成员处理,不报错。
## 3. 菜单与路由接入
- `frontend/src/layouts/AdminLayout.tsx`
- 「客服」菜单组:`/chat-support-groups` → 客服群组(`chat:manage`)。
- 「系统」菜单组新增:`/roles` → 角色权限(`role:manage`)。
- `sectionMeta` 补 `/roles` 与 `/chat-support-groups` 的标题与描述。
- `frontend/src/app/AppRouter.tsx`:`AdminRolesPage`、`AdminSupportGroupsPage` 均为 lazy import。
## 4. 只读回归
开发环境:
```text
前端 http://localhost:5289
后端 http://127.0.0.1:18089
Cookie /tmp/jysh-admin-cookie2.txt(HttpOnly,withCredentials)
```
页面路由:
| 路径 | 状态 |
|---|---|
| `/console-jy8k3m/roles` | 200 |
| `/console-jy8k3m/chat-support-groups` | 200 |
接口(携带已有管理端 cookie,未触发 refresh):
| 路径 | 状态 |
|---|---|
| `GET /api/admin/roles` | 200(含 `super_admin`,`perm_count=78`) |
| `GET /api/admin/permissions` | 200 |
| `GET /api/admin/chat-support-groups` | 200(含 `owner_onboarding` 收号组-客服) |
| `GET /api/admin/chat-support-groups/support-admins` | 200 |
写操作(创建/编辑/删除角色与客服群、保存权限)本轮未执行,避免影响开发库既有权限数据。
## 5. 类型检查与构建
```bash
cd frontend && npx tsc --noEmit # 通过
cd frontend && npm run build # 通过
```
构建结果(无超大包告警):
```text
dist/assets/index-C5HmO9XZ.js 493.43 kB │ gzip: 155.88 kB
dist/assets/vendor-react-BKj2bk8R.js 282.79 kB │ gzip: 90.00 kB
dist/assets/vendor-mobile-DQjalTEh.js 307.07 kB │ gzip: 103.63 kB
dist/assets/vendor-antd-icons-SRcm2AV6.js 102.44 kB │ gzip: 29.73 kB
dist/assets/Table-CUTV1oFv.js 144.26 kB │ gzip: 47.07 kB
```
修复项:`AdminRolesPage.tsx` 移除未使用的 `AdminRolePayload` 类型导入(`TS6196`)。
## 6. 仍未覆盖
- 角色权限与客服群的写操作、内置角色删除拦截的真实响应未做端到端验证。
- 客服坐席为空时,真实会话分流链路未验证。
+4 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、支付配置、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;分销账号同步入口已接入,订单结算比例已 bps 化;剩余真实外部支付 E2E、真实站点数据业务回归与上线准备。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、支付配置、角色权限、客服群组、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;分销账号同步入口已接入,订单结算比例已 bps 化;剩余真实外部支付 E2E、真实站点数据业务回归与上线准备。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
@@ -410,9 +410,12 @@ hfb_jysh/
- 财务看板、资金明细、运营支出。
- 申诉仲裁、手工订单、提号、客服群、快捷回复。
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`)。
- 待落地:手工录单 `/api/admin/manual-entries`、快捷回复 `/api/admin/chats/quick-replies`、推广追踪链接 `/api/admin/tracking-links`。
- 分销站点、规则、订单、结算、二维码池、分销用户、公告。
- 公告、邀请返利、推送、追踪链接。
- 系统配置、支付配置、备份、管理员管理。
- 待落地:管理员账号管理 `/api/admin/admin-users`。
- 现有 React 分销后台仅做品牌主题、Logo、登录页、Shell 和文案轻改,不改功能。
**验收:** 43 个后台页面全部可用,分销后台与主站视觉统一,分销业务流程不变。
+12
View File
@@ -180,6 +180,16 @@ const AdminPaymentConfigsPage = lazy(() =>
default: module.AdminPaymentConfigsPage,
}))
)
const AdminRolesPage = lazy(() =>
import('@/features/admin/AdminRolesPage').then(module => ({
default: module.AdminRolesPage,
}))
)
const AdminSupportGroupsPage = lazy(() =>
import('@/features/admin/AdminSupportGroupsPage').then(module => ({
default: module.AdminSupportGroupsPage,
}))
)
const AdminDistributionSitesPage = lazy(() =>
import('@/features/admin/AdminDistributionSitesPage').then(module => ({
default: module.AdminDistributionSitesPage,
@@ -309,6 +319,8 @@ export function AppRouter() {
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
<Route path="payment-configs" element={<AdminPaymentConfigsPage />} />
<Route path="roles" element={<AdminRolesPage />} />
<Route path="chat-support-groups" element={<AdminSupportGroupsPage />} />
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
<Route
path="distribution/dashboard"
@@ -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[]
}
+13
View File
@@ -14,6 +14,7 @@ import {
import {
AccountBookOutlined,
BellOutlined,
CustomerServiceOutlined,
DashboardOutlined,
FileDoneOutlined,
LogoutOutlined,
@@ -131,12 +132,19 @@ const allNavGroups: NavGroup[] = [
icon: <BellOutlined />,
children: [{ key: '/announcements', label: '公告管理', permission: 'announcement:view' }],
},
{
key: 'support',
label: '客服',
icon: <CustomerServiceOutlined />,
children: [{ key: '/chat-support-groups', label: '客服群组', permission: 'chat:manage' }],
},
{
key: 'system',
label: '系统',
icon: <SettingOutlined />,
children: [
{ key: '/system-configs', label: '系统配置', permission: 'system_config:view' },
{ key: '/roles', label: '角色权限', permission: 'role:manage' },
{ key: '/payment-configs', label: '支付配置', permission: 'payment_config:list' },
{ key: '/audit-logs', label: '审计日志', permission: 'audit_log:view' },
],
@@ -168,6 +176,11 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
'/distribution/sms': { title: '站点短信', description: '为分站配置独立短信签名、模板与密钥。' },
'/distribution/invitation': { title: '邀请返利', description: '配置分站邀请好友的返利比例。' },
'/announcements': { title: '公告管理', description: '维护主站与分站公告。' },
'/roles': { title: '角色权限', description: '维护后台角色并分配资源级操作权限。' },
'/chat-support-groups': {
title: '客服群组',
description: '维护客服群与坐席分配,支撑会话分流。',
},
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
'/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' },
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },
+49
View File
@@ -162,3 +162,52 @@
height: 40px;
}
/* Phase 2 role permission matrix */
.admin-role-permission-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
}
.admin-permission-groups {
display: grid;
gap: 16px;
margin-top: 16px;
max-height: 52vh;
overflow-y: auto;
padding-right: 4px;
}
.admin-permission-group {
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-warm);
}
.admin-permission-group h5 {
margin: 0 0 12px !important;
font-size: 14px;
letter-spacing: 0.02em;
}
.admin-permission-group .ant-checkbox-group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px 16px;
}
.admin-permission-group .ant-checkbox-wrapper {
margin-inline-start: 0;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 768px) {
.admin-permission-group .ant-checkbox-group {
grid-template-columns: 1fr;
}
}