feat(admin): 补齐管理员账号与客服运营长尾页

This commit is contained in:
yml2213
2026-10-30 16:16:37 +08:00
parent a54680f59a
commit a39a504b81
10 changed files with 2482 additions and 5 deletions
+142
View File
@@ -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
View File
@@ -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 个后台页面全部可用,分销后台与主站视觉统一,分销业务流程不变。
+24
View File
@@ -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>
)
}
+211
View File
@@ -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',
+181
View File
@@ -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
}
+20 -2
View File
@@ -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: '维护客服群与坐席分配,支撑会话分流。',