feat(admin): 新增后台用户列表与详情操作

This commit is contained in:
yml2213
2026-10-30 13:06:45 +08:00
parent cd66bca9d5
commit 3297fd69ff
10 changed files with 1054 additions and 4 deletions
+2 -2
View File
@@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
+82
View File
@@ -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. 移动端下单、支付与交接/归还/结账。
+1 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与商品审核;退款、提现、用户、申诉与系统配置待迁移。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作;退款、提现、申诉与系统配置待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+6 -1
View File
@@ -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() {
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="listings" element={<AdminListingsPage />} />
<Route path="listings/review" element={<AdminListingsPage mode="review" />} />
<Route path="users" element={<AdminUsersPage />} />
{adminSections.map(section => (
<Route
key={section.path}
@@ -2,6 +2,7 @@ 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 { useSearchParams } from 'react-router-dom'
import { fetchAdminListings, fetchAdminPendingListings } from '@/features/admin/api'
import { AdminListingDetailDrawer } from '@/features/admin/AdminListingDetailDrawer'
@@ -129,12 +130,19 @@ function buildColumns(
export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
const isReviewMode = mode === 'review'
const [searchParams] = useSearchParams()
const initialOwnerId = Number(searchParams.get('owner_id')) || undefined
const initialKeyword = searchParams.get('keyword')?.trim() ?? ''
const [draft, setDraft] = useState<AdminListingQuery>(() => ({
...emptyFilters,
keyword: initialKeyword,
owner_id: initialOwnerId,
review_status: isReviewMode ? 'pending' : '',
}))
const [applied, setApplied] = useState<AdminListingQuery>(() => ({
...emptyFilters,
keyword: initialKeyword,
owner_id: initialOwnerId,
review_status: isReviewMode ? 'pending' : '',
}))
const [page, setPage] = useState(1)
@@ -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<UserAction, { title: string; okText: string; hint: string }> = {
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: <span className="admin-mono">UID {user.id}</span> },
{ key: 'phone', label: '手机号', value: formatText(user.phone) },
{ key: 'nickname', label: '昵称', value: formatText(user.nickname) },
{
key: 'status',
label: '账号状态',
value: <StatusTag meta={readStatusMeta(userStatusMap, user.status)} />,
},
{
key: 'realname',
label: '实名状态',
value: <StatusTag meta={readStatusMeta(realnameStatusMap, user.realname_status)} />,
},
{
key: 'risk',
label: '风险状态',
value: <StatusTag meta={readStatusMeta(riskStatusMap, user.risk_status)} />,
},
{ 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<UserAction | null>(null)
const [reason, setReason] = useState('')
const [amountYuan, setAmountYuan] = useState<number | null>(null)
const [targetPoints, setTargetPoints] = useState<number | null>(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 (
<>
<DetailDrawer
open={open}
onClose={onClose}
title={user ? user.nickname || user.phone : '用户详情'}
subtitle={
user ? (
<span>
{user.phone} · <span className="admin-mono">UID {user.id}</span>
</span>
) : null
}
tags={
user ? (
<Space size={6} wrap>
<StatusTag meta={readStatusMeta(userStatusMap, user.status)} />
<StatusTag meta={readStatusMeta(realnameStatusMap, user.realname_status)} />
<StatusTag meta={readStatusMeta(riskStatusMap, user.risk_status)} />
</Space>
) : null
}
sections={user ? buildSections(user) : []}
emptyDescription="请选择一个用户"
footer={
user ? (
<Space wrap>
<Button
size="small"
onClick={() => navigate(`/orders?user_id=${user.id}`)}
disabled={!can('order:list')}
>
订单明细
</Button>
<Button
size="small"
onClick={() => navigate(`/listings?owner_id=${user.id}`)}
disabled={!can('listing:view')}
>
商品明细
</Button>
{user.status === 'active' && can('user:freeze') ? (
<Button size="small" danger onClick={() => openAction('freeze')}>
冻结
</Button>
) : null}
{user.status === 'frozen' && can('user:unfreeze') ? (
<Button size="small" onClick={() => openAction('unfreeze')}>
解冻
</Button>
) : null}
{can('user:deposit_free') ? (
<Button size="small" onClick={() => openAction('quota')}>
免押额度
</Button>
) : null}
{can('user:growth_points') ? (
<Button size="small" onClick={() => openAction('growth')}>
成长值
</Button>
) : null}
{!isVerified && can('user:manual_realname') ? (
<Button size="small" onClick={() => openAction('manual-realname')}>
实名补录
</Button>
) : null}
{isVerified && can('user:revoke_realname') ? (
<Button size="small" onClick={() => openAction('revoke-realname')}>
撤销实名
</Button>
) : null}
{can('user:wallet_adjust') ? (
<Button size="small" onClick={() => openAction('wallet')}>
调整余额
</Button>
) : null}
<Button size="small" onClick={onClose}>
关闭
</Button>
</Space>
) : (
<Button onClick={onClose}>关闭</Button>
)
}
/>
<Modal
open={action !== null}
title={copy?.title}
okText={copy?.okText}
cancelText="取消"
confirmLoading={submitting}
onOk={() => void submitAction()}
onCancel={closeAction}
>
<Typography.Paragraph type="secondary">{copy?.hint}</Typography.Paragraph>
{action === 'quota' ? (
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
value={amountYuan}
addonBefore="¥"
placeholder="手动免押额度"
onChange={value => setAmountYuan(typeof value === 'number' ? value : null)}
/>
) : null}
{action === 'growth' ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={0}
value={targetPoints}
addonAfter="成长值"
onChange={value => setTargetPoints(typeof value === 'number' ? value : null)}
/>
<Input.TextArea
rows={3}
value={reason}
maxLength={200}
showCount
placeholder="调整原因(必填)"
onChange={event => setReason(event.target.value)}
/>
</Space>
) : null}
{action === 'manual-realname' ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
value={realnameName}
placeholder="真实姓名"
onChange={event => setRealnameName(event.target.value)}
/>
<Input
value={realnameIDNo}
placeholder="身份证号"
onChange={event => setRealnameIDNo(event.target.value)}
/>
<Input.TextArea
rows={2}
value={reason}
maxLength={200}
placeholder="补录原因(必填)"
onChange={event => setReason(event.target.value)}
/>
</Space>
) : null}
{action === 'wallet' ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Select
value={walletDirection}
style={{ width: '100%' }}
options={[
{ value: 'in', label: '加款' },
{ value: 'out', label: '扣款' },
]}
onChange={value => setWalletDirection(value)}
/>
<InputNumber
style={{ width: '100%' }}
min={0.01}
precision={2}
value={amountYuan}
addonBefore="¥"
placeholder="调账金额"
onChange={value => setAmountYuan(typeof value === 'number' ? value : null)}
/>
<Input.TextArea
rows={2}
value={reason}
maxLength={200}
placeholder="调账原因(必填)"
onChange={event => setReason(event.target.value)}
/>
<Input
value={walletReference}
placeholder="外部流水号(可选)"
onChange={event => setWalletReference(event.target.value)}
/>
</Space>
) : null}
{['freeze', 'unfreeze', 'revoke-realname'].includes(action ?? '') ? (
<Input.TextArea
rows={3}
value={reason}
maxLength={200}
showCount
placeholder="请填写原因(必填)"
onChange={event => setReason(event.target.value)}
/>
) : null}
{user && action === 'freeze' ? (
<Typography.Paragraph style={{ marginTop: 12, marginBottom: 0 }} type="secondary">
冻结对象:{user.nickname || user.phone}(UID {user.id})
</Typography.Paragraph>
) : null}
{user && action === 'wallet' ? (
<Typography.Paragraph style={{ marginTop: 12, marginBottom: 0 }} type="secondary">
当前可用余额 {formatMoney(user.available_balance_cent)},冻结余额{' '}
{formatMoney(user.frozen_balance_cent)}。
</Typography.Paragraph>
) : null}
{user && action === 'quota' ? (
<Typography.Paragraph style={{ marginTop: 12, marginBottom: 0 }} type="secondary">
等级额度 {formatMoney(user.deposit_free_level_quota_cent)},当前手动额度{' '}
{formatMoney(user.deposit_free_manual_quota_cent)}。
{user.deposit_free_quota_cent > 0 ? <Tag color="gold">含历史额度记录</Tag> : null}
</Typography.Paragraph>
) : null}
</Modal>
</>
)
}
@@ -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<AdminUserItem> {
return [
{
title: '用户',
dataIndex: 'phone',
width: 220,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canView ? (
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
{row.nickname || row.phone}
</button>
) : (
<strong>{row.nickname || row.phone}</strong>
)}
<span>{row.phone}</span>
<span className="admin-mono">UID {row.id}</span>
</div>
),
},
{
title: '实名 / 风险',
key: 'identity',
width: 130,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={readStatusMeta(realnameStatusMap, row.realname_status)} />
<StatusTag meta={readStatusMeta(riskStatusMap, row.risk_status)} />
</div>
),
},
{
title: '余额',
key: 'balance',
width: 140,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.available_balance_cent)}</strong>
<span>冻结 {formatMoney(row.frozen_balance_cent)}</span>
</div>
),
},
{
title: '免押额度',
key: 'deposit_free',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.deposit_free_effective_quota_cent)}</strong>
<span>剩余 {formatMoney(row.deposit_free_remaining_cent)}</span>
</div>
),
},
{
title: '成长',
key: 'growth',
width: 130,
render: (_, row) => (
<div className="admin-stack">
<span>{row.renter_growth_level_name || row.renter_growth_level || '—'}</span>
<span>{row.renter_growth_points.toLocaleString('zh-CN')} 成长值</span>
</div>
),
},
{
title: '业务',
key: 'activity',
width: 150,
render: (_, row) => (
<Space size={4} wrap>
<Tag>订单 {row.order_count}</Tag>
<Tag>商品 {row.listing_count}</Tag>
<Tag>申诉 {row.dispute_count}</Tag>
</Space>
),
},
{
title: '账号状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(userStatusMap, value)} />,
},
{
title: '注册时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 96,
fixed: 'right',
render: (_, row) =>
canView ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
详情
</Button>
) : (
<Typography.Text type="secondary">无权限</Typography.Text>
),
},
]
}
export function AdminUsersPage() {
const [draft, setDraft] = useState<AdminUserQuery>(emptyFilters)
const [applied, setApplied] = useState<AdminUserQuery>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailUser, setDetailUser] = useState<AdminUserItem | null>(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<AdminUserQuery>) => {
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 用户"
title="用户管理"
description="查看用户实名、风险、钱包、免押额度与交易活跃度;点击昵称查看详情。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 位用户</Tag>
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新数据
</Button>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="关键词" width={300}>
<Input
allowClear
value={draft.keyword}
placeholder="手机号 / 昵称 / 用户 ID"
onChange={event => patchFilter({ keyword: event.target.value })}
/>
</FilterField>
<FilterField label="账号状态" width={150}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={userStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
<FilterField label="免押额度" width={160}>
<Select
allowClear
value={draft.has_deposit_free_quota ? '1' : undefined}
placeholder="全部用户"
options={[{ value: '1', label: '有免押额度' }]}
onChange={value => patchFilter({ has_deposit_free_quota: value === '1' })}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="用户列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminUserItem>
title={<span>用户列表</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
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 ? (
<AdminUserDetailDrawer
user={detailUser}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onUpdated={updated => {
setDetailUser(updated)
void refetch()
}}
/>
) : null}
</div>
)
}
+87
View File
@@ -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<string, unknown> = { ...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<ApiResponse<Partial<AdminUserPage>>>('/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<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/freeze`,
{ reason }
)
return data.data
}
export async function unfreezeAdminUser(id: number | string, reason: string) {
const { data } = await adminClient.post<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/unfreeze`,
{ reason }
)
return data.data
}
export async function setAdminUserDepositFreeQuota(id: number | string, amountCent: number) {
const { data } = await adminClient.post<ApiResponse<AdminUserItem>>(
`/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<ApiResponse<AdminUserItem>>(
`/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<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/manual-realname`,
payload
)
return data.data
}
export async function revokeAdminUserRealname(id: number | string, reason: string) {
const { data } = await adminClient.post<ApiResponse<AdminUserItem>>(
`/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<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/wallet/adjust`,
payload
)
return data.data
}
+24
View File
@@ -155,3 +155,27 @@ export const offlineSettlementStatusOptions = ['pending', 'settled'].map(value =
value,
label: offlineSettlementStatusMap[value].label,
}))
export const userStatusMap: Record<string, StatusMeta> = {
active: { label: '正常', tone: 'success' },
frozen: { label: '已冻结', tone: 'red' },
disabled: { label: '已禁用', tone: 'default' },
}
export const realnameStatusMap: Record<string, StatusMeta> = {
unverified: { label: '未实名', tone: 'default' },
pending: { label: '认证中', tone: 'gold' },
verified: { label: '已实名', tone: 'success' },
rejected: { label: '认证失败', tone: 'red' },
}
export const riskStatusMap: Record<string, StatusMeta> = {
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)
+42
View File
@@ -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
}