From a39a504b812ec98eb6f7367bf42a683b9d070f18 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 16:16:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=A1=A5=E9=BD=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E8=B4=A6=E5=8F=B7=E4=B8=8E=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E8=BF=90=E8=90=A5=E9=95=BF=E5=B0=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase2-后台长尾四页验收.md | 142 ++++ docs/项目计划.md | 8 +- frontend/src/app/AppRouter.tsx | 24 + .../features/admin/AdminManualEntriesPage.tsx | 615 ++++++++++++++++++ .../features/admin/AdminQuickRepliesPage.tsx | 298 +++++++++ .../features/admin/AdminStaffAccountsPage.tsx | 491 ++++++++++++++ .../features/admin/AdminTrackingLinksPage.tsx | 495 ++++++++++++++ frontend/src/features/admin/api.ts | 211 ++++++ frontend/src/features/admin/types.ts | 181 ++++++ frontend/src/layouts/AdminLayout.tsx | 22 +- 10 files changed, 2482 insertions(+), 5 deletions(-) create mode 100644 docs/Phase2-后台长尾四页验收.md create mode 100644 frontend/src/features/admin/AdminManualEntriesPage.tsx create mode 100644 frontend/src/features/admin/AdminQuickRepliesPage.tsx create mode 100644 frontend/src/features/admin/AdminStaffAccountsPage.tsx create mode 100644 frontend/src/features/admin/AdminTrackingLinksPage.tsx diff --git a/docs/Phase2-后台长尾四页验收.md b/docs/Phase2-后台长尾四页验收.md new file mode 100644 index 0000000..bfe30bc --- /dev/null +++ b/docs/Phase2-后台长尾四页验收.md @@ -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. 仍未覆盖 + +- 四个页面的写操作(新建、编辑、删除、重置密码、启停)均未做端到端验证。 +- 手工录单的日期区间筛选依赖后端按北京时间解析,尚未用真实数据验证边界。 +- 推广短链跳转与埋点统计链路未验证(需要真实访问流量)。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 0763c11..c9ce4e7 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -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 个后台页面全部可用,分销后台与主站视觉统一,分销业务流程不变。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 9dd478a..cea8381 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> = { + 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() + const [optionForm] = Form.useForm<{ type: string; name: string }>() + const [filters, setFilters] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState() + const [optionsOpen, setOptionsOpen] = useState(false) + const [renaming, setRenaming] = useState() + 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 暂无{optionTypeLabels[type]}选项 + } + return ( + + {items.map(item => ( + } + onClose={event => { + event.preventDefault() + removeOption(item) + }} + color="geekblue" + > + { + if (!canManageOptions) return + setRenaming(item) + setRenameValue(item.name) + }} + onKeyDown={event => { + if (!canManageOptions || event.key !== 'Enter') return + setRenaming(item) + setRenameValue(item.name) + }} + > + {item.name} + + + ))} + + ) + } + + const columns: TableColumnsType = [ + { + title: '录单编号', + dataIndex: 'entry_no', + width: 200, + fixed: 'left', + render: value => {value}, + }, + { + title: '平台 / 操作人', + key: 'platform', + width: 200, + render: (_, row) => ( +
+ {formatText(row.platform)} + {formatText(row.operator)} +
+ ), + }, + { + title: '客服', + key: 'admin', + width: 170, + render: (_, row) => ( +
+ {row.admin_name || row.admin_username || `ID ${row.admin_id}`} + {row.admin_username} +
+ ), + }, + { + 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) => ( + + + + + ), + }, + ] + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 今日录单 {todayTotal} 条 + 参与客服 {stats.length} 人 + + } + extra={ + + {canManageOptions ? ( + + ) : null} + + {canCreate ? ( + + ) : null} + + } + /> + + {stats.length > 0 ? ( +
+ + {stats.map(item => ( + + {item.admin_name || `ID ${item.admin_id}`} · {item.count} + + ))} + +
+ ) : null} + + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + setFilters(emptyFilters) + setApplied(emptyFilters) + setPage(1) + }} + > + + setFilters(current => ({ ...current, entry_no: event.target.value }))} + /> + + + setFilters(current => ({ ...current, keyword: event.target.value }))} + /> + + + + setFilters(current => ({ + ...current, + range: value && value[0] && value[1] ? [value[0], value[1]] : null, + })) + } + /> + + + + {entriesQuery.isError ? ( + + ) : null} + + + 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="还没有录入记录" + /> + + void save()} + onCancel={() => (submitting ? undefined : setEditorOpen(false))} + maskClosable={!submitting} + > +
+ + + + + ({ value: item.name }))} + placeholder="选择或填写平台" + /> + + + ({ value: item.name }))} + placeholder="选择或填写操作人" + /> + + + + +
+
+ + setOptionsOpen(false)} + footer={ + + } + width={640} + > + + 平台 + {renderOptionList('platform')} + + 操作人 + + {renderOptionList('operator')} +
+ + + + + + +
+
+ + void submitRename()} + onCancel={() => setRenaming(undefined)} + > + setRenameValue(event.target.value)} + placeholder="选项名称" + maxLength={64} + /> + +
+ ) +} + +function AutoCompleteOptions({ + options, + placeholder, +}: { + options: { value: string }[] + placeholder?: string +}) { + return ( + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/features/admin/AdminStaffAccountsPage.tsx b/frontend/src/features/admin/AdminStaffAccountsPage.tsx new file mode 100644 index 0000000..99335bb --- /dev/null +++ b/frontend/src/features/admin/AdminStaffAccountsPage.tsx @@ -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() + const [editForm] = Form.useForm() + const [passwordForm] = Form.useForm() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [createOpen, setCreateOpen] = useState(false) + const [editing, setEditing] = useState() + const [roleTarget, setRoleTarget] = useState() + const [roleIds, setRoleIds] = useState([]) + const [passwordTarget, setPasswordTarget] = useState() + 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 = [ + { + title: '账号', + key: 'account', + width: 240, + fixed: 'left', + render: (_, row) => ( +
+ + {row.nickname || row.username} + + {row.status === 'active' ? '启用' : '停用'} + + {row.id === currentAdminId ? 当前登录 : null} + + {row.username} +
+ ), + }, + { + title: '角色', + key: 'roles', + render: (_, row) => + row.roles.length === 0 ? ( + 未分配 + ) : ( + + {row.roles.map(role => ( + + {role.name} + + ))} + + ), + }, + { + 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) => ( + + + + + + + ), + }, + ] + + if (!canManage) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 共 {accountsQuery.data?.total ?? 0} 个账号 + 可用角色 {rolesQuery.data?.length ?? 0} + + } + extra={ + + + + + } + /> + + + + {accountsQuery.isError ? ( + + ) : null} + + + 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) + }} + /> + + void submitCreate()} + onCancel={() => (submitting ? undefined : setCreateOpen(false))} + maskClosable={!submitting} + > +
+ + + + + + + passwordRule(value) }]} + > + + +
+
+ + void submitEdit()} + onCancel={() => (submitting ? undefined : setEditing(undefined))} + maskClosable={!submitting} + > +
+ + + + + setRoleIds(value)} + /> + + + void submitPassword()} + onCancel={() => (submitting ? undefined : setPasswordTarget(undefined))} + maskClosable={!submitting} + > + + passwordRule(value) }]} + > + + + + +
+ ) +} diff --git a/frontend/src/features/admin/AdminTrackingLinksPage.tsx b/frontend/src/features/admin/AdminTrackingLinksPage.tsx new file mode 100644 index 0000000..0a4babb --- /dev/null +++ b/frontend/src/features/admin/AdminTrackingLinksPage.tsx @@ -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 + +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() + const [filters, setFilters] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState() + 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 = [ + { + title: '推广链接', + key: 'link', + width: 260, + fixed: 'left', + render: (_, row) => ( +
+ + {row.name} + + {row.status === 'active' ? '启用' : '停用'} + + + {row.code} + + {formatText(row.short_url)} + +
+ ), + }, + { + title: '渠道', + dataIndex: 'channel_type', + width: 110, + render: value => formatText(value), + }, + { + title: '点击 PV / UV', + key: 'clicks', + width: 140, + align: 'right', + render: (_, row) => ( +
+ {row.click_pv} / {row.click_uv} + IP {row.click_ip} +
+ ), + }, + { + title: '注册 / 下单', + key: 'conversion', + width: 140, + align: 'right', + render: (_, row) => ( +
+ {row.register_count} / {row.order_count} + + 转化 {(row.conversion_rate * 100).toFixed(1)}% + +
+ ), + }, + { + title: '成交金额', + key: 'gmv', + width: 160, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.paid_gmv_cent)} + 成交 {row.paid_order_count} 单 +
+ ), + }, + { + 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) => ( + + + + + + ), + }, + ] + + if (!canManage) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 链接 {overview?.total_links ?? 0} + 启用 {overview?.active_links ?? 0} + 今日 UV {overview?.today_uv ?? 0} + + } + extra={ + + + + + } + /> + +
+ + + + + + +
+ + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + setFilters(emptyFilters) + setApplied(emptyFilters) + setPage(1) + }} + > + + + setFilters(current => ({ ...current, keyword: event.target.value })) + } + /> + + + setFilters(current => ({ ...current, status: value }))} + /> + + + + {linksQuery.isError ? ( + + ) : null} + + + 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="还没有推广链接" + /> + + void save()} + onCancel={() => (submitting ? undefined : setEditorOpen(false))} + maskClosable={!submitting} + width={560} + > +
+ + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 43bc477..79c7047 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -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>>( + '/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>( + `/admin/admin-users/${id}` + ) + return data.data +} + +export async function createAdminStaffAccount(payload: AdminStaffCreatePayload) { + const { data } = await adminClient.post>( + '/admin/admin-users', + payload + ) + return data.data +} + +export async function updateAdminStaffAccount(id: number, payload: AdminStaffUpdatePayload) { + const { data } = await adminClient.put>( + `/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>>( + '/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>( + '/admin/manual-entries', + payload + ) + return data.data.entry +} + +export async function updateAdminManualEntry(id: number, payload: AdminManualEntryPayload) { + const { data } = await adminClient.put>( + `/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>( + '/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>( + '/admin/manual-entries/options', + payload + ) + return data.data +} + +export async function updateAdminManualEntryOption(id: number, name: string) { + const { data } = await adminClient.put>( + `/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>( + '/admin/manual-entries/today-stats' + ) + return data.data ?? [] +} + +export async function fetchAdminQuickReplies() { + const { data } = await adminClient.get>( + '/admin/chats/quick-replies' + ) + return data.data ?? [] +} + +export async function createAdminQuickReply(payload: AdminQuickReplyPayload) { + const { data } = await adminClient.post>( + '/admin/chats/quick-replies', + payload + ) + return data.data +} + +export async function updateAdminQuickReply(id: number, payload: AdminQuickReplyPayload) { + const { data } = await adminClient.put>( + `/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>>( + '/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>( + '/admin/tracking-links/overview' + ) + return data.data +} + +export async function createAdminTrackingLink(payload: AdminTrackingLinkPayload) { + const { data } = await adminClient.post>( + '/admin/tracking-links', + payload + ) + return data.data +} + +export async function updateAdminTrackingLink(id: number, payload: AdminTrackingLinkPayload) { + const { data } = await adminClient.put>( + `/admin/tracking-links/${id}`, + payload + ) + return data.data +} + +export async function updateAdminTrackingLinkStatus(id: number, status: string) { + const { data } = await adminClient.put>( + `/admin/tracking-links/${id}/status`, + { status } + ) + return data.data +} + + + + export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery) { const { data } = await adminClient.get>>( '/admin/finance/disbursements', diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 0ef24d1..4eeaa6d 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -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 +} + + + diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 458daf7..47f77ad 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -130,13 +130,17 @@ const allNavGroups: NavGroup[] = [ key: 'operations', label: '运营', icon: , - 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: , - 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 = { '/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: '维护客服群与坐席分配,支撑会话分流。',