增加打手列表筛选汇总

This commit is contained in:
yml2213
2026-09-06 12:55:58 +08:00
parent 4c6c53d818
commit fe2a28454d
6 changed files with 268 additions and 98 deletions
@@ -44,6 +44,18 @@ export type WorkerUserRow = {
last_renamed_at?: string | null last_renamed_at?: string | null
} }
export type WorkerUserStatistics = {
totalCount: number
statusCounts: Record<string, number>
workerTypeCounts: Record<string, number>
availableAmount: number
frozenDepositAmount: number
pendingUnfreezeAmount: number
totalCreditedAmount: number
totalSettledAmount: number
timeoutOrderCount: number
}
export type WorkerSessionRow = { export type WorkerSessionRow = {
id: number id: number
session_id: string session_id: string
@@ -1,6 +1,6 @@
import { query, withTransaction } from '../../db/client.js' import { query, withTransaction } from '../../db/client.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import type { CreateWorkerInput, ListInput, WorkerUserRow } from './types.js' import type { CreateWorkerInput, ListInput, WorkerUserRow, WorkerUserStatistics } from './types.js'
import type { PoolClient } from 'pg' import type { PoolClient } from 'pg'
const WORKER_USER_SELECT = ` const WORKER_USER_SELECT = `
@@ -265,6 +265,75 @@ export async function listWorkerUsers({
return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) }
} }
/** 按筛选条件汇总打手,统计范围不受当前分页影响。 */
export async function getWorkerUserStatistics(
input: Pick<ListInput, 'status' | 'keyword' | 'workerType' | 'workerId' | 'levelId'> = {},
): Promise<WorkerUserStatistics> {
const { whereClause, params } = buildWorkerUserWhere(input)
const statisticsResult = await query<{
status: string
worker_type: string
total_count: number
available_amount: number
frozen_deposit_amount: number
pending_unfreeze_amount: number
total_credited_amount: number
total_settled_amount: number
}>(
`
SELECT
wu.status,
wu.worker_type,
COUNT(*)::int AS total_count,
COALESCE(SUM(COALESCE(ww.available_amount, 0)), 0)::bigint AS available_amount,
COALESCE(SUM(COALESCE(ww.frozen_deposit_amount, 0)), 0)::bigint AS frozen_deposit_amount,
COALESCE(SUM(COALESCE(ww.pending_unfreeze_amount, 0)), 0)::bigint AS pending_unfreeze_amount,
COALESCE(SUM(COALESCE(ww.total_credited_amount, 0)), 0)::bigint AS total_credited_amount,
COALESCE(SUM(COALESCE(ww.total_settled_amount, 0)), 0)::bigint AS total_settled_amount
FROM worker_users wu
LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id
${whereClause}
GROUP BY wu.status, wu.worker_type
`,
params,
)
const timeoutResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM work_order_events e
INNER JOIN worker_users wu ON e.payload_json->>'workerId' = wu.id::text
${whereClause ? `${whereClause} AND` : 'WHERE'} e.event_type LIKE 'timeout_%'
`,
params,
)
const statistics: WorkerUserStatistics = {
totalCount: 0,
statusCounts: {},
workerTypeCounts: {},
availableAmount: 0,
frozenDepositAmount: 0,
pendingUnfreezeAmount: 0,
totalCreditedAmount: 0,
totalSettledAmount: 0,
timeoutOrderCount: Number(timeoutResult.rows[0]?.total || 0),
}
for (const row of statisticsResult.rows) {
const count = Number(row.total_count || 0)
statistics.totalCount += count
statistics.statusCounts[row.status] = (statistics.statusCounts[row.status] || 0) + count
statistics.workerTypeCounts[row.worker_type] =
(statistics.workerTypeCounts[row.worker_type] || 0) + count
statistics.availableAmount += Number(row.available_amount || 0)
statistics.frozenDepositAmount += Number(row.frozen_deposit_amount || 0)
statistics.pendingUnfreezeAmount += Number(row.pending_unfreeze_amount || 0)
statistics.totalCreditedAmount += Number(row.total_credited_amount || 0)
statistics.totalSettledAmount += Number(row.total_settled_amount || 0)
}
return statistics
}
export { export {
addWorkerWalletCredit, addWorkerWalletCredit,
ensureWorkerWallet, ensureWorkerWallet,
@@ -1,5 +1,6 @@
import { import {
countTimeoutEventsByWorkerIds, countTimeoutEventsByWorkerIds,
getWorkerUserStatistics,
getWorkerLevelById, getWorkerLevelById,
listWorkerUsers, listWorkerUsers,
revokeAllWorkerSessions, revokeAllWorkerSessions,
@@ -14,6 +15,7 @@ import {
hashWorkerPassword, hashWorkerPassword,
mapWorkerUser, mapWorkerUser,
normalizeAmountFen, normalizeAmountFen,
normalizeBoolean,
normalizeOptionalId, normalizeOptionalId,
normalizeReviewStatus, normalizeReviewStatus,
normalizeSessionVersion, normalizeSessionVersion,
@@ -29,15 +31,21 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
const pageSize = normalizePageSize(query.pageSize) const pageSize = normalizePageSize(query.pageSize)
const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0 const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0
const levelId = normalizeOptionalId(query.levelId ?? query.level_id) || 0 const levelId = normalizeOptionalId(query.levelId ?? query.level_id) || 0
const { items, total } = await listWorkerUsers({ const filters = {
page,
pageSize,
workerId, workerId,
levelId, levelId,
status: String(query.status || '').trim(), status: String(query.status || '').trim(),
keyword: String(query.keyword || '').trim(), keyword: String(query.keyword || '').trim(),
workerType: String(query.workerType || query.worker_type || '').trim(), workerType: String(query.workerType || query.worker_type || '').trim(),
}) }
const includeStatistics = normalizeBoolean(
query.includeStatistics ?? query.include_statistics,
false,
)
const [{ items, total }, statistics] = await Promise.all([
listWorkerUsers({ page, pageSize, ...filters }),
includeStatistics ? getWorkerUserStatistics(filters) : Promise.resolve(undefined),
])
const timeoutCounts = await countTimeoutEventsByWorkerIds(items.map((item) => Number(item.id))) const timeoutCounts = await countTimeoutEventsByWorkerIds(items.map((item) => Number(item.id)))
return { return {
items: items.map((item) => ({ items: items.map((item) => ({
@@ -45,6 +53,7 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
timeoutOrderCount: timeoutCounts.get(Number(item.id)) || 0, timeoutOrderCount: timeoutCounts.get(Number(item.id)) || 0,
})), })),
pagination: { page, pageSize, total }, pagination: { page, pageSize, total },
...(statistics ? { statistics } : {}),
} }
} }
@@ -33,6 +33,7 @@ import type {
AdminWorkerWithdrawalAccount, AdminWorkerWithdrawalAccount,
UploadedFile, UploadedFile,
WorkerUser, WorkerUser,
WorkerUserStatistics,
} from '@/types/worker-platform' } from '@/types/worker-platform'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import WorkerDetailDrawer from './WorkerDetailDrawer' import WorkerDetailDrawer from './WorkerDetailDrawer'
@@ -54,6 +55,62 @@ type PersonalDepositFreeAmountFormValues = {
depositFreeAmount?: number depositFreeAmount?: number
} }
const WORKER_STATISTICS_STATUS_ORDER = ['pending_review', 'active', 'rejected', 'disabled']
const WORKER_STATISTICS_TYPE_ORDER = ['internal', 'external']
function WorkerStatisticsBar({ statistics }: { statistics?: WorkerUserStatistics }) {
if (!statistics) return null
const statusItems = WORKER_STATISTICS_STATUS_ORDER.map((status) => ({
status,
count: Number(statistics.statusCounts?.[status] || 0),
}))
const otherStatusItems = Object.entries(statistics.statusCounts || {})
.filter(([status]) => !WORKER_STATISTICS_STATUS_ORDER.includes(status))
.map(([status, count]) => ({ status, count: Number(count || 0) }))
const typeItems = WORKER_STATISTICS_TYPE_ORDER.map((workerType) => ({
workerType,
count: Number(statistics.workerTypeCounts?.[workerType] || 0),
}))
const otherTypeItems = Object.entries(statistics.workerTypeCounts || {})
.filter(([workerType]) => !WORKER_STATISTICS_TYPE_ORDER.includes(workerType))
.map(([workerType, count]) => ({ workerType, count: Number(count || 0) }))
const workerTypeLabels: Record<string, string> = {
internal: '内部打手',
external: '外部打手',
}
return (
<Card size="small" bordered={false} className="worker-orders-statistics-card">
<div className="worker-orders-statistics-summary">
本次筛选统计共 <strong>{statistics.totalCount}</strong> 名,余额合计{' '}
<strong>{formatMoney(statistics.availableAmount)}</strong>,冻结押金合计{' '}
<strong>{formatMoney(statistics.frozenDepositAmount)}</strong>,待解冻合计{' '}
<strong>{formatMoney(statistics.pendingUnfreezeAmount)}</strong>,累计结算{' '}
<strong>{formatMoney(statistics.totalSettledAmount)}</strong>,累计充值{' '}
<strong>{formatMoney(statistics.totalCreditedAmount)}</strong>。
</div>
<div className="worker-orders-statistics-statuses">
其中:
{[...statusItems, ...otherStatusItems].map(({ status, count }, index) => (
<span key={`status-${status}`}>
{index > 0 ? ',' : ''}
{formatWorkerStatus(status)}
{count}名
</span>
))}
{[...typeItems, ...otherTypeItems].map(({ workerType, count }, index) => (
<span key={`type-${workerType}`}>
{index === 0 ? ',' : ''}
{workerTypeLabels[workerType] || workerType}
{count}名
</span>
))}
<span>,超时订单{statistics.timeoutOrderCount}单</span>
</div>
</Card>
)
}
export default function WorkersPanel() { export default function WorkersPanel() {
const { message, modal } = App.useApp() const { message, modal } = App.useApp()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -123,6 +180,7 @@ export default function WorkersPanel() {
workerType, workerType,
levelId: levelId || undefined, levelId: levelId || undefined,
workerId: targetWorkerId || undefined, workerId: targetWorkerId || undefined,
includeStatistics: true,
page, page,
pageSize, pageSize,
}), }),
@@ -491,97 +549,102 @@ export default function WorkersPanel() {
] ]
return ( return (
<Card <section className="platform-panel-stack">
title="打手" <Card
extra={ title="打手"
<Space wrap className="filter-form"> extra={
<Input <Space wrap className="filter-form">
allowClear <Input
value={keywordInput} allowClear
placeholder="账号 / 昵称 / 手机号" value={keywordInput}
style={{ width: 220 }} placeholder="账号 / 昵称 / 手机号"
onChange={(event) => setKeywordInput(event.target.value)} style={{ width: 220 }}
onPressEnter={searchWorkers} onChange={(event) => setKeywordInput(event.target.value)}
/> onPressEnter={searchWorkers}
<Select />
allowClear <Select
value={status} allowClear
style={{ width: 150 }} value={status}
options={[ style={{ width: 150 }}
{ value: '', label: '全部状态' }, options={[
{ value: 'pending_review', label: '待处理' }, { value: '', label: '全部状态' },
{ value: 'active', label: '已启用' }, { value: 'pending_review', label: '待处理' },
{ value: 'rejected', label: '已冻结' }, { value: 'active', label: '已启用' },
{ value: 'disabled', label: '已停用' }, { value: 'rejected', label: '已冻结' },
]} { value: 'disabled', label: '已停用' },
onChange={(nextStatus) => { ]}
setStatus(nextStatus || '') onChange={(nextStatus) => {
setPage(1) setStatus(nextStatus || '')
}} setPage(1)
/> }}
<Select />
allowClear <Select
value={workerType || undefined} allowClear
style={{ width: 140 }} value={workerType || undefined}
placeholder="全部类型" style={{ width: 140 }}
options={[ placeholder="全部类型"
{ value: 'external', label: '外部打手' }, options={[
{ value: 'internal', label: '内部打手' }, { value: 'external', label: '外部打手' },
]} { value: 'internal', label: '内部打手' },
onChange={(nextType) => { ]}
setWorkerType(nextType || '') onChange={(nextType) => {
setPage(1) setWorkerType(nextType || '')
}} setPage(1)
/> }}
<Select />
allowClear <Select
value={levelId || undefined} allowClear
style={{ width: 140 }} value={levelId || undefined}
placeholder="全部等级" style={{ width: 140 }}
loading={levelsQuery.isLoading} placeholder="全部等级"
options={(levelsQuery.data?.data.items || []).map((item) => ({ loading={levelsQuery.isLoading}
value: item.levelId, options={(levelsQuery.data?.data.items || []).map((item) => ({
label: item.name, value: item.levelId,
}))} label: item.name,
onChange={(nextLevelId) => { }))}
setLevelId(nextLevelId || 0) onChange={(nextLevelId) => {
setPage(1) setLevelId(nextLevelId || 0)
}} setPage(1)
/> }}
<Button type="primary" icon={<SearchOutlined />} onClick={searchWorkers}> />
查询 <Button type="primary" icon={<SearchOutlined />} onClick={searchWorkers}>
</Button> 查询
<Button onClick={clearWorkerFilters}>清空筛选</Button> </Button>
<Button <Button onClick={clearWorkerFilters}>清空筛选</Button>
icon={<ReloadOutlined />} <Button
loading={workersQuery.isFetching} icon={<ReloadOutlined />}
onClick={() => workersQuery.refetch()} loading={workersQuery.isFetching}
> onClick={() => workersQuery.refetch()}
刷新 >
</Button> 刷新
</Space> </Button>
} </Space>
bordered={false} }
> bordered={false}
{targetWorkerId ? ( >
<Typography.Text type="secondary">已定位打手 #{targetWorkerId}</Typography.Text> {targetWorkerId ? (
) : null} <Typography.Text type="secondary">已定位打手 #{targetWorkerId}</Typography.Text>
<Table<WorkerUser> ) : null}
rowKey="workerId" </Card>
loading={workersQuery.isLoading} <WorkerStatisticsBar statistics={workersQuery.data?.data.statistics} />
dataSource={workersQuery.data?.data.items || []} <Card title="打手列表" bordered={false}>
columns={columns} <Table<WorkerUser>
pagination={buildAdminTablePagination({ rowKey="workerId"
current: workersPagination?.page || page, loading={workersQuery.isLoading}
pageSize: workersPagination?.pageSize || pageSize, dataSource={workersQuery.data?.data.items || []}
total: workersPagination?.total || 0, columns={columns}
onChange: (nextPage, nextPageSize) => { pagination={buildAdminTablePagination({
setPage(nextPage) current: workersPagination?.page || page,
setPageSize(nextPageSize) pageSize: workersPagination?.pageSize || pageSize,
}, total: workersPagination?.total || 0,
})} onChange: (nextPage, nextPageSize) => {
scroll={{ x: 1000 }} setPage(nextPage)
/> setPageSize(nextPageSize)
},
})}
scroll={{ x: 1000 }}
/>
</Card>
<Modal <Modal
title="冻结打手账号" title="冻结打手账号"
@@ -812,6 +875,6 @@ export default function WorkersPanel() {
</Modal> </Modal>
<WorkerDetailDrawer worker={detailWorker} onClose={() => setDetailWorker(null)} /> <WorkerDetailDrawer worker={detailWorker} onClose={() => setDetailWorker(null)} />
</Card> </section>
) )
} }
@@ -25,6 +25,7 @@ import type {
WorkerAnnouncementConfig, WorkerAnnouncementConfig,
WorkerLevel, WorkerLevel,
WorkerListResponse, WorkerListResponse,
WorkerUserListResponse,
WorkerUser, WorkerUser,
WorkerWalletLedger, WorkerWalletLedger,
} from '@/types/worker-platform' } from '@/types/worker-platform'
@@ -173,7 +174,7 @@ export function saveAdminWorkerLevel(payload: {
} }
export function fetchAdminWorkerUsers(params?: Record<string, unknown>) { export function fetchAdminWorkerUsers(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkerUser>>('/api/v1/admin/worker-platform/workers', params) return apiGet<WorkerUserListResponse>('/api/v1/admin/worker-platform/workers', params)
} }
export function fetchAdminWorkerDetail(workerId: number | string) { export function fetchAdminWorkerDetail(workerId: number | string) {
@@ -50,6 +50,18 @@ export type WorkerUser = {
renameUnlockedAt: string | null renameUnlockedAt: string | null
} }
export type WorkerUserStatistics = {
totalCount: number
statusCounts: Record<string, number>
workerTypeCounts: Record<string, number>
availableAmount: number
frozenDepositAmount: number
pendingUnfreezeAmount: number
totalCreditedAmount: number
totalSettledAmount: number
timeoutOrderCount: number
}
export type WorkerSessionDevice = { export type WorkerSessionDevice = {
sessionId: string sessionId: string
deviceId: string deviceId: string
@@ -677,6 +689,10 @@ export type WorkerListResponse<T> = {
pagination: Pagination pagination: Pagination
} }
export type WorkerUserListResponse = WorkerListResponse<WorkerUser> & {
statistics?: WorkerUserStatistics
}
export type WorkerOrderOverviewItem = { export type WorkerOrderOverviewItem = {
status: string status: string
orderCount: number orderCount: number