feat(admin): 补齐管理员账号与客服运营长尾页
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
# Phase 2 验收报告(后台长尾四页)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:管理员账号、手工录单、快捷回复、推广追踪
|
||||
> 结论:**四个页面已完成并接入菜单;只读接口与路由回归全部通过;写操作未在验收中执行**
|
||||
|
||||
---
|
||||
|
||||
## 1. 管理员账号
|
||||
|
||||
- 页面:`frontend/src/features/admin/AdminStaffAccountsPage.tsx`
|
||||
- 路由:`/console-jy8k3m/admin-users`
|
||||
- 权限:`admin_user:manage`
|
||||
- 接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/admin-users` | 分页列表(`page` / `page_size`) |
|
||||
| GET | `/api/admin/admin-users/:id` | 单个账号 |
|
||||
| POST | `/api/admin/admin-users` | 新建(用户名 + 初始密码) |
|
||||
| PUT | `/api/admin/admin-users/:id` | 修改昵称与状态 |
|
||||
| DELETE | `/api/admin/admin-users/:id` | 删除 |
|
||||
| PUT | `/api/admin/admin-users/:id/roles` | 覆盖式分配角色 |
|
||||
| PUT | `/api/admin/admin-users/:id/password` | 重置密码 |
|
||||
|
||||
- 能力:
|
||||
- 列表展示账号、状态、角色标签、角色说明、最后登录与创建时间,服务端分页。
|
||||
- 新建时校验密码强度:至少 8 位且同时包含字母与数字(与后端 `passwordStrongEnough` 一致)。
|
||||
- 角色分配复用 `GET /api/admin/roles` 的缓存数据,多选后整体覆盖提交。
|
||||
- 前端拦截「删除当前登录账号」,并禁用对应按钮;后端仍会二次校验自身删除与最后一个超级管理员。
|
||||
- 重置密码后提示该账号需重新登录(后端会提升 `token_version` 使旧会话失效)。
|
||||
|
||||
## 2. 手工录单
|
||||
|
||||
- 页面:`frontend/src/features/admin/AdminManualEntriesPage.tsx`
|
||||
- 路由:`/console-jy8k3m/manual-entries`
|
||||
- 权限:`cs_manual_entry:view/create/update/delete/option_manage`
|
||||
- 接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/manual-entries` | 列表(`entry_no`/`keyword`/`admin_id`/`start_date`/`end_date`/分页) |
|
||||
| POST | `/api/admin/manual-entries` | 新建录单,客服身份取登录上下文 |
|
||||
| PUT | `/api/admin/manual-entries/:id` | 修正录单 |
|
||||
| DELETE | `/api/admin/manual-entries/:id` | 删除录单 |
|
||||
| GET | `/api/admin/manual-entries/today-stats` | 今日按客服统计 |
|
||||
| GET/POST | `/api/admin/manual-entries/options` | 选项查询 / 新增 |
|
||||
| PUT/DELETE | `/api/admin/manual-entries/options/:id` | 选项重命名 / 删除 |
|
||||
|
||||
- 能力:
|
||||
- 顶部展示今日录单总数与参与客服,按客服聚合打标签,60 秒自动刷新。
|
||||
- 筛选支持编号、关键字与日期区间(北京时间闭区间,按 `YYYY-MM-DD` 传参)。
|
||||
- 平台与操作人从选项表中取值,同时允许手工填写选项外的值。
|
||||
- 选项管理弹窗支持新增、点击标签重命名、删除;不影响历史录单记录。
|
||||
|
||||
## 3. 快捷回复
|
||||
|
||||
- 页面:`frontend/src/features/admin/AdminQuickRepliesPage.tsx`
|
||||
- 路由:`/console-jy8k3m/chat-quick-replies`
|
||||
- 权限:查看 `chat:view`,增删改 `chat:send`
|
||||
- 接口:`GET/POST /api/admin/chats/quick-replies`、`PUT/DELETE /api/admin/chats/quick-replies/:id`
|
||||
- 能力:标题、内容(1000 字上限)、排序(0–9999)、全局/个人可见开关;列表按排序值升序展示,内容支持展开与省略。
|
||||
|
||||
## 4. 推广追踪
|
||||
|
||||
- 页面:`frontend/src/features/admin/AdminTrackingLinksPage.tsx`
|
||||
- 路由:`/console-jy8k3m/tracking-links`
|
||||
- 权限:`ad_tracking:manage`
|
||||
- 接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/tracking-links` | 列表(关键字 / 渠道 / 状态 / 分页) |
|
||||
| GET | `/api/admin/tracking-links/overview` | 大盘统计 |
|
||||
| POST | `/api/admin/tracking-links` | 创建 |
|
||||
| PUT | `/api/admin/tracking-links/:id` | 编辑 |
|
||||
| PUT | `/api/admin/tracking-links/:id/status` | 启用 / 停用 |
|
||||
|
||||
- 能力:
|
||||
- 顶部 6 个统计:累计 PV/UV、注册、成交订单、成交金额、今日 UV。
|
||||
- 成本按元录入、按分提交:`cost_cent = Math.round(cost_yuan * 100)`,展示统一走 `formatMoney(cents)`。
|
||||
- 列表展示点击/注册/下单/转化率/成交金额/成本,支持复制短链与启停切换。
|
||||
- 编码创建后不可修改(与后端 `UpdateTrackingLinkRequest` 不含 `code` 保持一致)。
|
||||
|
||||
## 5. 菜单与路由
|
||||
|
||||
- `frontend/src/layouts/AdminLayout.tsx`
|
||||
- 运营组新增:`/tracking-links` → 推广追踪(`ad_tracking:manage`)。
|
||||
- 客服组新增:`/manual-entries` 手工录单(`cs_manual_entry:view`)、`/chat-quick-replies` 快捷回复(`chat:view`)。
|
||||
- 系统组新增:`/admin-users` 管理员账号(`admin_user:manage`)。
|
||||
- `sectionMeta` 同步补齐四页标题与描述。
|
||||
- `frontend/src/app/AppRouter.tsx`:四个页面均以 `lazy()` 懒加载。
|
||||
|
||||
## 6. 只读回归
|
||||
|
||||
```text
|
||||
前端 http://localhost:5289
|
||||
后端 http://127.0.0.1:18089
|
||||
Cookie /tmp/jysh-admin-cookie2.txt(HttpOnly + withCredentials)
|
||||
```
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|---|---|
|
||||
| `GET /console-jy8k3m/admin-users` | 200 |
|
||||
| `GET /console-jy8k3m/manual-entries` | 200 |
|
||||
| `GET /console-jy8k3m/chat-quick-replies` | 200 |
|
||||
| `GET /console-jy8k3m/tracking-links` | 200 |
|
||||
| `GET /api/admin/admin-users?page=1&page_size=5` | 200(含测试站长账号与 `ops` 角色) |
|
||||
| `GET /api/admin/manual-entries` | 200(空列表) |
|
||||
| `GET /api/admin/manual-entries/options` | 200(平台/操作人均为空) |
|
||||
| `GET /api/admin/manual-entries/today-stats` | 200(空数组) |
|
||||
| `GET /api/admin/chats/quick-replies` | 200(空数组) |
|
||||
| `GET /api/admin/tracking-links` | 200(空列表) |
|
||||
| `GET /api/admin/tracking-links/overview` | 200 |
|
||||
|
||||
本轮未执行任何创建、修改、删除或密码重置写操作。
|
||||
|
||||
## 7. 类型检查与构建
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit # 通过
|
||||
cd frontend && npm run build # 通过
|
||||
```
|
||||
|
||||
构建结果(无超大包告警):
|
||||
|
||||
```text
|
||||
dist/assets/index-D8usHbzZ.js 494.74 kB │ gzip: 156.18 kB
|
||||
dist/assets/vendor-mobile-eCXS5uji.js 307.07 kB │ gzip: 103.63 kB
|
||||
dist/assets/vendor-react-BKj2bk8R.js 282.79 kB │ gzip: 90.00 kB
|
||||
dist/assets/Table-BCzDIoX2.js 144.26 kB │ gzip: 47.07 kB
|
||||
dist/assets/vendor-antd-icons-CZjfKEFT.js 103.46 kB │ gzip: 30.05 kB
|
||||
```
|
||||
|
||||
修复项:移除 `AdminManualEntriesPage.tsx` 未使用的 `dayjs` 默认导入、`AdminTrackingLinksPage.tsx` 未使用的 `LinkOutlined`(均为 `TS6133`)。
|
||||
|
||||
## 8. 仍未覆盖
|
||||
|
||||
- 四个页面的写操作(新建、编辑、删除、重置密码、启停)均未做端到端验证。
|
||||
- 手工录单的日期区间筛选依赖后端按北京时间解析,尚未用真实数据验证边界。
|
||||
- 推广短链跳转与埋点统计链路未验证(需要真实访问流量)。
|
||||
+5
-3
@@ -410,12 +410,14 @@ hfb_jysh/
|
||||
|
||||
- 财务看板、资金明细、运营支出。
|
||||
- 申诉仲裁、手工订单、提号、客服群、快捷回复。
|
||||
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`)。
|
||||
- 待落地:手工录单 `/api/admin/manual-entries`、快捷回复 `/api/admin/chats/quick-replies`、推广追踪链接 `/api/admin/tracking-links`。
|
||||
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`)。
|
||||
- 待落地:无(手工录单与快捷回复已迁移完毕)。
|
||||
- 分销站点、规则、订单、结算、二维码池、分销用户、公告。
|
||||
- 公告、邀请返利、推送、追踪链接。
|
||||
- 已落地:公告管理、邀请返利、推广追踪(`/tracking-links`,权限 `ad_tracking:manage`)。
|
||||
- 系统配置、支付配置、备份、管理员管理。
|
||||
- 待落地:管理员账号管理 `/api/admin/admin-users`。
|
||||
- 已落地:系统配置、支付配置、管理员账号(`/admin-users`,权限 `admin_user:manage`)。
|
||||
- 待落地:数据备份管理页。
|
||||
- 现有 React 分销后台仅做品牌主题、Logo、登录页、Shell 和文案轻改,不改功能。
|
||||
|
||||
**验收:** 43 个后台页面全部可用,分销后台与主站视觉统一,分销业务流程不变。
|
||||
|
||||
@@ -190,6 +190,26 @@ const AdminSupportGroupsPage = lazy(() =>
|
||||
default: module.AdminSupportGroupsPage,
|
||||
}))
|
||||
)
|
||||
const AdminStaffAccountsPage = lazy(() =>
|
||||
import('@/features/admin/AdminStaffAccountsPage').then(module => ({
|
||||
default: module.AdminStaffAccountsPage,
|
||||
}))
|
||||
)
|
||||
const AdminManualEntriesPage = lazy(() =>
|
||||
import('@/features/admin/AdminManualEntriesPage').then(module => ({
|
||||
default: module.AdminManualEntriesPage,
|
||||
}))
|
||||
)
|
||||
const AdminQuickRepliesPage = lazy(() =>
|
||||
import('@/features/admin/AdminQuickRepliesPage').then(module => ({
|
||||
default: module.AdminQuickRepliesPage,
|
||||
}))
|
||||
)
|
||||
const AdminTrackingLinksPage = lazy(() =>
|
||||
import('@/features/admin/AdminTrackingLinksPage').then(module => ({
|
||||
default: module.AdminTrackingLinksPage,
|
||||
}))
|
||||
)
|
||||
const AdminDistributionSitesPage = lazy(() =>
|
||||
import('@/features/admin/AdminDistributionSitesPage').then(module => ({
|
||||
default: module.AdminDistributionSitesPage,
|
||||
@@ -320,6 +340,10 @@ export function AppRouter() {
|
||||
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
|
||||
<Route path="payment-configs" element={<AdminPaymentConfigsPage />} />
|
||||
<Route path="roles" element={<AdminRolesPage />} />
|
||||
<Route path="admin-users" element={<AdminStaffAccountsPage />} />
|
||||
<Route path="manual-entries" element={<AdminManualEntriesPage />} />
|
||||
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
|
||||
<Route path="tracking-links" element={<AdminTrackingLinksPage />} />
|
||||
<Route path="chat-support-groups" element={<AdminSupportGroupsPage />} />
|
||||
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { type Dayjs } from 'dayjs'
|
||||
|
||||
import {
|
||||
createAdminManualEntry,
|
||||
createAdminManualEntryOption,
|
||||
deleteAdminManualEntry,
|
||||
deleteAdminManualEntryOption,
|
||||
fetchAdminManualEntries,
|
||||
fetchAdminManualEntryOptions,
|
||||
fetchAdminManualEntryTodayStats,
|
||||
updateAdminManualEntry,
|
||||
updateAdminManualEntryOption,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import type {
|
||||
AdminManualEntry,
|
||||
AdminManualEntryOption,
|
||||
AdminManualEntryPayload,
|
||||
} from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface EntryFormValues {
|
||||
entry_no: string
|
||||
platform?: string
|
||||
operator?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
entry_no: string
|
||||
keyword: string
|
||||
range: [Dayjs, Dayjs] | null
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = { entry_no: '', keyword: '', range: null }
|
||||
|
||||
const optionTypeLabels: Record<string, string> = {
|
||||
platform: '平台',
|
||||
operator: '操作人',
|
||||
}
|
||||
|
||||
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 AdminManualEntriesPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<EntryFormValues>()
|
||||
const [optionForm] = Form.useForm<{ type: string; name: string }>()
|
||||
const [filters, setFilters] = useState<Filters>(emptyFilters)
|
||||
const [applied, setApplied] = useState<Filters>(emptyFilters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminManualEntry>()
|
||||
const [optionsOpen, setOptionsOpen] = useState(false)
|
||||
const [renaming, setRenaming] = useState<AdminManualEntryOption>()
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const can = (code: string) =>
|
||||
Boolean(permissions?.includes('*') || permissions?.includes(code))
|
||||
const canView = can('cs_manual_entry:view')
|
||||
const canCreate = can('cs_manual_entry:create')
|
||||
const canUpdate = can('cs_manual_entry:update')
|
||||
const canDelete = can('cs_manual_entry:delete')
|
||||
const canManageOptions = can('cs_manual_entry:option_manage')
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
entry_no: applied.entry_no.trim() || undefined,
|
||||
keyword: applied.keyword.trim() || undefined,
|
||||
start_date: applied.range?.[0]?.format('YYYY-MM-DD'),
|
||||
end_date: applied.range?.[1]?.format('YYYY-MM-DD'),
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}),
|
||||
[applied, page, pageSize]
|
||||
)
|
||||
|
||||
const entriesQuery = useQuery({
|
||||
queryKey: ['admin-manual-entries', query],
|
||||
queryFn: () => fetchAdminManualEntries(query),
|
||||
enabled: canView,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: ['admin-manual-entry-options'],
|
||||
queryFn: fetchAdminManualEntryOptions,
|
||||
enabled: canView,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['admin-manual-entry-stats'],
|
||||
queryFn: fetchAdminManualEntryTodayStats,
|
||||
enabled: canView,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const entries = entriesQuery.data?.items ?? []
|
||||
const options = optionsQuery.data ?? { platforms: [], operators: [] }
|
||||
const stats = statsQuery.data ?? []
|
||||
const todayTotal = stats.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
const invalidateEntries = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-manual-entries'] })
|
||||
const invalidateOptions = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-manual-entry-options'] })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(undefined)
|
||||
form.setFieldsValue({ entry_no: '', platform: undefined, operator: undefined, remark: '' })
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (entry: AdminManualEntry) => {
|
||||
setEditing(entry)
|
||||
form.setFieldsValue({
|
||||
entry_no: entry.entry_no,
|
||||
platform: entry.platform || undefined,
|
||||
operator: entry.operator || undefined,
|
||||
remark: entry.remark,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields()
|
||||
const payload: AdminManualEntryPayload = {
|
||||
entry_no: values.entry_no.trim(),
|
||||
platform: values.platform?.trim(),
|
||||
operator: values.operator?.trim(),
|
||||
remark: values.remark?.trim(),
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateAdminManualEntry(editing.id, payload)
|
||||
message.success('录单已更新')
|
||||
} else {
|
||||
await createAdminManualEntry(payload)
|
||||
message.success('录单已提交')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await Promise.all([invalidateEntries(), queryClient.invalidateQueries({ queryKey: ['admin-manual-entry-stats'] })])
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (entry: AdminManualEntry) => {
|
||||
modal.confirm({
|
||||
title: `确认删除录单「${entry.entry_no}」?`,
|
||||
content: '删除后该录单记录不可恢复,统计会同步变化。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteAdminManualEntry(entry.id)
|
||||
message.success('录单已删除')
|
||||
await Promise.all([
|
||||
invalidateEntries(),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-manual-entry-stats'] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addOption = async () => {
|
||||
const values = await optionForm.validateFields()
|
||||
try {
|
||||
await createAdminManualEntryOption({ type: values.type, name: values.name.trim() })
|
||||
message.success('选项已新增')
|
||||
optionForm.setFieldsValue({ type: values.type, name: '' })
|
||||
await invalidateOptions()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
}
|
||||
|
||||
const submitRename = async () => {
|
||||
if (!renaming) return
|
||||
const name = renameValue.trim()
|
||||
if (!name) {
|
||||
message.warning('名称不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateAdminManualEntryOption(renaming.id, name)
|
||||
message.success('选项已重命名')
|
||||
setRenaming(undefined)
|
||||
await invalidateOptions()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
}
|
||||
|
||||
const removeOption = (option: AdminManualEntryOption) => {
|
||||
modal.confirm({
|
||||
title: `确认删除选项「${option.name}」?`,
|
||||
content: '已存在的录单记录不受影响,仅从下拉选项移除。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteAdminManualEntryOption(option.id)
|
||||
message.success('选项已删除')
|
||||
await invalidateOptions()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const renderOptionList = (type: 'platform' | 'operator') => {
|
||||
const items = type === 'platform' ? options.platforms : options.operators
|
||||
if (items.length === 0) {
|
||||
return <Typography.Text type="secondary">暂无{optionTypeLabels[type]}选项</Typography.Text>
|
||||
}
|
||||
return (
|
||||
<Space size={6} wrap>
|
||||
{items.map(item => (
|
||||
<Tag
|
||||
key={item.id}
|
||||
closable={canManageOptions}
|
||||
closeIcon={<DeleteOutlined />}
|
||||
onClose={event => {
|
||||
event.preventDefault()
|
||||
removeOption(item)
|
||||
}}
|
||||
color="geekblue"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ cursor: canManageOptions ? 'pointer' : 'default' }}
|
||||
onClick={() => {
|
||||
if (!canManageOptions) return
|
||||
setRenaming(item)
|
||||
setRenameValue(item.name)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (!canManageOptions || event.key !== 'Enter') return
|
||||
setRenaming(item)
|
||||
setRenameValue(item.name)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminManualEntry> = [
|
||||
{
|
||||
title: '录单编号',
|
||||
dataIndex: 'entry_no',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '平台 / 操作人',
|
||||
key: 'platform',
|
||||
width: 200,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.platform)}</span>
|
||||
<Typography.Text type="secondary">{formatText(row.operator)}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客服',
|
||||
key: 'admin',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{row.admin_name || row.admin_username || `ID ${row.admin_id}`}</strong>
|
||||
<span className="admin-mono">{row.admin_username}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
render: value => formatText(value),
|
||||
},
|
||||
{
|
||||
title: '录单时间',
|
||||
dataIndex: 'created_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={!canUpdate}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!canDelete}
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader eyebrow="JIEYOU OPS · 客服" title="手工录单" />
|
||||
<Alert type="warning" showIcon message="当前账号没有手工录单查看权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 客服"
|
||||
title="手工录单"
|
||||
description="客服在系统外成单后录入编号,用于对账与客服绩效统计。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">今日录单 {todayTotal} 条</Tag>
|
||||
<Tag>参与客服 {stats.length} 人</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
{canManageOptions ? (
|
||||
<Button icon={<SettingOutlined />} onClick={() => setOptionsOpen(true)}>
|
||||
选项管理
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={entriesQuery.isFetching}
|
||||
onClick={() => {
|
||||
void entriesQuery.refetch()
|
||||
void statsQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{canCreate ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建录单
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
<div className="admin-stack" style={{ marginBottom: 12 }}>
|
||||
<Space size={6} wrap>
|
||||
{stats.map(item => (
|
||||
<Tag key={item.admin_id} color="cyan">
|
||||
{item.admin_name || `ID ${item.admin_id}`} · {item.count}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FilterBar
|
||||
loading={entriesQuery.isFetching}
|
||||
onSearch={() => {
|
||||
setPage(1)
|
||||
setApplied(filters)
|
||||
}}
|
||||
onReset={() => {
|
||||
setFilters(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<FilterField label="录单编号">
|
||||
<Input
|
||||
allowClear
|
||||
value={filters.entry_no}
|
||||
placeholder="精确或前缀匹配"
|
||||
onChange={event => setFilters(current => ({ ...current, entry_no: event.target.value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="关键字">
|
||||
<Input
|
||||
allowClear
|
||||
value={filters.keyword}
|
||||
placeholder="平台 / 操作人 / 备注"
|
||||
onChange={event => setFilters(current => ({ ...current, keyword: event.target.value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="录单日期" width={280}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={filters.range}
|
||||
allowEmpty={[true, true]}
|
||||
onChange={value =>
|
||||
setFilters(current => ({
|
||||
...current,
|
||||
range: value && value[0] && value[1] ? [value[0], value[1]] : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{entriesQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="录单列表加载失败"
|
||||
description={errorText(entriesQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminManualEntry>
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="id"
|
||||
loading={entriesQuery.isFetching}
|
||||
total={entriesQuery.data?.total ?? 0}
|
||||
page={entriesQuery.data?.page ?? page}
|
||||
pageSize={entriesQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="还没有录入记录"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? `编辑录单 · ${editing.entry_no}` : '新建录单'}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void save()}
|
||||
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
|
||||
maskClosable={!submitting}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="entry_no"
|
||||
label="录单编号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入录单编号' },
|
||||
{ max: 64, message: '最多 64 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="系统外成单编号" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="platform" label="平台">
|
||||
<AutoCompleteOptions
|
||||
options={options.platforms.map(item => ({ value: item.name }))}
|
||||
placeholder="选择或填写平台"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="operator" label="操作人">
|
||||
<AutoCompleteOptions
|
||||
options={options.operators.map(item => ({ value: item.name }))}
|
||||
placeholder="选择或填写操作人"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} maxLength={255} showCount placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={optionsOpen}
|
||||
title="录单选项管理"
|
||||
onCancel={() => setOptionsOpen(false)}
|
||||
footer={
|
||||
<Button onClick={() => setOptionsOpen(false)}>关闭</Button>
|
||||
}
|
||||
width={640}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="选项用于录单表单下拉,不影响已录入的历史记录。"
|
||||
/>
|
||||
<Typography.Title level={5}>平台</Typography.Title>
|
||||
{renderOptionList('platform')}
|
||||
<Typography.Title level={5} style={{ marginTop: 20 }}>
|
||||
操作人
|
||||
</Typography.Title>
|
||||
{renderOptionList('operator')}
|
||||
<Form
|
||||
form={optionForm}
|
||||
layout="inline"
|
||||
style={{ marginTop: 20, gap: 8 }}
|
||||
initialValues={{ type: 'platform', name: '' }}
|
||||
>
|
||||
<Form.Item name="type" rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'platform', label: '平台' },
|
||||
{ value: 'operator', label: '操作人' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="新增选项名称" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void addOption()}>
|
||||
新增
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(renaming)}
|
||||
title="重命名选项"
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
onOk={() => void submitRename()}
|
||||
onCancel={() => setRenaming(undefined)}
|
||||
>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={event => setRenameValue(event.target.value)}
|
||||
placeholder="选项名称"
|
||||
maxLength={64}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoCompleteOptions({
|
||||
options,
|
||||
placeholder,
|
||||
}: {
|
||||
options: { value: string }[]
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
mode="tags"
|
||||
maxCount={1}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
style={{ width: '100%' }}
|
||||
// 允许手填不在选项内的平台/操作人,标签模式天然支持
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
createAdminQuickReply,
|
||||
deleteAdminQuickReply,
|
||||
fetchAdminQuickReplies,
|
||||
updateAdminQuickReply,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import type { AdminQuickReply, AdminQuickReplyPayload } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatText } from '@/shared/utils/format'
|
||||
|
||||
interface ReplyFormValues {
|
||||
title: string
|
||||
content: string
|
||||
sort_order: number
|
||||
is_global: boolean
|
||||
}
|
||||
|
||||
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 AdminQuickRepliesPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ReplyFormValues>()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminQuickReply>()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('chat:view'))
|
||||
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('chat:send'))
|
||||
|
||||
const repliesQuery = useQuery({
|
||||
queryKey: ['admin-quick-replies'],
|
||||
queryFn: fetchAdminQuickReplies,
|
||||
enabled: canView,
|
||||
})
|
||||
|
||||
const replies = [...(repliesQuery.data ?? [])].sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(undefined)
|
||||
form.setFieldsValue({ title: '', content: '', sort_order: 0, is_global: true })
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (reply: AdminQuickReply) => {
|
||||
setEditing(reply)
|
||||
form.setFieldsValue({
|
||||
title: reply.title,
|
||||
content: reply.content,
|
||||
sort_order: reply.sort_order,
|
||||
is_global: reply.is_global,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields()
|
||||
const payload: AdminQuickReplyPayload = {
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
sort_order: values.sort_order ?? 0,
|
||||
is_global: Boolean(values.is_global),
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateAdminQuickReply(editing.id, payload)
|
||||
message.success('快捷回复已更新')
|
||||
} else {
|
||||
await createAdminQuickReply(payload)
|
||||
message.success('快捷回复已创建')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-quick-replies'] })
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (reply: AdminQuickReply) => {
|
||||
modal.confirm({
|
||||
title: `确认删除快捷回复「${reply.title}」?`,
|
||||
content: '删除后客服在会话中不再看到该话术。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteAdminQuickReply(reply.id)
|
||||
message.success('快捷回复已删除')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-quick-replies'] })
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminQuickReply> = [
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
width: 220,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<Space size={6} wrap>
|
||||
<strong>{row.title}</strong>
|
||||
<Tag color={row.is_global ? 'geekblue' : 'default'}>
|
||||
{row.is_global ? '全局' : '个人'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<span className="admin-mono">#{row.id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '话术内容',
|
||||
dataIndex: 'content',
|
||||
render: value => (
|
||||
<Typography.Paragraph style={{ marginBottom: 0 }} ellipsis={{ rows: 3, expandable: true }}>
|
||||
{formatText(value)}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader eyebrow="JIEYOU OPS · 客服" title="快捷回复" />
|
||||
<Alert type="warning" showIcon message="当前账号没有聊天查看权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 客服"
|
||||
title="快捷回复"
|
||||
description="维护客服会话常用话术,按排序值从小到大展示。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {replies.length} 条话术</Tag>
|
||||
<Tag>全局 {replies.filter(item => item.is_global).length}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
{canManage ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建话术
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={repliesQuery.isFetching}
|
||||
onClick={() => void repliesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="全局话术对所有客服可见,个人话术仅创建者可见。"
|
||||
description="后台创建的记录默认归属当前登录管理员。"
|
||||
/>
|
||||
|
||||
{repliesQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="快捷回复加载失败"
|
||||
description={errorText(repliesQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminQuickReply>
|
||||
columns={columns}
|
||||
dataSource={replies}
|
||||
rowKey="id"
|
||||
loading={repliesQuery.isFetching}
|
||||
emptyDescription="还没有快捷回复"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? `编辑话术 · ${editing.title}` : '新建快捷回复'}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void save()}
|
||||
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
|
||||
maskClosable={!submitting}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[
|
||||
{ required: true, message: '请输入标题' },
|
||||
{ max: 64, message: '最多 64 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="用于客服选择时快速识别" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="话术内容"
|
||||
rules={[
|
||||
{ required: true, message: '请输入话术内容' },
|
||||
{ max: 1000, message: '最多 1000 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={6} showCount maxLength={1000} placeholder="发送给用户的完整内容" />
|
||||
</Form.Item>
|
||||
<Space size={24} align="start">
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber min={0} max={9999} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_global" label="全局可见" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
KeyOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
assignAdminStaffRoles,
|
||||
createAdminStaffAccount,
|
||||
deleteAdminStaffAccount,
|
||||
fetchAdminRoles,
|
||||
fetchAdminStaffAccounts,
|
||||
resetAdminStaffPassword,
|
||||
updateAdminStaffAccount,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import type { AdminStaffAccount } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface CreateFormValues {
|
||||
username: string
|
||||
password: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
interface EditFormValues {
|
||||
nickname?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface PasswordFormValues {
|
||||
new_password: string
|
||||
}
|
||||
|
||||
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 : '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
function passwordRule(value?: string) {
|
||||
if (!value) return Promise.reject(new Error('请输入密码'))
|
||||
if (value.length < 8) return Promise.reject(new Error('密码至少 8 位'))
|
||||
if (!/[A-Za-z]/.test(value) || !/\d/.test(value)) {
|
||||
return Promise.reject(new Error('密码需同时包含字母和数字'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export function AdminStaffAccountsPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [createForm] = Form.useForm<CreateFormValues>()
|
||||
const [editForm] = Form.useForm<EditFormValues>()
|
||||
const [passwordForm] = Form.useForm<PasswordFormValues>()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminStaffAccount>()
|
||||
const [roleTarget, setRoleTarget] = useState<AdminStaffAccount>()
|
||||
const [roleIds, setRoleIds] = useState<number[]>([])
|
||||
const [passwordTarget, setPasswordTarget] = useState<AdminStaffAccount>()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const currentAdminId = useAdminSessionStore(state => state.admin?.id)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('admin_user:manage'))
|
||||
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: ['admin-staff-accounts', page, pageSize],
|
||||
queryFn: () => fetchAdminStaffAccounts({ page, page_size: pageSize }),
|
||||
enabled: canManage,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ['admin-roles'],
|
||||
queryFn: fetchAdminRoles,
|
||||
enabled: canManage,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
const accounts = accountsQuery.data?.items ?? []
|
||||
const roleOptions = (rolesQuery.data ?? []).map(role => ({
|
||||
value: role.id,
|
||||
label: `${role.name}(${role.code})`,
|
||||
}))
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-staff-accounts'] })
|
||||
|
||||
const openCreate = () => {
|
||||
createForm.setFieldsValue({ username: '', password: '', nickname: '' })
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
const values = await createForm.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createAdminStaffAccount({
|
||||
username: values.username.trim(),
|
||||
password: values.password,
|
||||
nickname: values.nickname?.trim(),
|
||||
})
|
||||
message.success('管理员账号已创建')
|
||||
setCreateOpen(false)
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (account: AdminStaffAccount) => {
|
||||
setEditing(account)
|
||||
editForm.setFieldsValue({ nickname: account.nickname, status: account.status })
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editing) return
|
||||
const values = await editForm.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateAdminStaffAccount(editing.id, {
|
||||
nickname: values.nickname?.trim(),
|
||||
status: values.status,
|
||||
})
|
||||
message.success('账号信息已更新')
|
||||
setEditing(undefined)
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openRoles = (account: AdminStaffAccount) => {
|
||||
setRoleTarget(account)
|
||||
setRoleIds(account.roles.map(role => role.id))
|
||||
}
|
||||
|
||||
const submitRoles = async () => {
|
||||
if (!roleTarget) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await assignAdminStaffRoles(roleTarget.id, roleIds)
|
||||
message.success('角色已更新')
|
||||
setRoleTarget(undefined)
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openPassword = (account: AdminStaffAccount) => {
|
||||
setPasswordTarget(account)
|
||||
passwordForm.setFieldsValue({ new_password: '' })
|
||||
}
|
||||
|
||||
const submitPassword = async () => {
|
||||
if (!passwordTarget) return
|
||||
const values = await passwordForm.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await resetAdminStaffPassword(passwordTarget.id, values.new_password)
|
||||
message.success('密码已重置,该账号需重新登录')
|
||||
setPasswordTarget(undefined)
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (account: AdminStaffAccount) => {
|
||||
if (account.id === currentAdminId) {
|
||||
message.warning('不能删除当前登录账号')
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: `确认删除管理员「${account.nickname || account.username}」?`,
|
||||
content: '删除后该账号立即失去后台访问权限,操作不可撤销。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteAdminStaffAccount(account.id)
|
||||
message.success('管理员账号已删除')
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminStaffAccount> = [
|
||||
{
|
||||
title: '账号',
|
||||
key: 'account',
|
||||
width: 240,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<Space size={6} wrap>
|
||||
<strong>{row.nickname || row.username}</strong>
|
||||
<Tag color={row.status === 'active' ? 'success' : 'default'}>
|
||||
{row.status === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
{row.id === currentAdminId ? <Tag color="blue">当前登录</Tag> : null}
|
||||
</Space>
|
||||
<span className="admin-mono">{row.username}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
key: 'roles',
|
||||
render: (_, row) =>
|
||||
row.roles.length === 0 ? (
|
||||
<Typography.Text type="secondary">未分配</Typography.Text>
|
||||
) : (
|
||||
<Space size={4} wrap>
|
||||
{row.roles.map(role => (
|
||||
<Tag key={role.id} color="geekblue">
|
||||
{role.name}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '说明',
|
||||
key: 'description',
|
||||
render: (_, row) =>
|
||||
formatText(row.roles.map(role => role.description).filter(Boolean).join(' / ')),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login_at',
|
||||
width: 170,
|
||||
render: value => (value ? formatDateTime(value) : '从未登录'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SafetyOutlined />}
|
||||
disabled={!canManage}
|
||||
onClick={() => openRoles(row)}
|
||||
>
|
||||
角色
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
disabled={!canManage}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<KeyOutlined />}
|
||||
disabled={!canManage}
|
||||
onClick={() => openPassword(row)}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!canManage || row.id === currentAdminId}
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader eyebrow="JIEYOU OPS · 系统" title="管理员账号" />
|
||||
<Alert type="warning" showIcon message="当前账号没有管理员账号管理权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 系统"
|
||||
title="管理员账号"
|
||||
description="维护后台账号、启用状态、角色绑定与密码重置。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {accountsQuery.data?.total ?? 0} 个账号</Tag>
|
||||
<Tag>可用角色 {rolesQuery.data?.length ?? 0}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建账号
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={accountsQuery.isFetching}
|
||||
onClick={() => void accountsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="密码规则:至少 8 位,且同时包含字母与数字。"
|
||||
description="重置密码后该账号的登录态会失效;不能删除当前登录账号,也不能删除最后一个超级管理员。"
|
||||
/>
|
||||
|
||||
{accountsQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="管理员列表加载失败"
|
||||
description={errorText(accountsQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminStaffAccount>
|
||||
columns={columns}
|
||||
dataSource={accounts}
|
||||
rowKey="id"
|
||||
loading={accountsQuery.isFetching}
|
||||
total={accountsQuery.data?.total ?? 0}
|
||||
page={accountsQuery.data?.page ?? page}
|
||||
pageSize={accountsQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="新建管理员账号"
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void submitCreate()}
|
||||
onCancel={() => (submitting ? undefined : setCreateOpen(false))}
|
||||
maskClosable={!submitting}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input placeholder="登录用户名" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="nickname" label="昵称">
|
||||
<Input placeholder="用于后台展示,可留空" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="初始密码"
|
||||
rules={[{ validator: (_, value) => passwordRule(value) }]}
|
||||
>
|
||||
<Input.Password placeholder="至少 8 位,含字母与数字" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editing)}
|
||||
title={editing ? `编辑账号 · ${editing.username}` : '编辑账号'}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void submitEdit()}
|
||||
onCancel={() => (submitting ? undefined : setEditing(undefined))}
|
||||
maskClosable={!submitting}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="nickname" label="昵称">
|
||||
<Input placeholder="用于后台展示" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(roleTarget)}
|
||||
title={roleTarget ? `分配角色 · ${roleTarget.username}` : '分配角色'}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void submitRoles()}
|
||||
onCancel={() => (submitting ? undefined : setRoleTarget(undefined))}
|
||||
maskClosable={!submitting}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择角色"
|
||||
options={roleOptions}
|
||||
value={roleIds}
|
||||
onChange={value => setRoleIds(value)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(passwordTarget)}
|
||||
title={passwordTarget ? `重置密码 · ${passwordTarget.username}` : '重置密码'}
|
||||
okText="重置"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void submitPassword()}
|
||||
onCancel={() => (submitting ? undefined : setPasswordTarget(undefined))}
|
||||
maskClosable={!submitting}
|
||||
>
|
||||
<Form form={passwordForm} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label="新密码"
|
||||
rules={[{ validator: (_, value) => passwordRule(value) }]}
|
||||
>
|
||||
<Input.Password placeholder="至少 8 位,含字母与数字" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
CopyOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
createAdminTrackingLink,
|
||||
fetchAdminTrackingLinks,
|
||||
fetchAdminTrackingOverview,
|
||||
updateAdminTrackingLink,
|
||||
updateAdminTrackingLinkStatus,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import type {
|
||||
AdminTrackingLink,
|
||||
AdminTrackingLinkPayload,
|
||||
AdminTrackingLinkQuery,
|
||||
} from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface LinkFormValues {
|
||||
name: string
|
||||
code?: string
|
||||
channel_type?: string
|
||||
target_url?: string
|
||||
cost_yuan?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
type Filters = Pick<AdminTrackingLinkQuery, 'keyword' | 'channel_type' | 'status'>
|
||||
|
||||
const emptyFilters: Filters = { keyword: '', channel_type: undefined, status: undefined }
|
||||
|
||||
const channelOptions = [
|
||||
{ value: 'general', label: '通用' },
|
||||
{ value: 'douyin', label: '抖音' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'search', label: '搜索' },
|
||||
{ value: 'offline', 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 AdminTrackingLinksPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<LinkFormValues>()
|
||||
const [filters, setFilters] = useState<Filters>(emptyFilters)
|
||||
const [applied, setApplied] = useState<Filters>(emptyFilters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminTrackingLink>()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('ad_tracking:manage'))
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
keyword: applied.keyword?.trim() || undefined,
|
||||
channel_type: applied.channel_type,
|
||||
status: applied.status,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}),
|
||||
[applied, page, pageSize]
|
||||
)
|
||||
|
||||
const linksQuery = useQuery({
|
||||
queryKey: ['admin-tracking-links', query],
|
||||
queryFn: () => fetchAdminTrackingLinks(query),
|
||||
enabled: canManage,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-tracking-overview'],
|
||||
queryFn: fetchAdminTrackingOverview,
|
||||
enabled: canManage,
|
||||
})
|
||||
|
||||
const links = linksQuery.data?.items ?? []
|
||||
const overview = overviewQuery.data
|
||||
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tracking-links'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tracking-overview'] }),
|
||||
])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(undefined)
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
code: '',
|
||||
channel_type: 'general',
|
||||
target_url: '',
|
||||
cost_yuan: 0,
|
||||
notes: '',
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (link: AdminTrackingLink) => {
|
||||
setEditing(link)
|
||||
form.setFieldsValue({
|
||||
name: link.name,
|
||||
code: link.code,
|
||||
channel_type: link.channel_type || 'general',
|
||||
target_url: link.target_url,
|
||||
cost_yuan: link.cost_cent / 100,
|
||||
notes: link.notes,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields()
|
||||
const costCent = Math.round((values.cost_yuan ?? 0) * 100)
|
||||
const payload: AdminTrackingLinkPayload = {
|
||||
name: values.name.trim(),
|
||||
channel_type: values.channel_type,
|
||||
target_url: values.target_url?.trim(),
|
||||
cost_cent: costCent,
|
||||
notes: values.notes?.trim(),
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateAdminTrackingLink(editing.id, payload)
|
||||
message.success('推广链接已更新')
|
||||
} else {
|
||||
await createAdminTrackingLink({ ...payload, code: values.code?.trim() })
|
||||
message.success('推广链接已创建')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (link: AdminTrackingLink) => {
|
||||
const next = link.status === 'active' ? 'disabled' : 'active'
|
||||
modal.confirm({
|
||||
title: next === 'active' ? `启用链接「${link.name}」?` : `停用链接「${link.name}」?`,
|
||||
content: next === 'active' ? '启用后短链会恢复跳转并继续统计。' : '停用后短链不再跳转,已有统计数据保留。',
|
||||
okText: next === 'active' ? '启用' : '停用',
|
||||
okButtonProps: next === 'active' ? undefined : { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await updateAdminTrackingLinkStatus(link.id, next)
|
||||
message.success(next === 'active' ? '链接已启用' : '链接已停用')
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const copyShortURL = async (link: AdminTrackingLink) => {
|
||||
if (!link.short_url) {
|
||||
message.warning('该链接暂无短链地址')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.short_url)
|
||||
message.success('短链已复制')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminTrackingLink> = [
|
||||
{
|
||||
title: '推广链接',
|
||||
key: 'link',
|
||||
width: 260,
|
||||
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>
|
||||
<Typography.Text type="secondary" copyable={Boolean(row.short_url)}>
|
||||
{formatText(row.short_url)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '渠道',
|
||||
dataIndex: 'channel_type',
|
||||
width: 110,
|
||||
render: value => formatText(value),
|
||||
},
|
||||
{
|
||||
title: '点击 PV / UV',
|
||||
key: 'clicks',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{row.click_pv} / {row.click_uv}</span>
|
||||
<Typography.Text type="secondary">IP {row.click_ip}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '注册 / 下单',
|
||||
key: 'conversion',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{row.register_count} / {row.order_count}</span>
|
||||
<Typography.Text type="secondary">
|
||||
转化 {(row.conversion_rate * 100).toFixed(1)}%
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成交金额',
|
||||
key: 'gmv',
|
||||
width: 160,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatMoney(row.paid_gmv_cent)}</span>
|
||||
<Typography.Text type="secondary">成交 {row.paid_order_count} 单</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
dataIndex: 'cost_cent',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: value => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 210,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!row.short_url}
|
||||
onClick={() => void copyShortURL(row)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
disabled={!canManage}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger={row.status === 'active'}
|
||||
disabled={!canManage}
|
||||
onClick={() => toggleStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader eyebrow="JIEYOU OPS · 运营" title="推广追踪" />
|
||||
<Alert type="warning" showIcon message="当前账号没有推广追踪管理权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 运营"
|
||||
title="推广追踪"
|
||||
description="管理推广短链、渠道成本与注册下单转化数据。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">链接 {overview?.total_links ?? 0}</Tag>
|
||||
<Tag color="green">启用 {overview?.active_links ?? 0}</Tag>
|
||||
<Tag>今日 UV {overview?.today_uv ?? 0}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={linksQuery.isFetching || overviewQuery.isFetching}
|
||||
onClick={() => {
|
||||
void linksQuery.refetch()
|
||||
void overviewQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建链接
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="admin-kpi-grid" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="累计 PV" value={overview?.total_pv ?? 0} />
|
||||
<Statistic title="累计 UV" value={overview?.total_uv ?? 0} />
|
||||
<Statistic title="注册用户" value={overview?.total_registers ?? 0} />
|
||||
<Statistic title="成交订单" value={overview?.total_paid_orders ?? 0} />
|
||||
<Statistic
|
||||
title="成交金额"
|
||||
value={overview?.total_paid_gmv_cent ? overview.total_paid_gmv_cent / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
<Statistic title="今日 UV" value={overview?.today_uv ?? 0} />
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
loading={linksQuery.isFetching}
|
||||
onSearch={() => {
|
||||
setPage(1)
|
||||
setApplied(filters)
|
||||
}}
|
||||
onReset={() => {
|
||||
setFilters(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<FilterField label="关键字">
|
||||
<Input
|
||||
allowClear
|
||||
value={filters.keyword}
|
||||
placeholder="名称 / 编码"
|
||||
onChange={event =>
|
||||
setFilters(current => ({ ...current, keyword: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="渠道">
|
||||
<Select
|
||||
allowClear
|
||||
value={filters.channel_type}
|
||||
options={channelOptions}
|
||||
placeholder="全部渠道"
|
||||
onChange={value =>
|
||||
setFilters(current => ({ ...current, channel_type: value }))
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
value={filters.status}
|
||||
placeholder="全部状态"
|
||||
options={[
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '停用' },
|
||||
]}
|
||||
onChange={value => setFilters(current => ({ ...current, status: value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{linksQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="推广链接加载失败"
|
||||
description={errorText(linksQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminTrackingLink>
|
||||
columns={columns}
|
||||
dataSource={links}
|
||||
rowKey="id"
|
||||
loading={linksQuery.isFetching}
|
||||
total={linksQuery.data?.total ?? 0}
|
||||
page={linksQuery.data?.page ?? page}
|
||||
pageSize={linksQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="还没有推广链接"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? `编辑链接 · ${editing.name}` : '新建推广链接'}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void save()}
|
||||
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
|
||||
maskClosable={!submitting}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:抖音 10 月投放" maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="短链编码"
|
||||
rules={[
|
||||
{ required: !editing, message: '请输入短链编码' },
|
||||
{ min: 2, max: 64, message: '长度 2-64 个字符' },
|
||||
]}
|
||||
extra={editing ? '编码创建后不可修改' : '用于生成短链,建议使用英文与数字'}
|
||||
>
|
||||
<Input placeholder="例如:dy-2025-10" disabled={Boolean(editing)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel_type" label="渠道">
|
||||
<Select options={channelOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_url" label="跳转地址">
|
||||
<Input placeholder="留空默认跳转首页" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cost_yuan" label="推广成本(元)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 200 }} prefix="¥" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,25 @@ import type {
|
||||
AdminSupportGroup,
|
||||
AdminSupportGroupPayload,
|
||||
AdminSupportMember,
|
||||
AdminStaffAccount,
|
||||
AdminStaffAccountPage,
|
||||
AdminStaffAccountQuery,
|
||||
AdminStaffCreatePayload,
|
||||
AdminStaffUpdatePayload,
|
||||
AdminManualEntry,
|
||||
AdminManualEntryAgentStat,
|
||||
AdminManualEntryOption,
|
||||
AdminManualEntryOptions,
|
||||
AdminManualEntryPage,
|
||||
AdminManualEntryPayload,
|
||||
AdminManualEntryQuery,
|
||||
AdminQuickReply,
|
||||
AdminQuickReplyPayload,
|
||||
AdminTrackingLink,
|
||||
AdminTrackingLinkPage,
|
||||
AdminTrackingLinkPayload,
|
||||
AdminTrackingLinkQuery,
|
||||
AdminTrackingOverview,
|
||||
AdminSyncSiteAccountsPayload,
|
||||
AdminSyncSiteAccountsResult,
|
||||
AdminSystemConfig,
|
||||
@@ -1082,6 +1101,198 @@ export async function assignAdminSupportGroupMembers(id: number, memberIds: numb
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchAdminStaffAccounts(query: AdminStaffAccountQuery = {}) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminStaffAccountPage>>>(
|
||||
'/admin/admin-users',
|
||||
{ params: compactParams(query) }
|
||||
)
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminStaffAccount(id: number) {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminStaffAccount>>(
|
||||
`/admin/admin-users/${id}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createAdminStaffAccount(payload: AdminStaffCreatePayload) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminStaffAccount>>(
|
||||
'/admin/admin-users',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminStaffAccount(id: number, payload: AdminStaffUpdatePayload) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminStaffAccount>>(
|
||||
`/admin/admin-users/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteAdminStaffAccount(id: number) {
|
||||
await adminClient.delete(`/admin/admin-users/${id}`)
|
||||
}
|
||||
|
||||
export async function assignAdminStaffRoles(id: number, roleIds: number[]) {
|
||||
await adminClient.put(`/admin/admin-users/${id}/roles`, { role_ids: roleIds })
|
||||
}
|
||||
|
||||
export async function resetAdminStaffPassword(id: number, newPassword: string) {
|
||||
await adminClient.put(`/admin/admin-users/${id}/password`, { new_password: newPassword })
|
||||
}
|
||||
|
||||
export async function fetchAdminManualEntries(query: AdminManualEntryQuery = {}) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminManualEntryPage>>>(
|
||||
'/admin/manual-entries',
|
||||
{ params: compactParams(query) }
|
||||
)
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminManualEntry(payload: AdminManualEntryPayload) {
|
||||
const { data } = await adminClient.post<ApiResponse<{ entry: AdminManualEntry }>>(
|
||||
'/admin/manual-entries',
|
||||
payload
|
||||
)
|
||||
return data.data.entry
|
||||
}
|
||||
|
||||
export async function updateAdminManualEntry(id: number, payload: AdminManualEntryPayload) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminManualEntry>>(
|
||||
`/admin/manual-entries/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteAdminManualEntry(id: number) {
|
||||
await adminClient.delete(`/admin/manual-entries/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchAdminManualEntryOptions() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminManualEntryOptions>>(
|
||||
'/admin/manual-entries/options'
|
||||
)
|
||||
return {
|
||||
platforms: data.data?.platforms ?? [],
|
||||
operators: data.data?.operators ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminManualEntryOption(payload: { type: string; name: string }) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminManualEntryOption>>(
|
||||
'/admin/manual-entries/options',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminManualEntryOption(id: number, name: string) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminManualEntryOption>>(
|
||||
`/admin/manual-entries/options/${id}`,
|
||||
{ name }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteAdminManualEntryOption(id: number) {
|
||||
await adminClient.delete(`/admin/manual-entries/options/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchAdminManualEntryTodayStats() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminManualEntryAgentStat[]>>(
|
||||
'/admin/manual-entries/today-stats'
|
||||
)
|
||||
return data.data ?? []
|
||||
}
|
||||
|
||||
export async function fetchAdminQuickReplies() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminQuickReply[]>>(
|
||||
'/admin/chats/quick-replies'
|
||||
)
|
||||
return data.data ?? []
|
||||
}
|
||||
|
||||
export async function createAdminQuickReply(payload: AdminQuickReplyPayload) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminQuickReply>>(
|
||||
'/admin/chats/quick-replies',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminQuickReply(id: number, payload: AdminQuickReplyPayload) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminQuickReply>>(
|
||||
`/admin/chats/quick-replies/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteAdminQuickReply(id: number) {
|
||||
await adminClient.delete(`/admin/chats/quick-replies/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchAdminTrackingLinks(query: AdminTrackingLinkQuery = {}) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminTrackingLinkPage>>>(
|
||||
'/admin/tracking-links',
|
||||
{ params: compactParams(query) }
|
||||
)
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminTrackingOverview() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminTrackingOverview>>(
|
||||
'/admin/tracking-links/overview'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createAdminTrackingLink(payload: AdminTrackingLinkPayload) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminTrackingLink>>(
|
||||
'/admin/tracking-links',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminTrackingLink(id: number, payload: AdminTrackingLinkPayload) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminTrackingLink>>(
|
||||
`/admin/tracking-links/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminTrackingLinkStatus(id: number, status: string) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminTrackingLink>>(
|
||||
`/admin/tracking-links/${id}/status`,
|
||||
{ status }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<FinanceDisbursementPage>>>(
|
||||
'/admin/finance/disbursements',
|
||||
|
||||
@@ -1408,3 +1408,184 @@ export interface AdminSupportGroupPayload {
|
||||
sort_order?: number
|
||||
member_ids?: number[]
|
||||
}
|
||||
|
||||
export interface AdminStaffRole {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description?: string
|
||||
perm_count?: number
|
||||
}
|
||||
|
||||
export interface AdminStaffAccount {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
status: string
|
||||
roles: AdminStaffRole[]
|
||||
last_login_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminStaffAccountPage {
|
||||
items: AdminStaffAccount[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminStaffAccountQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminStaffCreatePayload {
|
||||
username: string
|
||||
password: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export interface AdminStaffUpdatePayload {
|
||||
nickname?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface AdminManualEntry {
|
||||
id: number
|
||||
entry_no: string
|
||||
platform: string
|
||||
operator: string
|
||||
admin_id: number
|
||||
admin_name: string
|
||||
admin_username: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminManualEntryPage {
|
||||
items: AdminManualEntry[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminManualEntryQuery {
|
||||
entry_no?: string
|
||||
keyword?: string
|
||||
admin_id?: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminManualEntryPayload {
|
||||
entry_no: string
|
||||
platform?: string
|
||||
operator?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface AdminManualEntryOption {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminManualEntryOptions {
|
||||
platforms: AdminManualEntryOption[]
|
||||
operators: AdminManualEntryOption[]
|
||||
}
|
||||
|
||||
export interface AdminManualEntryAgentStat {
|
||||
date: string
|
||||
admin_id: number
|
||||
admin_name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AdminQuickReply {
|
||||
id: number
|
||||
admin_user_id: number
|
||||
title: string
|
||||
content: string
|
||||
sort_order: number
|
||||
is_global: boolean
|
||||
}
|
||||
|
||||
export interface AdminQuickReplyPayload {
|
||||
title: string
|
||||
content: string
|
||||
sort_order?: number
|
||||
is_global?: boolean
|
||||
}
|
||||
|
||||
export interface AdminTrackingLink {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
channel_type: string
|
||||
target_url: string
|
||||
short_url: string
|
||||
status: string
|
||||
cost_cent: number
|
||||
notes: string
|
||||
created_by: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
click_pv: number
|
||||
click_uv: number
|
||||
click_ip: number
|
||||
register_count: number
|
||||
order_count: number
|
||||
paid_order_count: number
|
||||
paid_gmv_cent: number
|
||||
conversion_rate: number
|
||||
}
|
||||
|
||||
export interface AdminTrackingLinkPage {
|
||||
items: AdminTrackingLink[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminTrackingLinkQuery {
|
||||
keyword?: string
|
||||
channel_type?: string
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminTrackingLinkPayload {
|
||||
name: string
|
||||
code?: string
|
||||
channel_type?: string
|
||||
target_url?: string
|
||||
cost_cent?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface AdminTrackingOverview {
|
||||
total_links: number
|
||||
active_links: number
|
||||
total_pv: number
|
||||
total_uv: number
|
||||
total_ip: number
|
||||
total_registers: number
|
||||
total_orders: number
|
||||
total_paid_orders: number
|
||||
total_paid_gmv_cent: number
|
||||
today_pv: number
|
||||
today_uv: number
|
||||
today_registers: number
|
||||
today_orders: number
|
||||
today_paid_gmv_cent: number
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -130,13 +130,17 @@ const allNavGroups: NavGroup[] = [
|
||||
key: 'operations',
|
||||
label: '运营',
|
||||
icon: <BellOutlined />,
|
||||
children: [{ key: '/announcements', label: '公告管理', permission: 'announcement:view' }],
|
||||
children: [{ key: '/announcements', label: '公告管理', permission: 'announcement:view' }, { key: '/tracking-links', label: '推广追踪', permission: 'ad_tracking:manage' }],
|
||||
},
|
||||
{
|
||||
key: 'support',
|
||||
label: '客服',
|
||||
icon: <CustomerServiceOutlined />,
|
||||
children: [{ key: '/chat-support-groups', label: '客服群组', permission: 'chat:manage' }],
|
||||
children: [
|
||||
{ key: '/chat-support-groups', label: '客服群组', permission: 'chat:manage' },
|
||||
{ key: '/manual-entries', label: '手工录单', permission: 'cs_manual_entry:view' },
|
||||
{ key: '/chat-quick-replies', label: '快捷回复', permission: 'chat:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
@@ -145,6 +149,7 @@ const allNavGroups: NavGroup[] = [
|
||||
children: [
|
||||
{ key: '/system-configs', label: '系统配置', permission: 'system_config:view' },
|
||||
{ key: '/roles', label: '角色权限', permission: 'role:manage' },
|
||||
{ key: '/admin-users', label: '管理员账号', permission: 'admin_user:manage' },
|
||||
{ key: '/payment-configs', label: '支付配置', permission: 'payment_config:list' },
|
||||
{ key: '/audit-logs', label: '审计日志', permission: 'audit_log:view' },
|
||||
],
|
||||
@@ -177,6 +182,19 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
|
||||
'/distribution/invitation': { title: '邀请返利', description: '配置分站邀请好友的返利比例。' },
|
||||
'/announcements': { title: '公告管理', description: '维护主站与分站公告。' },
|
||||
'/roles': { title: '角色权限', description: '维护后台角色并分配资源级操作权限。' },
|
||||
'/admin-users': { title: '管理员账号', description: '维护后台账号、启用状态与角色绑定。' },
|
||||
'/manual-entries': {
|
||||
title: '手工录单',
|
||||
description: '录入系统外成单编号,用于对账与客服绩效统计。',
|
||||
},
|
||||
'/chat-quick-replies': {
|
||||
title: '快捷回复',
|
||||
description: '维护客服会话常用话术与全局可见范围。',
|
||||
},
|
||||
'/tracking-links': {
|
||||
title: '推广追踪',
|
||||
description: '管理推广短链、渠道成本与注册下单转化。',
|
||||
},
|
||||
'/chat-support-groups': {
|
||||
title: '客服群组',
|
||||
description: '维护客服群与坐席分配,支撑会话分流。',
|
||||
|
||||
Reference in New Issue
Block a user