From 3297fd69ffb509eb22e275b0e406c2553b77feab Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 13:06:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase1-验收报告.md | 4 +- docs/Phase2-用户管理验收.md | 82 +++ docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 7 +- .../src/features/admin/AdminListingsPage.tsx | 8 + .../features/admin/AdminUserDetailDrawer.tsx | 517 ++++++++++++++++++ .../src/features/admin/AdminUsersPage.tsx | 285 ++++++++++ frontend/src/features/admin/api.ts | 87 +++ frontend/src/features/admin/labels.ts | 24 + frontend/src/features/admin/types.ts | 42 ++ 10 files changed, 1054 insertions(+), 4 deletions(-) create mode 100644 docs/Phase2-用户管理验收.md create mode 100644 frontend/src/features/admin/AdminUserDetailDrawer.tsx create mode 100644 frontend/src/features/admin/AdminUsersPage.tsx diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index c0601fd..4499d50 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -194,9 +194,9 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB ## 5. 已知限制 1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。 -2. 后台已完成仪表盘、订单列表/详情、商品列表/详情与商品审核,其余页面仍为统一占位。 +2. 后台已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作,其余页面仍为统一占位。 3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。 -4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`;其余页面仍需核对。 +4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`;其余页面仍需核对。 5. SVG 中文尚未转曲。 6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。 7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。 diff --git a/docs/Phase2-用户管理验收.md b/docs/Phase2-用户管理验收.md new file mode 100644 index 0000000..8c413f9 --- /dev/null +++ b/docs/Phase2-用户管理验收.md @@ -0,0 +1,82 @@ +# Phase 2 验收报告(后台用户管理) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:`/console-jy8k3m/users` +> 结论:**本切片通过,用户列表、详情抽屉与人工操作已接真实接口** + +--- + +## 1. 接口与权限 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 用户列表 | `GET /api/admin/users` | `user:view` | +| 冻结 | `POST /api/admin/users/:id/freeze` | `user:freeze` | +| 解冻 | `POST /api/admin/users/:id/unfreeze` | `user:unfreeze` | +| 免押额度 | `POST /api/admin/users/:id/deposit-free-quota` | `user:deposit_free` | +| 成长值 | `POST /api/admin/users/:id/growth-points` | `user:growth_points` | +| 实名补录 | `POST /api/admin/users/:id/manual-realname` | `user:manual_realname` | +| 撤销实名 | `POST /api/admin/users/:id/revoke-realname` | `user:revoke_realname` | +| 余额调账 | `POST /api/admin/users/:id/wallet/adjust` | `user:wallet_adjust` | + +前端菜单权限沿用 `user:view`,与后端正向一致。后端未提供用户详情接口,详情抽屉直接使用列表 `UserDTO`,避免多余请求。 + +## 2. 用户列表 + +`frontend/src/features/admin/AdminUsersPage.tsx` + +- 筛选:关键词(手机号 / 昵称 / 用户 ID)、账号状态、是否有免押额度 +- 列:用户(昵称/手机号/UID)、实名与风险、余额、免押额度、成长、业务量、账号状态、注册时间、详情 +- 分页 10 / 20 / 50,翻页保留旧数据 +- 点击昵称、行或「详情」打开抽屉 +- 无 `user:view` 时隐藏详情入口 + +## 3. 用户详情抽屉 + +`frontend/src/features/admin/AdminUserDetailDrawer.tsx` + +- 标题区:昵称、手机号、UID、账号/实名/风险标签 +- 分段:账号概览、钱包、免押额度、成长与活跃 +- 快捷入口:订单明细(`/orders?user_id=`,需 `order:list`)、商品明细(`/listings?owner_id=`,需 `listing:view`) +- 操作按钮按权限与状态显示: + - 正常用户 + `user:freeze`:冻结(必填原因) + - 冻结用户 + `user:unfreeze`:解冻(必填原因) + - `user:deposit_free`:设置手动免押额度(元 → 分) + - `user:growth_points`:调整成长值(目标值 + 原因) + - 未实名 + `user:manual_realname`:实名补录(姓名 / 证件号 / 原因) + - 已实名 + `user:revoke_realname`:撤销实名(必填原因) + - `user:wallet_adjust`:加款/扣款、金额、原因、外部流水号 +- 操作成功后更新抽屉数据并刷新列表 + +## 4. 配套改动 + +- `AdminListingsPage` 支持从 URL 读取 `owner_id` 与 `keyword`,供用户详情跳转商品明细。 +- `admin/labels.ts` 新增用户状态、实名状态、风险状态映射。 +- `admin/api.ts` 新增用户列表与全部人工操作接口。 + +## 5. 验证结果 + +```text +npx tsc --noEmit 通过 +npm run build 通过(入口 495.51 kB,gzip 161.02 kB) +GET /api/admin/users?page=1&page_size=5 ok,返回 UserDTO 全字段 +GET /console-jy8k3m/users 200 +GET /console-jy8k3m/listings?owner_id=1 200 +``` + +开发库当前仅 1 个用户,冻结、调账、实名等写操作未执行,避免污染开发数据;按钮权限与请求体已按后端 DTO 核对。 + +## 6. 已知限制 + +1. 后端无用户详情接口,抽屉无法独立刷新单个用户,依赖列表数据与操作返回值。 +2. 未提供风险状态人工调整(后端暂无对应接口)。 +3. 调账、冻结等操作未接入二次审批或批量处理。 +4. 审计日志页面尚未迁移,操作记录只能在数据层核对。 + +## 7. 下一步建议 + +1. 后台财务看板与退款/提现审核。 +2. 后台申诉仲裁。 +3. 商品审核调价与转移号主。 +4. 移动端下单、支付与交接/归还/结账。 diff --git a/docs/项目计划.md b/docs/项目计划.md index e752e33..8ae34a2 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -395,7 +395,7 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) -> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与商品审核;退款、提现、用户、申诉与系统配置待迁移。 +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作;退款、提现、申诉与系统配置待迁移。 - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index ec3a7bf..2ced5b6 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -73,6 +73,11 @@ const AdminListingsPage = lazy(() => default: module.AdminListingsPage, })) ) +const AdminUsersPage = lazy(() => + import('@/features/admin/AdminUsersPage').then(module => ({ + default: module.AdminUsersPage, + })) +) const AdminLayout = lazy(() => import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout })) ) @@ -83,7 +88,6 @@ const AdminSectionPlaceholder = lazy(() => const adminSections: Array<{ path: string; title: string; description: string }> = [ { path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' }, { path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' }, - { path: 'users', title: '用户管理', description: '查看用户、实名状态与交易记录。' }, { path: 'finance', title: '财务看板', description: '查看收入、退款、结算与资金趋势。' }, { path: 'withdrawals', title: '提现审核', description: '审核提现申请与打款确认。' }, { path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' }, @@ -124,6 +128,7 @@ export function AppRouter() { } /> } /> } /> + } /> {adminSections.map(section => ( (() => ({ ...emptyFilters, + keyword: initialKeyword, + owner_id: initialOwnerId, review_status: isReviewMode ? 'pending' : '', })) const [applied, setApplied] = useState(() => ({ ...emptyFilters, + keyword: initialKeyword, + owner_id: initialOwnerId, review_status: isReviewMode ? 'pending' : '', })) const [page, setPage] = useState(1) diff --git a/frontend/src/features/admin/AdminUserDetailDrawer.tsx b/frontend/src/features/admin/AdminUserDetailDrawer.tsx new file mode 100644 index 0000000..84cd90a --- /dev/null +++ b/frontend/src/features/admin/AdminUserDetailDrawer.tsx @@ -0,0 +1,517 @@ +import { useState } from 'react' +import { + App, + Button, + Input, + InputNumber, + Modal, + Select, + Space, + Tag, + Typography, +} from 'antd' +import { useNavigate } from 'react-router-dom' + +import { + adjustAdminUserGrowthPoints, + adjustAdminUserWallet, + freezeAdminUser, + manualAdminUserRealname, + revokeAdminUserRealname, + setAdminUserDepositFreeQuota, + unfreezeAdminUser, +} from '@/features/admin/api' +import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { + readStatusMeta, + realnameStatusMap, + riskStatusMap, + userStatusMap, +} from '@/features/admin/labels' +import type { AdminUserItem } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +type UserAction = + | 'freeze' + | 'unfreeze' + | 'quota' + | 'growth' + | 'manual-realname' + | 'revoke-realname' + | 'wallet' + +const actionCopy: Record = { + freeze: { + title: '冻结用户', + okText: '确认冻结', + hint: '冻结后用户无法继续下单或发布商品,请填写原因。', + }, + unfreeze: { + title: '解冻用户', + okText: '确认解冻', + hint: '解冻后用户恢复交易能力,请填写解冻原因。', + }, + quota: { + title: '设置免押额度', + okText: '保存额度', + hint: '手动额度会与等级额度合并计算,0 表示取消手动额度。', + }, + growth: { + title: '调整成长值', + okText: '保存成长值', + hint: '成长值会直接影响租客等级与折扣,请谨慎修改。', + }, + 'manual-realname': { + title: '实名补录', + okText: '提交实名', + hint: '用于人工核验后补录实名信息,证件号只用于实名校验。', + }, + 'revoke-realname': { + title: '撤销实名', + okText: '确认撤销', + hint: '撤销后用户需要重新实名,请填写原因。', + }, + wallet: { + title: '调整可用余额', + okText: '确认调账', + hint: '只调整可用余额,不影响冻结余额;每次调账都会写入流水与审计日志。', + }, +} + +function buildSections(user: AdminUserItem): DetailSection[] { + return [ + { + key: 'overview', + title: '账号概览', + fields: [ + { key: 'uid', label: '用户 ID', value: UID {user.id} }, + { key: 'phone', label: '手机号', value: formatText(user.phone) }, + { key: 'nickname', label: '昵称', value: formatText(user.nickname) }, + { + key: 'status', + label: '账号状态', + value: , + }, + { + key: 'realname', + label: '实名状态', + value: , + }, + { + key: 'risk', + label: '风险状态', + value: , + }, + { key: 'credit', label: '信用分', value: formatText(user.credit_score) }, + { key: 'created', label: '注册时间', value: formatDateTime(user.created_at) }, + { key: 'updated', label: '更新时间', value: formatDateTime(user.updated_at) }, + { key: 'last_login', label: '最后登录', value: formatDateTime(user.last_login_at) }, + ], + }, + { + key: 'wallet', + title: '钱包', + fields: [ + { key: 'available', label: '可用余额', value: formatMoney(user.available_balance_cent) }, + { key: 'frozen', label: '冻结余额', value: formatMoney(user.frozen_balance_cent) }, + ], + }, + { + key: 'deposit_free', + title: '免押额度', + fields: [ + { key: 'manual', label: '手动额度', value: formatMoney(user.deposit_free_manual_quota_cent) }, + { key: 'level', label: '等级额度', value: formatMoney(user.deposit_free_level_quota_cent) }, + { + key: 'effective', + label: '有效额度', + value: formatMoney(user.deposit_free_effective_quota_cent), + }, + { key: 'used', label: '已使用', value: formatMoney(user.deposit_free_used_cent) }, + { + key: 'remaining', + label: '剩余额度', + value: formatMoney(user.deposit_free_remaining_cent), + }, + { + key: 'quota_record', + label: '额度记录', + value: formatMoney(user.deposit_free_quota_cent), + }, + ], + }, + { + key: 'growth', + title: '成长与活跃', + fields: [ + { + key: 'level', + label: '成长等级', + value: user.renter_growth_level_name || user.renter_growth_level || emptyText, + }, + { + key: 'points', + label: '成长值', + value: user.renter_growth_points.toLocaleString('zh-CN'), + }, + { key: 'orders', label: '订单数', value: String(user.order_count) }, + { key: 'listings', label: '商品数', value: String(user.listing_count) }, + { key: 'disputes', label: '申诉数', value: String(user.dispute_count) }, + ], + }, + ] +} + +interface AdminUserDetailDrawerProps { + user: AdminUserItem | null + open: boolean + onClose: () => void + onUpdated: (user: AdminUserItem) => void +} + +export function AdminUserDetailDrawer({ + user, + open, + onClose, + onUpdated, +}: AdminUserDetailDrawerProps) { + const { message } = App.useApp() + const navigate = useNavigate() + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code)) + const [action, setAction] = useState(null) + const [reason, setReason] = useState('') + const [amountYuan, setAmountYuan] = useState(null) + const [targetPoints, setTargetPoints] = useState(null) + const [realnameName, setRealnameName] = useState('') + const [realnameIDNo, setRealnameIDNo] = useState('') + const [walletDirection, setWalletDirection] = useState<'in' | 'out'>('in') + const [walletReference, setWalletReference] = useState('') + const [submitting, setSubmitting] = useState(false) + + const openAction = (next: UserAction) => { + setReason('') + setWalletDirection('in') + setWalletReference('') + if (next === 'quota') { + setAmountYuan(user ? user.deposit_free_manual_quota_cent / 100 : 0) + } + if (next === 'wallet') { + setAmountYuan(null) + } + if (next === 'growth') { + setTargetPoints(user?.renter_growth_points ?? 0) + } + if (next === 'manual-realname') { + setRealnameName('') + setRealnameIDNo('') + } + setAction(next) + } + + const closeAction = () => { + if (submitting) return + setAction(null) + setReason('') + } + + const submitAction = async () => { + if (!user || !action) return + const reasonText = reason.trim() + const needReason = action !== 'quota' + if (needReason && !reasonText) { + message.warning('请填写原因') + return + } + if (action === 'quota' && (amountYuan == null || amountYuan < 0)) { + message.warning('请填写有效的免押额度') + return + } + if (action === 'growth' && (targetPoints == null || targetPoints < 0)) { + message.warning('请填写有效的成长值') + return + } + if (action === 'manual-realname' && (!realnameName.trim() || !realnameIDNo.trim())) { + message.warning('请填写姓名与证件号') + return + } + if (action === 'wallet' && (amountYuan == null || amountYuan <= 0)) { + message.warning('请填写大于 0 的调账金额') + return + } + + setSubmitting(true) + try { + let updated: AdminUserItem + if (action === 'freeze') { + updated = await freezeAdminUser(user.id, reasonText) + message.success('用户已冻结') + } else if (action === 'unfreeze') { + updated = await unfreezeAdminUser(user.id, reasonText) + message.success('用户已解冻') + } else if (action === 'quota') { + updated = await setAdminUserDepositFreeQuota(user.id, Math.round((amountYuan ?? 0) * 100)) + message.success('免押额度已更新') + } else if (action === 'growth') { + updated = await adjustAdminUserGrowthPoints(user.id, targetPoints ?? 0, reasonText) + message.success('成长值已更新') + } else if (action === 'manual-realname') { + updated = await manualAdminUserRealname(user.id, { + name: realnameName.trim(), + id_no: realnameIDNo.trim(), + reason: reasonText, + }) + message.success('实名信息已补录') + } else if (action === 'revoke-realname') { + updated = await revokeAdminUserRealname(user.id, reasonText) + message.success('实名已撤销') + } else { + updated = await adjustAdminUserWallet(user.id, { + direction: walletDirection, + amount_cent: Math.round((amountYuan ?? 0) * 100), + reason: reasonText, + reference_no: walletReference.trim() || undefined, + }) + message.success('余额已调整') + } + setAction(null) + onUpdated(updated) + } catch (error) { + const text = + error && typeof error === 'object' && 'response' in error + ? ((error as { response?: { data?: { message?: string } } }).response?.data?.message ?? + '操作失败') + : error instanceof Error + ? error.message + : '操作失败' + message.error(text) + } finally { + setSubmitting(false) + } + } + + const copy = action ? actionCopy[action] : null + const isVerified = user?.realname_status === 'verified' + + return ( + <> + + {user.phone} · UID {user.id} + + ) : null + } + tags={ + user ? ( + + + + + + ) : null + } + sections={user ? buildSections(user) : []} + emptyDescription="请选择一个用户" + footer={ + user ? ( + + + + {user.status === 'active' && can('user:freeze') ? ( + + ) : null} + {user.status === 'frozen' && can('user:unfreeze') ? ( + + ) : null} + {can('user:deposit_free') ? ( + + ) : null} + {can('user:growth_points') ? ( + + ) : null} + {!isVerified && can('user:manual_realname') ? ( + + ) : null} + {isVerified && can('user:revoke_realname') ? ( + + ) : null} + {can('user:wallet_adjust') ? ( + + ) : null} + + + ) : ( + + ) + } + /> + + void submitAction()} + onCancel={closeAction} + > + {copy?.hint} + + {action === 'quota' ? ( + setAmountYuan(typeof value === 'number' ? value : null)} + /> + ) : null} + + {action === 'growth' ? ( + + setTargetPoints(typeof value === 'number' ? value : null)} + /> + setReason(event.target.value)} + /> + + ) : null} + + {action === 'manual-realname' ? ( + + setRealnameName(event.target.value)} + /> + setRealnameIDNo(event.target.value)} + /> + setReason(event.target.value)} + /> + + ) : null} + + {action === 'wallet' ? ( + + setWalletReference(event.target.value)} + /> + + ) : null} + + {['freeze', 'unfreeze', 'revoke-realname'].includes(action ?? '') ? ( + setReason(event.target.value)} + /> + ) : null} + + {user && action === 'freeze' ? ( + + 冻结对象:{user.nickname || user.phone}(UID {user.id}) + + ) : null} + {user && action === 'wallet' ? ( + + 当前可用余额 {formatMoney(user.available_balance_cent)},冻结余额{' '} + {formatMoney(user.frozen_balance_cent)}。 + + ) : null} + {user && action === 'quota' ? ( + + 等级额度 {formatMoney(user.deposit_free_level_quota_cent)},当前手动额度{' '} + {formatMoney(user.deposit_free_manual_quota_cent)}。 + {user.deposit_free_quota_cent > 0 ? 含历史额度记录 : null} + + ) : null} + + + ) +} diff --git a/frontend/src/features/admin/AdminUsersPage.tsx b/frontend/src/features/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..7b8f5ce --- /dev/null +++ b/frontend/src/features/admin/AdminUsersPage.tsx @@ -0,0 +1,285 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { EyeOutlined, ReloadOutlined } from '@ant-design/icons' + +import { fetchAdminUsers } from '@/features/admin/api' +import { AdminUserDetailDrawer } from '@/features/admin/AdminUserDetailDrawer' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { + readStatusMeta, + realnameStatusMap, + riskStatusMap, + userStatusMap, + userStatusOptions, +} from '@/features/admin/labels' +import type { AdminUserItem, AdminUserQuery } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney } from '@/shared/utils/format' + +const emptyFilters: AdminUserQuery = { + keyword: '', + status: '', + has_deposit_free_quota: false, +} + +function buildColumns( + onOpen: (row: AdminUserItem) => void, + canView: boolean +): TableColumnsType { + return [ + { + title: '用户', + dataIndex: 'phone', + width: 220, + fixed: 'left', + render: (_, row) => ( +
+ {canView ? ( + + ) : ( + {row.nickname || row.phone} + )} + {row.phone} + UID {row.id} +
+ ), + }, + { + title: '实名 / 风险', + key: 'identity', + width: 130, + render: (_, row) => ( +
+ + +
+ ), + }, + { + title: '余额', + key: 'balance', + width: 140, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.available_balance_cent)} + 冻结 {formatMoney(row.frozen_balance_cent)} +
+ ), + }, + { + title: '免押额度', + key: 'deposit_free', + width: 150, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.deposit_free_effective_quota_cent)} + 剩余 {formatMoney(row.deposit_free_remaining_cent)} +
+ ), + }, + { + title: '成长', + key: 'growth', + width: 130, + render: (_, row) => ( +
+ {row.renter_growth_level_name || row.renter_growth_level || '—'} + {row.renter_growth_points.toLocaleString('zh-CN')} 成长值 +
+ ), + }, + { + title: '业务', + key: 'activity', + width: 150, + render: (_, row) => ( + + 订单 {row.order_count} + 商品 {row.listing_count} + 申诉 {row.dispute_count} + + ), + }, + { + title: '账号状态', + dataIndex: 'status', + width: 110, + render: value => , + }, + { + title: '注册时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 96, + fixed: 'right', + render: (_, row) => + canView ? ( + + ) : ( + 无权限 + ), + }, + ] +} + +export function AdminUsersPage() { + const [draft, setDraft] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [detailUser, setDetailUser] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('user:view')) + + const { data, isPending, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['admin-users', applied, page, pageSize], + queryFn: () => fetchAdminUsers({ ...applied, page, page_size: pageSize }), + placeholderData: keepPreviousData, + }) + + const openDetail = (user: AdminUserItem) => { + if (!canView) return + setDetailUser(user) + setDetailOpen(true) + } + + const columns = useMemo(() => buildColumns(openDetail, canView), [canView]) + + const patchFilter = (patch: Partial) => { + setDraft(current => ({ ...current, ...patch })) + } + + const applyFilters = () => { + setApplied({ ...draft, keyword: draft.keyword?.trim() ?? '' }) + setPage(1) + } + + const resetFilters = () => { + setDraft(emptyFilters) + setApplied(emptyFilters) + setPage(1) + } + + const hasActiveFilters = + Boolean(draft.keyword?.trim()) || Boolean(draft.status) || Boolean(draft.has_deposit_free_quota) + const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。' + + return ( +
+ + 共 {data?.total ?? 0} 位用户 + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + } + /> + + + + patchFilter({ keyword: event.target.value })} + /> + + + patchFilter({ has_deposit_free_quota: value === '1' })} + /> + + + + {isError ? ( + void refetch()}> + 重试 + + } + /> + ) : null} + + + title={用户列表} + extra={ + + {data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'} + + } + rowKey="id" + columns={columns} + dataSource={data?.items ?? []} + loading={isFetching} + total={data?.total ?? 0} + page={data?.page ?? page} + pageSize={data?.page_size ?? pageSize} + onPageChange={(nextPage, nextPageSize) => { + setPage(nextPageSize !== pageSize ? 1 : nextPage) + setPageSize(nextPageSize) + }} + onRowClick={canView ? row => openDetail(row) : undefined} + emptyDescription="没有匹配的用户" + /> + + {canView ? ( + setDetailOpen(false)} + onUpdated={updated => { + setDetailUser(updated) + void refetch() + }} + /> + ) : null} +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index bc2da53..07efe08 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -10,6 +10,9 @@ import type { AdminOrderPage, AdminOrderQuery, AdminUser, + AdminUserItem, + AdminUserPage, + AdminUserQuery, DashboardData, } from '@/features/admin/types' @@ -130,3 +133,87 @@ export async function markAbnormalAdminListing(id: number | string, reason: stri ) return data.data } + +export async function fetchAdminUsers(query: AdminUserQuery) { + const params: Record = { ...compactParams(query) } + if (query.has_deposit_free_quota) { + params.has_deposit_free_quota = true + } else { + delete params.has_deposit_free_quota + } + const { data } = await adminClient.get>>('/admin/users', { + params, + }) + 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 freezeAdminUser(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/users/${id}/freeze`, + { reason } + ) + return data.data +} + +export async function unfreezeAdminUser(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/users/${id}/unfreeze`, + { reason } + ) + return data.data +} + +export async function setAdminUserDepositFreeQuota(id: number | string, amountCent: number) { + const { data } = await adminClient.post>( + `/admin/users/${id}/deposit-free-quota`, + { amount_cent: amountCent } + ) + return data.data +} + +export async function adjustAdminUserGrowthPoints( + id: number | string, + targetPoints: number, + reason: string +) { + const { data } = await adminClient.post>( + `/admin/users/${id}/growth-points`, + { target_points: targetPoints, reason } + ) + return data.data +} + +export async function manualAdminUserRealname( + id: number | string, + payload: { name: string; id_no: string; reason: string } +) { + const { data } = await adminClient.post>( + `/admin/users/${id}/manual-realname`, + payload + ) + return data.data +} + +export async function revokeAdminUserRealname(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/users/${id}/revoke-realname`, + { reason } + ) + return data.data +} + +export async function adjustAdminUserWallet( + id: number | string, + payload: { direction: 'in' | 'out'; amount_cent: number; reason: string; reference_no?: string } +) { + const { data } = await adminClient.post>( + `/admin/users/${id}/wallet/adjust`, + payload + ) + return data.data +} diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts index f24f0be..d6c344a 100644 --- a/frontend/src/features/admin/labels.ts +++ b/frontend/src/features/admin/labels.ts @@ -155,3 +155,27 @@ export const offlineSettlementStatusOptions = ['pending', 'settled'].map(value = value, label: offlineSettlementStatusMap[value].label, })) + +export const userStatusMap: Record = { + active: { label: '正常', tone: 'success' }, + frozen: { label: '已冻结', tone: 'red' }, + disabled: { label: '已禁用', tone: 'default' }, +} + +export const realnameStatusMap: Record = { + unverified: { label: '未实名', tone: 'default' }, + pending: { label: '认证中', tone: 'gold' }, + verified: { label: '已实名', tone: 'success' }, + rejected: { label: '认证失败', tone: 'red' }, +} + +export const riskStatusMap: Record = { + normal: { label: '正常', tone: 'success' }, + watch: { label: '观察', tone: 'gold' }, + restricted: { label: '受限', tone: 'orange' }, + blocked: { label: '已拦截', tone: 'red' }, +} + +export const userStatusOptions = toOptions(userStatusMap) +export const realnameStatusOptions = toOptions(realnameStatusMap) +export const riskStatusOptions = toOptions(riskStatusMap) diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 96ad01f..af2b500 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -262,3 +262,45 @@ export interface AdminListingPage { page: number page_size: number } + +export interface AdminUserQuery { + page?: number + page_size?: number + keyword?: string + status?: string + has_deposit_free_quota?: boolean +} + +export interface AdminUserItem { + id: number + phone: string + nickname: string + realname_status: string + risk_status: string + credit_score: number + deposit_free_quota_cent: number + deposit_free_manual_quota_cent: number + deposit_free_level_quota_cent: number + deposit_free_effective_quota_cent: number + deposit_free_used_cent: number + deposit_free_remaining_cent: number + renter_growth_points: number + renter_growth_level: string + renter_growth_level_name: string + available_balance_cent: number + frozen_balance_cent: number + status: string + order_count: number + listing_count: number + dispute_count: number + last_login_at?: string | null + created_at: string + updated_at: string +} + +export interface AdminUserPage { + items: AdminUserItem[] + total: number + page: number + page_size: number +}