feat(admin): 新增角色权限与客服群管理
This commit is contained in:
@@ -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
@@ -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 个后台页面全部可用,分销后台与主站视觉统一,分销业务流程不变。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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: '查看高风险操作与变更记录。' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user