优化后端-接单平台部分逻辑

This commit is contained in:
yml2213
2026-07-28 14:06:31 +08:00
parent b505b6b85e
commit 6e635b2499
9 changed files with 1389 additions and 20 deletions
@@ -2,6 +2,7 @@ export const APP_CONFIG_KEYS = {
notification: 'notification',
scheduledJobs: 'scheduled_jobs',
fulfillmentRouting: 'fulfillment_routing',
workerFinance: 'worker_finance',
cloudtentaclesSources: 'cloudtentacles_sources',
cloudtentaclesSession: 'cloudtentacles_session',
cloudtentaclesOverrideRules: 'cloudtentacles_override_rules',
@@ -74,6 +74,9 @@ export type WorkerFinanceRequestRow = {
created_at: string
updated_at: string
reviewed_at: string | null
worker_username?: string
worker_display_name?: string
worker_phone?: string
}
export type WorkCategoryRow = {
@@ -179,6 +182,7 @@ type FinanceRequestListInput = {
workerId?: number
status?: string
requestType?: string
keyword?: string
}
type CreateWorkerInput = {
@@ -242,6 +246,16 @@ const WORK_PRODUCT_RULE_SELECT = `
LEFT JOIN work_categories wc ON wc.id = wpr.category_id
`
const WORKER_FINANCE_REQUEST_SELECT = `
SELECT
wfr.*,
wu.username AS worker_username,
wu.display_name AS worker_display_name,
wu.phone AS worker_phone
FROM worker_finance_requests wfr
LEFT JOIN worker_users wu ON wu.id = wfr.worker_id
`
export async function getWorkerLevelByKey(levelKey: string): Promise<WorkerLevelRow | null> {
const result = await query<WorkerLevelRow>(
'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1',
@@ -580,32 +594,43 @@ export async function createWorkerFinanceRequest(input: {
return result.rows[0] || null
}
export async function getWorkerFinanceRequestById(
requestId: number | string,
): Promise<WorkerFinanceRequestRow | null> {
const result = await query<WorkerFinanceRequestRow>(
`${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`,
[Number(requestId)],
)
return result.rows[0] || null
}
export async function listWorkerFinanceRequests({
page = 1,
pageSize = 20,
workerId = 0,
status = '',
requestType = '',
keyword = '',
}: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> {
const { whereClause, params } = buildWorkerFinanceRequestWhere({
workerId,
status,
requestType,
keyword,
})
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr ${whereClause}`,
`SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr
LEFT JOIN worker_users wu ON wu.id = wfr.worker_id
${whereClause}`,
params,
)
const offset = (page - 1) * pageSize
params.push(pageSize, offset)
const itemsResult = await query<WorkerFinanceRequestRow>(
`
SELECT *
FROM worker_finance_requests wfr
${whereClause}
ORDER BY wfr.created_at DESC, wfr.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
`${WORKER_FINANCE_REQUEST_SELECT}
${whereClause}
ORDER BY wfr.created_at DESC, wfr.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
)
return {
@@ -614,6 +639,146 @@ export async function listWorkerFinanceRequests({
}
}
export async function reviewWorkerFinanceRequest(input: {
requestId: number
status: string
reviewedNote: string
now: string
}): Promise<{
request: WorkerFinanceRequestRow | null
failureReason: 'request_not_pending' | 'withdraw_insufficient' | null
}> {
return withTransaction(async (client) => {
const currentResult = await client.query<WorkerFinanceRequestRow>(
`
SELECT *
FROM worker_finance_requests
WHERE id = $1
LIMIT 1
FOR UPDATE
`,
[input.requestId],
)
const current = currentResult.rows[0] || null
if (!current) {
return {
request: null,
failureReason: null,
}
}
if (current.status !== 'pending') {
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: 'request_not_pending',
}
}
if (input.status === 'approved') {
await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now)
const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id))
const availableAmount = Number(wallet?.available_amount || 0)
const frozenAmount = Number(wallet?.frozen_deposit_amount || 0)
const amount = Number(current.amount || 0)
const note =
input.reviewedNote ||
(current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过')
if (current.request_type === 'withdraw') {
if (availableAmount < amount) {
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: 'withdraw_insufficient',
}
}
const nextAvailable = availableAmount - amount
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, updated_at = $2
WHERE worker_id = $3
`,
[nextAvailable, input.now, Number(current.worker_id)],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
audit_status, note, payload_json, created_at
) VALUES ($1, 'withdraw_paid', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
`,
[
Number(current.worker_id),
-amount,
nextAvailable,
frozenAmount,
note,
JSON.stringify({
requestId: Number(current.id),
requestType: current.request_type,
source: 'admin_finance_review',
}),
input.now,
],
)
} else {
const nextAvailable = availableAmount + amount
await client.query(
`
UPDATE worker_wallets
SET
available_amount = $1,
total_credited_amount = total_credited_amount + $2,
updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, amount, input.now, Number(current.worker_id)],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
audit_status, note, payload_json, created_at
) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
`,
[
Number(current.worker_id),
amount,
nextAvailable,
frozenAmount,
note,
JSON.stringify({
requestId: Number(current.id),
requestType: current.request_type,
source: 'admin_finance_review',
}),
input.now,
],
)
}
}
await client.query(
`
UPDATE worker_finance_requests
SET
status = $1,
reviewed_note = $2,
reviewed_at = $3,
updated_at = $3
WHERE id = $4
`,
[input.status, input.reviewedNote, input.now, input.requestId],
)
return {
request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId),
failureReason: null,
}
})
}
export async function getWorkerFinanceRequestSummary(workerId: number | string) {
const result = await query<{
pending_withdraw_amount: number
@@ -1421,6 +1586,17 @@ async function getWorkerUserByIdWithClient(
return result.rows[0] || null
}
async function getWorkerFinanceRequestByIdWithClient(
client: PoolClient,
requestId: number | string,
): Promise<WorkerFinanceRequestRow | null> {
const result = await client.query<WorkerFinanceRequestRow>(
`${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`,
[Number(requestId)],
)
return result.rows[0] || null
}
async function getWorkOrderByIdWithClient(
client: PoolClient,
workOrderId: number | string,
@@ -1500,6 +1676,7 @@ function buildWorkerFinanceRequestWhere({
workerId = 0,
status = '',
requestType = '',
keyword = '',
}: FinanceRequestListInput) {
const filters: string[] = []
const params: unknown[] = []
@@ -1515,6 +1692,16 @@ function buildWorkerFinanceRequestWhere({
params.push(requestType)
filters.push(`wfr.request_type = $${params.length}`)
}
if (keyword) {
params.push(`%${keyword}%`)
filters.push(
`(wu.username ILIKE $${params.length}
OR wu.display_name ILIKE $${params.length}
OR wu.phone ILIKE $${params.length}
OR wfr.account_name ILIKE $${params.length}
OR wfr.account_no ILIKE $${params.length})`,
)
}
return {
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
params,
@@ -4,7 +4,9 @@ import {
acceptAdminWorkOrder,
creditAdminWorkerWallet,
createAdminMockWorkOrder,
getAdminWorkerFinanceConfig,
getAdminWorkerPlatformSummary,
listAdminWorkerFinanceRequests,
listAdminWorkCategories,
listAdminWorkProductRules,
listAdminWorkerLevels,
@@ -12,13 +14,16 @@ import {
listAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
} from '../../services/worker-platform/worker-platform-service.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
@@ -161,6 +166,62 @@ router.post(
),
)
router.get(
'/worker-platform/finance-config',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(() => getAdminWorkerFinanceConfig(), {
successMessage: 'ok',
errorMessage: '读取资金配置失败',
scope: '[admin/worker-platform/finance-config]',
}),
)
router.post(
'/worker-platform/finance-config',
requireAdminRoles(['admin', 'operator']),
createJsonHandler((req) => saveAdminWorkerFinanceConfig(req.body || {}), {
successMessage: '资金配置已保存',
errorMessage: '保存资金配置失败',
scope: '[admin/worker-platform/finance-config]',
audit: (_req, data) => ({
action: 'worker_finance_config_saved',
targetType: 'worker_finance_config',
targetId: 'worker_finance',
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
)
router.get(
'/worker-platform/finance-requests',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => listAdminWorkerFinanceRequests(req.query), {
successMessage: 'ok',
errorMessage: '读取资金申请失败',
scope: '[admin/worker-platform/finance-requests]',
}),
)
router.post(
'/worker-platform/finance-requests/:requestId/review',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
reviewAdminWorkerFinanceRequest(String(req.params.requestId || ''), req.body || {}),
{
successMessage: '资金申请已处理',
errorMessage: '处理资金申请失败',
scope: '[admin/worker-platform/finance-requests/:requestId/review]',
audit: (req, data) => ({
action: 'worker_finance_request_reviewed',
targetType: 'worker_finance_request',
targetId: String(req.params.requestId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.get(
'/worker-platform/orders',
requireAdminRoles(['admin', 'operator', 'support']),
@@ -238,6 +299,22 @@ router.post(
}),
)
router.post(
'/worker-platform/orders/:workOrderId/unpublish',
requireAdminRoles(['admin', 'operator']),
createJsonHandler((req) => unpublishAdminWorkOrder(String(req.params.workOrderId || '')), {
successMessage: '订单已撤回到未分配',
errorMessage: '下架接单订单失败',
scope: '[admin/worker-platform/orders/:workOrderId/unpublish]',
audit: (req, data) => ({
action: 'work_order_unpublished',
targetType: 'work_order',
targetId: String(req.params.workOrderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
)
router.post(
'/worker-platform/orders/:workOrderId/problem',
requireAdminRoles(['admin', 'operator', 'support']),
@@ -0,0 +1,123 @@
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import type { JsonObject } from '../../types/json.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
import {
normalizeStoredFileUrl,
refreshUploadedFileUrls,
type UploadedFileDto,
} from '../file-storage/file-storage-service.js'
export type WorkerFinanceConfig = {
recharge: {
enabled: boolean
channelName: string
accountName: string
accountNo: string
qrCodeImage: UploadedFileDto | null
instructions: string
}
withdraw: {
enabled: boolean
instructions: string
}
}
export function getWorkerFinanceConfig(): WorkerFinanceConfig {
const config = readAppConfigEntry({
configKey: APP_CONFIG_KEYS.workerFinance,
fallback: createDefaultWorkerFinanceConfig,
normalize: normalizeWorkerFinanceConfig,
})
return refreshWorkerFinanceConfig(config)
}
export async function saveWorkerFinanceConfig(rawValue: unknown): Promise<WorkerFinanceConfig> {
const saved = await saveAppConfigEntry({
configKey: APP_CONFIG_KEYS.workerFinance,
value: rawValue,
normalize: normalizeWorkerFinanceConfig,
})
return refreshWorkerFinanceConfig(saved)
}
export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
const recharge = isPlainObject(source.recharge) ? source.recharge : {}
const withdraw = isPlainObject(source.withdraw) ? source.withdraw : {}
return {
recharge: {
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
accountName: String(recharge.accountName || recharge.account_name || '').trim(),
accountNo: String(recharge.accountNo || recharge.account_no || '').trim(),
qrCodeImage: normalizeUploadedFileForStorage(
recharge.qrCodeImage || recharge.qr_code_image,
),
instructions: String(recharge.instructions || '').trim(),
},
withdraw: {
enabled: typeof withdraw.enabled === 'boolean' ? withdraw.enabled : true,
instructions: String(withdraw.instructions || '').trim(),
},
}
}
export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
return {
recharge: {
enabled: true,
channelName: '',
accountName: '',
accountNo: '',
qrCodeImage: null,
instructions: '',
},
withdraw: {
enabled: true,
instructions: '',
},
}
}
function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceConfig {
return {
recharge: {
...config.recharge,
qrCodeImage: config.recharge.qrCodeImage
? (refreshUploadedFileUrls(config.recharge.qrCodeImage) as UploadedFileDto)
: null,
},
withdraw: {
...config.withdraw,
},
}
}
function normalizeUploadedFileForStorage(value: unknown): UploadedFileDto | null {
if (!isPlainObject(value)) {
return null
}
const objectKey = String(value.objectKey || value.object_key || '').trim()
if (!objectKey) {
return null
}
return {
objectKey,
url: normalizeStoredFileUrl(value.url, objectKey),
thumbnailUrl: normalizeStoredFileUrl(
value.thumbnailUrl || value.thumbnail_url,
objectKey,
),
mediumUrl: normalizeStoredFileUrl(value.mediumUrl || value.medium_url, objectKey),
filename: String(value.filename || '').trim() || '收款二维码',
contentType: String(value.contentType || value.content_type || '').trim(),
size: Math.max(0, Number(value.size || 0) || 0),
}
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -13,7 +13,9 @@ import {
createWorkOrderEvent,
createWorkerFinanceRequest,
createWorkerUser,
getWorkerFinanceRequestById,
getWorkerFinanceRequestSummary,
getWorkerLevelById,
incrementWorkerSessionVersion,
getWorkOrderById,
getWorkOrderByOrderItemId,
@@ -29,6 +31,7 @@ import {
listWorkerLevels,
listWorkerUsers,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
updateWorkOrder,
updateWorkerPassword,
updateWorkerUser,
@@ -56,6 +59,10 @@ import {
refreshStoredFileUrl,
refreshUploadedFileUrls,
} from '../file-storage/file-storage-service.js'
import {
getWorkerFinanceConfig,
saveWorkerFinanceConfig,
} from './worker-finance-config-service.js'
export type WorkerSession = {
sessionId: string
@@ -286,9 +293,10 @@ export function requireActiveWorkerSession(session: WorkerSession | null | undef
export async function getWorkerProfile(session: WorkerSession) {
const worker = await getRequiredWorker(session.workerId)
const [financeSummary, acceptedOrderCount] = await Promise.all([
const [financeSummary, acceptedOrderCount, financeConfig] = await Promise.all([
getWorkerFinanceRequestSummary(session.workerId),
countWorkerAcceptedOrders(session.workerId),
Promise.resolve(getWorkerFinanceConfig()),
])
return {
worker: mapWorkerUser(worker),
@@ -299,6 +307,7 @@ export async function getWorkerProfile(session: WorkerSession) {
approvedWithdrawAmount: financeSummary.approvedWithdrawAmount,
pendingRechargeAmount: financeSummary.pendingRechargeAmount,
},
financeConfig,
}
}
@@ -348,6 +357,13 @@ export async function createWorkerRechargeRequest(
) {
requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
const financeConfig = getWorkerFinanceConfig()
if (financeConfig.recharge.enabled === false) {
throw createHttpError('当前充值入口暂未开放,请联系管理员', {
statusCode: 409,
errorCode: 'worker_recharge_disabled',
})
}
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
if (amount <= 0) {
throw createHttpError('充值金额必须大于 0', {
@@ -369,6 +385,12 @@ export async function createWorkerRechargeRequest(
source: 'worker_profile_recharge',
username: worker.username,
phone: worker.phone || '',
financeConfig: {
channelName: financeConfig.recharge.channelName,
accountName: financeConfig.recharge.accountName,
accountNo: financeConfig.recharge.accountNo,
qrCodeImage: financeConfig.recharge.qrCodeImage,
},
}),
now: nowIso(),
})
@@ -389,6 +411,13 @@ export async function createWorkerWithdrawRequest(
) {
requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
const financeConfig = getWorkerFinanceConfig()
if (financeConfig.withdraw.enabled === false) {
throw createHttpError('当前提现入口暂未开放,请联系管理员', {
statusCode: 409,
errorCode: 'worker_withdraw_disabled',
})
}
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
if (amount <= 0) {
throw createHttpError('提现金额必须大于 0', {
@@ -878,11 +907,21 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
const worker = await getRequiredWorker(workerId)
const status = normalizeReviewStatus(payload.status)
const levelId =
normalizeOptionalId(payload.levelId) ||
worker.level_id ||
(await ensureWorkerPlatformDefaults()).level?.id ||
null
const requestedLevelId = normalizeOptionalId(payload.levelId)
let levelId = worker.level_id || null
if (requestedLevelId) {
const level = await getWorkerLevelById(requestedLevelId)
if (!level) {
throw createHttpError('接单等级不存在', {
statusCode: 404,
errorCode: 'worker_level_not_found',
})
}
levelId = Number(level.id)
}
if (!levelId) {
levelId = (await ensureWorkerPlatformDefaults()).level?.id || null
}
const now = nowIso()
const updated = await updateWorkerUser(worker.id, {
status,
@@ -914,6 +953,81 @@ export async function creditAdminWorkerWallet(workerId: number | string, payload
return { wallet: mapWallet(wallet) }
}
export async function getAdminWorkerFinanceConfig() {
return getWorkerFinanceConfig()
}
export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) {
const config = await saveWorkerFinanceConfig(payload)
return config
}
export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const status = normalizeFinanceRequestStatus(query.status)
const requestType = normalizeFinanceRequestType(query.requestType)
const keyword = String(query.keyword || '').trim()
const { items, total } = await listWorkerFinanceRequests({
page,
pageSize,
status,
requestType,
keyword,
})
return {
items: items.map(mapFinanceRequest),
pagination: { page, pageSize, total },
}
}
export async function reviewAdminWorkerFinanceRequest(
requestId: number | string,
payload: JsonObject = {},
) {
const current = await getWorkerFinanceRequestById(requestId)
if (!current) {
throw createHttpError('资金申请不存在', {
statusCode: 404,
errorCode: 'worker_finance_request_not_found',
})
}
const status = normalizeAdminFinanceReviewStatus(payload.status)
const reviewedNote = String(payload.reviewedNote || payload.note || '').trim()
const reviewed = await reviewWorkerFinanceRequest({
requestId: Number(current.id),
status,
reviewedNote,
now: nowIso(),
})
if (reviewed.failureReason === 'request_not_pending') {
throw createHttpError('该申请已处理,请刷新后重试', {
statusCode: 409,
errorCode: 'worker_finance_request_reviewed',
})
}
if (reviewed.failureReason === 'withdraw_insufficient') {
throw createHttpError('打手余额不足,暂时无法通过该提现申请', {
statusCode: 409,
errorCode: 'worker_finance_request_withdraw_insufficient',
})
}
if (!reviewed.request) {
throw createHttpError('资金申请不存在', {
statusCode: 404,
errorCode: 'worker_finance_request_not_found',
})
}
return {
request: mapFinanceRequest(reviewed.request),
}
}
export async function listAdminWorkOrders(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
@@ -1079,6 +1193,32 @@ export async function publishAdminWorkOrder(workOrderId: number | string) {
return { order: mapWorkOrderAdmin(updated || workOrder) }
}
export async function unpublishAdminWorkOrder(workOrderId: number | string) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
throw createHttpError('只有待抢单且未被接走的订单可以下架', {
statusCode: 409,
errorCode: 'work_order_unpublish_status_invalid',
})
}
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.UNASSIGNED,
published_at: null,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
eventType: 'unpublished',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.UNASSIGNED,
now,
})
return { order: mapWorkOrderAdmin(updated || workOrder) }
}
export async function markAdminWorkOrderProblem(
workOrderId: number | string,
payload: JsonObject = {},
@@ -1559,6 +1699,14 @@ function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined)
createdAt: request.created_at,
updatedAt: request.updated_at,
reviewedAt: request.reviewed_at,
worker: request.worker_id
? {
workerId: Number(request.worker_id),
username: request.worker_username || '',
displayName: request.worker_display_name || request.worker_username || '',
phone: request.worker_phone || '',
}
: null,
}
}
@@ -1902,6 +2050,7 @@ function normalizeWalletLedgerType(value: unknown) {
if (
[
'manual_credit',
'withdraw_paid',
'deposit_freeze',
'deposit_release',
'deposit_deduction',
@@ -1940,6 +2089,17 @@ function normalizeFinanceRequestStatus(value: unknown) {
})
}
function normalizeAdminFinanceReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['approved', 'rejected', 'cancelled'].includes(status)) {
return status
}
throw createHttpError('审核状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_review_status_invalid',
})
}
function normalizeWithdrawChannel(value: unknown) {
const channel = String(value || '').trim()
if (['alipay', 'wechat', 'bank'].includes(channel)) {
@@ -28,16 +28,19 @@ import {
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import PageHeader from '@/components/admin/PageHeader'
import {
acceptAdminWorkOrder,
createAdminMockWorkOrder,
creditAdminWorkerWallet,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
@@ -46,13 +49,16 @@ import {
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
} from '@/services/admin'
import type {
CollectField,
@@ -60,6 +66,8 @@ import type {
WorkCategory,
WorkOrder,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
@@ -69,6 +77,23 @@ import {
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
type FinanceConfigFormValues = {
recharge?: {
enabled?: boolean
channelName?: string
accountName?: string
accountNo?: string
qrCodeImageList?: UploadedFile[]
instructions?: string
}
withdraw?: {
enabled?: boolean
instructions?: string
}
}
type FinanceReviewAction = 'approved' | 'rejected' | 'cancelled'
export default function AdminWorkerPlatformPage() {
return (
<section className="page-stack">
@@ -84,6 +109,7 @@ export default function AdminWorkerPlatformPage() {
{ key: 'rules', label: '物品规则', children: <ProductRulesPanel /> },
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
{ key: 'finance', label: '资金', children: <FinancePanel /> },
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
]}
/>
@@ -123,7 +149,7 @@ function SummaryCards() {
}
function WorkOrdersPanel() {
const { message } = App.useApp()
const { message, modal } = App.useApp()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
@@ -329,6 +355,26 @@ function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'open' ? (
<Button
danger
onClick={() => {
modal.confirm({
title: '确认下架该订单?',
content: '下架后订单会回到未分配状态,不再在抢单大厅展示。',
okText: '确认下架',
cancelText: '取消',
onOk: () =>
runAction(
() => unpublishAdminWorkOrder(row.workOrderId),
'订单已撤回到未分配',
),
})
}}
>
</Button>
) : null}
{row.status === 'pending_acceptance' ? (
<Button
icon={<CheckOutlined />}
@@ -1088,12 +1134,18 @@ function WorkersPanel() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [creditWorker, setCreditWorker] = useState<WorkerUser | null>(null)
const [levelWorker, setLevelWorker] = useState<WorkerUser | null>(null)
const [creditForm] = Form.useForm()
const [levelForm] = Form.useForm<{ levelId?: number }>()
const workersQuery = useQuery({
queryKey: ['admin-worker-platform-workers', status, page, pageSize],
queryFn: () => fetchAdminWorkerUsers({ status, page, pageSize }),
})
const levelsQuery = useQuery({
queryKey: ['admin-worker-platform-levels'],
queryFn: () => fetchAdminWorkerLevels(),
})
const workersPagination = workersQuery.data?.data.pagination
async function refreshWorkers() {
@@ -1133,6 +1185,23 @@ function WorkersPanel() {
}
}
async function submitLevel(values: { levelId?: number }) {
if (!levelWorker) return
try {
await reviewAdminWorkerUser(levelWorker.workerId, {
status: levelWorker.status,
levelId: Number(values.levelId || 0),
reviewNote: levelWorker.reviewNote,
})
message.success('打手等级已更新')
setLevelWorker(null)
levelForm.resetFields()
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '调整等级失败')
}
}
const columns: TableColumnsType<WorkerUser> = [
{
title: '打手',
@@ -1168,7 +1237,7 @@ function WorkersPanel() {
},
{
title: '操作',
width: 260,
width: 340,
render: (_, row) => (
<Space wrap>
{row.status !== 'active' ? (
@@ -1177,6 +1246,16 @@ function WorkersPanel() {
{row.status !== 'rejected' ? (
<Button onClick={() => review(row, 'rejected')}></Button>
) : null}
<Button
onClick={() => {
setLevelWorker(row)
levelForm.setFieldsValue({
levelId: row.level?.levelId,
})
}}
>
</Button>
<Button onClick={() => setCreditWorker(row)}></Button>
</Space>
),
@@ -1231,6 +1310,38 @@ function WorkersPanel() {
scroll={{ x: 1000 }}
/>
<Modal
title="调整打手等级"
open={Boolean(levelWorker)}
onCancel={() => {
setLevelWorker(null)
levelForm.resetFields()
}}
onOk={() => levelForm.submit()}
destroyOnHidden
>
<Form form={levelForm} layout="vertical" onFinish={submitLevel}>
<Form.Item label="打手">
<Typography.Text>
{levelWorker?.displayName || levelWorker?.username || '-'}
</Typography.Text>
</Form.Item>
<Form.Item
label="目标等级"
name="levelId"
rules={[{ required: true, message: '请选择目标等级' }]}
>
<Select
loading={levelsQuery.isLoading}
options={(levelsQuery.data?.data.items || []).map((item) => ({
value: item.levelId,
label: `${item.name} / 最多同时 ${item.maxActiveOrders}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="人工增加余额"
open={Boolean(creditWorker)}
@@ -1263,6 +1374,408 @@ function WorkersPanel() {
)
}
function FinancePanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [configForm] = Form.useForm<FinanceConfigFormValues>()
const [reviewForm] = Form.useForm<{ reviewedNote?: string }>()
const [requestStatus, setRequestStatus] = useState('')
const [requestType, setRequestType] = useState('')
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [savingConfig, setSavingConfig] = useState(false)
const [reviewing, setReviewing] = useState(false)
const [reviewState, setReviewState] = useState<{
action: FinanceReviewAction
request: WorkerFinanceRequest
} | null>(null)
const financeConfigQuery = useQuery({
queryKey: ['admin-worker-platform-finance-config'],
queryFn: () => fetchAdminWorkerFinanceConfig(),
})
const financeRequestsQuery = useQuery({
queryKey: [
'admin-worker-platform-finance-requests',
requestStatus,
requestType,
keyword,
page,
pageSize,
],
queryFn: () =>
fetchAdminWorkerFinanceRequests({
status: requestStatus,
requestType,
keyword,
page,
pageSize,
}),
})
const financeRequestsPagination = financeRequestsQuery.data?.data.pagination
useEffect(() => {
const config = financeConfigQuery.data?.data
if (!config) {
return
}
configForm.setFieldsValue(mapFinanceConfigToFormValues(config))
}, [financeConfigQuery.data, configForm])
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-finance-config'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-finance-requests'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-workers'],
}),
])
}
async function submitFinanceConfig(values: FinanceConfigFormValues) {
setSavingConfig(true)
try {
await saveAdminWorkerFinanceConfig({
recharge: {
enabled: values.recharge?.enabled !== false,
channelName: String(values.recharge?.channelName || '').trim(),
accountName: String(values.recharge?.accountName || '').trim(),
accountNo: String(values.recharge?.accountNo || '').trim(),
qrCodeImage: values.recharge?.qrCodeImageList?.[0] || null,
instructions: String(values.recharge?.instructions || '').trim(),
},
withdraw: {
enabled: values.withdraw?.enabled !== false,
instructions: String(values.withdraw?.instructions || '').trim(),
},
})
message.success('资金配置已保存')
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存资金配置失败')
} finally {
setSavingConfig(false)
}
}
function openReviewModal(request: WorkerFinanceRequest, action: FinanceReviewAction) {
setReviewState({ request, action })
reviewForm.setFieldsValue({ reviewedNote: '' })
}
async function submitFinanceReview(values: { reviewedNote?: string }) {
if (!reviewState) return
setReviewing(true)
try {
await reviewAdminWorkerFinanceRequest(reviewState.request.requestId, {
status: reviewState.action,
reviewedNote: String(values.reviewedNote || '').trim(),
})
message.success(`${formatFinanceReviewAction(reviewState.action)}成功`)
setReviewState(null)
reviewForm.resetFields()
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '处理资金申请失败')
} finally {
setReviewing(false)
}
}
const requestColumns: TableColumnsType<WorkerFinanceRequest> = [
{
title: '打手',
minWidth: 180,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>
{row.worker?.displayName || row.worker?.username || '-'}
</Typography.Text>
<Typography.Text type="secondary">
{row.worker?.username || '-'}
</Typography.Text>
</div>
),
},
{
title: '类型',
width: 120,
render: (_, row) => (
<Tag color={row.requestType === 'withdraw' ? 'green' : 'blue'}>
{formatFinanceRequestType(row.requestType)}
</Tag>
),
},
{
title: '金额',
width: 120,
render: (_, row) => formatMoney(row.amount),
},
{
title: '收款信息',
minWidth: 240,
render: (_, row) => renderFinanceRequestAccount(row),
},
{
title: '备注',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text>{row.note || '-'}</Typography.Text>
{row.reviewedNote ? (
<Typography.Text type="secondary">
{row.reviewedNote}
</Typography.Text>
) : null}
</div>
),
},
{
title: '状态',
width: 120,
render: (_, row) => (
<Tag color={resolveFinanceRequestStatusColor(row.status)}>
{formatFinanceRequestStatus(row.status)}
</Tag>
),
},
{
title: '时间',
width: 180,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text>{formatAdminDateTime(row.createdAt)}</Typography.Text>
<Typography.Text type="secondary">
{row.reviewedAt ? `审核:${formatAdminDateTime(row.reviewedAt)}` : '待审核'}
</Typography.Text>
</div>
),
},
{
title: '操作',
width: 240,
render: (_, row) =>
row.status === 'pending' ? (
<Space wrap>
<Button type="primary" onClick={() => openReviewModal(row, 'approved')}>
</Button>
<Button onClick={() => openReviewModal(row, 'rejected')}></Button>
<Button danger onClick={() => openReviewModal(row, 'cancelled')}>
</Button>
</Space>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
]
return (
<section className="platform-panel-stack">
<Card
title="资金配置"
bordered={false}
loading={financeConfigQuery.isLoading}
extra={
<Button
icon={<ReloadOutlined />}
loading={financeConfigQuery.isFetching}
onClick={() => financeConfigQuery.refetch()}
>
</Button>
}
>
{financeConfigQuery.error ? (
<Typography.Text type="danger">
{financeConfigQuery.error instanceof Error
? financeConfigQuery.error.message
: '读取资金配置失败'}
</Typography.Text>
) : null}
<Form
form={configForm}
layout="vertical"
onFinish={submitFinanceConfig}
initialValues={mapFinanceConfigToFormValues()}
>
<Card title="充值入口" size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="开启充值申请"
name={['recharge', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="收款通道名称" name={['recharge', 'channelName']}>
<Input placeholder="如:支付宝扫码、微信收款" />
</Form.Item>
<Form.Item label="收款人" name={['recharge', 'accountName']}>
<Input placeholder="请输入收款人姓名" />
</Form.Item>
<Form.Item label="收款账号" name={['recharge', 'accountNo']}>
<Input placeholder="请输入支付宝账号、微信号或银行卡号" />
</Form.Item>
<Form.Item label="收款二维码" name={['recharge', 'qrCodeImageList']}>
<ImageUpload scene="worker-finance" scope="admin" maxCount={1} />
</Form.Item>
<Form.Item label="充值说明" name={['recharge', 'instructions']}>
<Input.TextArea
rows={4}
placeholder="例如:转账后请填写付款时间、付款尾号、截图说明。"
/>
</Form.Item>
</Card>
<Card title="提现入口" size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="开启提现申请"
name={['withdraw', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="提现说明" name={['withdraw', 'instructions']}>
<Input.TextArea
rows={4}
placeholder="例如:每天 18:00 前审核,当日打款;银行卡请备注开户行。"
/>
</Form.Item>
</Card>
<Button type="primary" htmlType="submit" loading={savingConfig}>
</Button>
</Form>
</Card>
<Card
title="资金申请"
bordered={false}
extra={
<Space wrap>
<Input.Search
allowClear
value={keywordInput}
placeholder="搜索打手账号/昵称/手机号"
style={{ width: 240 }}
onChange={(event) => setKeywordInput(event.target.value)}
onSearch={(value) => {
setKeyword(String(value || '').trim())
setPage(1)
}}
/>
<Select
value={requestType}
style={{ width: 140 }}
options={[
{ value: '', label: '全部类型' },
{ value: 'recharge', label: '充值申请' },
{ value: 'withdraw', label: '提现申请' },
]}
onChange={(nextType) => {
setRequestType(nextType)
setPage(1)
}}
/>
<Select
value={requestStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
{ value: 'cancelled', label: '已取消' },
]}
onChange={(nextStatus) => {
setRequestStatus(nextStatus)
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={financeRequestsQuery.isFetching}
onClick={() => financeRequestsQuery.refetch()}
>
</Button>
</Space>
}
>
<Table<WorkerFinanceRequest>
rowKey="requestId"
loading={financeRequestsQuery.isLoading}
dataSource={financeRequestsQuery.data?.data.items || []}
columns={requestColumns}
pagination={buildAdminTablePagination({
current: financeRequestsPagination?.page || page,
pageSize: financeRequestsPagination?.pageSize || pageSize,
total: financeRequestsPagination?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1380 }}
/>
</Card>
<Modal
title={reviewState ? `${formatFinanceReviewAction(reviewState.action)}资金申请` : '处理资金申请'}
open={Boolean(reviewState)}
destroyOnHidden
confirmLoading={reviewing}
onCancel={() => {
setReviewState(null)
reviewForm.resetFields()
}}
onOk={() => reviewForm.submit()}
>
{reviewState ? (
<Space direction="vertical" size={16} className="full-width">
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="打手">
{reviewState.request.worker?.displayName ||
reviewState.request.worker?.username ||
'-'}
</Descriptions.Item>
<Descriptions.Item label="申请类型">
{formatFinanceRequestType(reviewState.request.requestType)}
</Descriptions.Item>
<Descriptions.Item label="金额">
{formatMoney(reviewState.request.amount)}
</Descriptions.Item>
<Descriptions.Item label="收款信息">
{renderFinanceRequestAccountText(reviewState.request)}
</Descriptions.Item>
<Descriptions.Item label="申请备注">
{reviewState.request.note || '-'}
</Descriptions.Item>
</Descriptions>
<Form form={reviewForm} layout="vertical" onFinish={submitFinanceReview}>
<Form.Item label="审核备注" name="reviewedNote">
<Input.TextArea
rows={4}
placeholder={`请输入${formatFinanceReviewAction(reviewState.action)}备注`}
/>
</Form.Item>
</Form>
</Space>
) : null}
</Modal>
</section>
)
}
function LevelsPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
@@ -1556,3 +2069,115 @@ function formatWorkerStatus(status: string) {
}
return labels[status] || status
}
function mapFinanceConfigToFormValues(
config?: WorkerFinanceConfig,
): FinanceConfigFormValues {
return {
recharge: {
enabled: config?.recharge.enabled !== false,
channelName: config?.recharge.channelName || '',
accountName: config?.recharge.accountName || '',
accountNo: config?.recharge.accountNo || '',
qrCodeImageList: config?.recharge.qrCodeImage ? [config.recharge.qrCodeImage] : [],
instructions: config?.recharge.instructions || '',
},
withdraw: {
enabled: config?.withdraw.enabled !== false,
instructions: config?.withdraw.instructions || '',
},
}
}
function formatFinanceReviewAction(action: FinanceReviewAction) {
if (action === 'approved') return '通过'
if (action === 'rejected') return '拒绝'
return '取消'
}
function formatFinanceRequestType(requestType: string) {
if (requestType === 'recharge') return '充值申请'
if (requestType === 'withdraw') return '提现申请'
return requestType || '-'
}
function formatFinanceRequestStatus(status: string) {
const labels: Record<string, string> = {
pending: '待处理',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已取消',
}
return labels[status] || status || '-'
}
function resolveFinanceRequestStatusColor(status: string) {
if (status === 'approved') return 'green'
if (status === 'pending') return 'gold'
if (status === 'rejected') return 'red'
if (status === 'cancelled') return 'default'
return 'blue'
}
function renderFinanceRequestAccount(request: WorkerFinanceRequest) {
return (
<div className="cell-stack">
<Typography.Text>{renderFinanceRequestAccountText(request)}</Typography.Text>
{request.requestType === 'withdraw' && request.accountNo ? (
<Typography.Text type="secondary">
{maskFinanceAccount(request.accountNo)}
</Typography.Text>
) : null}
</div>
)
}
function renderFinanceRequestAccountText(request: WorkerFinanceRequest) {
if (request.requestType === 'withdraw') {
const channel = formatFinanceChannel(request.accountChannel)
const accountName = request.accountName || '-'
return `${channel} / ${accountName}`
}
const rechargeSnapshot = getRechargeSnapshot(request)
if (
rechargeSnapshot.channelName ||
rechargeSnapshot.accountName ||
rechargeSnapshot.accountNo
) {
return [
rechargeSnapshot.channelName,
rechargeSnapshot.accountName,
rechargeSnapshot.accountNo ? maskFinanceAccount(rechargeSnapshot.accountNo) : '',
]
.filter(Boolean)
.join(' / ')
}
return '线下充值,等待管理员核对'
}
function getRechargeSnapshot(request: WorkerFinanceRequest) {
const payload = asRecord(request.payload)
const financeConfig = asRecord(payload.financeConfig)
return {
channelName: String(financeConfig.channelName || '').trim(),
accountName: String(financeConfig.accountName || '').trim(),
accountNo: String(financeConfig.accountNo || '').trim(),
}
}
function formatFinanceChannel(channel: string) {
if (channel === 'alipay') return '支付宝'
if (channel === 'wechat') return '微信收款'
if (channel === 'bank') return '银行卡'
if (channel === 'manual') return '线下转账'
return channel || '-'
}
function maskFinanceAccount(value: string) {
const text = String(value || '').trim()
if (!text) return '-'
if (text.length <= 8) return text
return `${text.slice(0, 4)} **** ${text.slice(-4)}`
}
@@ -19,6 +19,7 @@ import {
Descriptions,
Empty,
Form,
Image,
Input,
InputNumber,
Modal,
@@ -113,6 +114,7 @@ export default function WorkerProfilePage() {
const worker = profileQuery.data?.data.worker
const summary = profileQuery.data?.data.summary
const financeConfig = profileQuery.data?.data.financeConfig
const canUseFinanceActions = worker?.status === 'active'
const ledgersQuery = useQuery({
@@ -179,6 +181,30 @@ export default function WorkerProfilePage() {
setActiveTab('requests')
}
function openRechargeModal() {
if (!canUseFinanceActions) {
message.warning('当前账号尚未通过审核,暂时不能发起充值申请')
return
}
if (financeConfig?.recharge.enabled === false) {
message.warning('当前充值入口暂未开放,请联系管理员')
return
}
setRechargeOpen(true)
}
function openWithdrawModal() {
if (!canUseFinanceActions) {
message.warning('当前账号尚未通过审核,暂时不能发起提现申请')
return
}
if (financeConfig?.withdraw.enabled === false) {
message.warning('当前提现入口暂未开放,请联系管理员')
return
}
setWithdrawOpen(true)
}
async function submitRecharge(values: RechargeFormValues) {
setSubmittingRecharge(true)
try {
@@ -327,7 +353,7 @@ export default function WorkerProfilePage() {
</Typography.Text>
</div>
) : (
<Typography.Text type="secondary">线</Typography.Text>
renderRechargeRequestSummary(row)
),
},
{
@@ -452,7 +478,7 @@ export default function WorkerProfilePage() {
type="primary"
icon={<WalletOutlined />}
disabled={!canUseFinanceActions}
onClick={() => setRechargeOpen(true)}
onClick={openRechargeModal}
>
</Button>
@@ -460,7 +486,7 @@ export default function WorkerProfilePage() {
type="primary"
icon={<BankOutlined />}
disabled={!canUseFinanceActions}
onClick={() => setWithdrawOpen(true)}
onClick={openWithdrawModal}
>
</Button>
@@ -549,6 +575,7 @@ export default function WorkerProfilePage() {
options={[
{ value: '', label: '全部流水' },
{ value: 'manual_credit', label: '人工充值' },
{ value: 'withdraw_paid', label: '提现打款' },
{ value: 'deposit_freeze', label: '冻结押金' },
{ value: 'deposit_release', label: '释放押金' },
{ value: 'deposit_deduction', label: '扣除押金' },
@@ -697,6 +724,51 @@ export default function WorkerProfilePage() {
initialValues={{ amount: undefined, note: '' }}
onFinish={submitRecharge}
>
<Alert
showIcon
type={hasRechargeConfig(financeConfig) ? 'info' : 'warning'}
message={
hasRechargeConfig(financeConfig)
? '请先按下方收款信息完成转账,再提交充值申请,管理员核对后会手动入账。'
: '管理员暂未配置完整收款信息,如无法转账请先联系管理员确认。'
}
/>
<Space
direction="vertical"
size={12}
className="full-width"
style={{ marginTop: 16, marginBottom: 16 }}
>
{financeConfig?.recharge.qrCodeImage ? (
<Image
width={180}
src={
financeConfig.recharge.qrCodeImage.mediumUrl ||
financeConfig.recharge.qrCodeImage.url
}
/>
) : null}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="收款通道">
{financeConfig?.recharge.channelName || '-'}
</Descriptions.Item>
<Descriptions.Item label="收款人">
{financeConfig?.recharge.accountName || '-'}
</Descriptions.Item>
<Descriptions.Item label="收款账号">
{financeConfig?.recharge.accountNo ? (
<Typography.Text copyable>
{financeConfig.recharge.accountNo}
</Typography.Text>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="充值说明">
{financeConfig?.recharge.instructions || '请转账后填写备注说明,方便管理员核对。'}
</Descriptions.Item>
</Descriptions>
</Space>
<Form.Item
label="充值金额"
name="amount"
@@ -730,6 +802,14 @@ export default function WorkerProfilePage() {
initialValues={{ accountChannel: 'alipay' }}
onFinish={submitWithdraw}
>
{financeConfig?.withdraw.instructions ? (
<Alert
showIcon
type="info"
message={financeConfig.withdraw.instructions}
style={{ marginBottom: 16 }}
/>
) : null}
<Form.Item label="可提现余额">
<Typography.Text strong>
{formatMoney(resolveAvailableForWithdraw(worker, summary))}
@@ -962,6 +1042,7 @@ function resolveWorkerStatusColor(status: string) {
function formatLedgerType(ledgerType: string) {
const labels: Record<string, string> = {
manual_credit: '人工充值',
withdraw_paid: '提现打款',
deposit_freeze: '冻结押金',
deposit_release: '释放押金',
deposit_deduction: '扣除押金',
@@ -972,6 +1053,7 @@ function formatLedgerType(ledgerType: string) {
function resolveLedgerTagColor(ledgerType: string) {
if (ledgerType === 'manual_credit') return 'blue'
if (ledgerType === 'withdraw_paid') return 'purple'
if (ledgerType === 'deposit_freeze') return 'gold'
if (ledgerType === 'deposit_release') return 'green'
if (ledgerType === 'deposit_deduction') return 'red'
@@ -995,6 +1077,46 @@ function formatRequestStatus(status: string) {
return labels[status] || status || '-'
}
function hasRechargeConfig(
financeConfig: {
recharge?: { accountName?: string; accountNo?: string; qrCodeImage?: unknown }
} | undefined,
) {
return Boolean(
financeConfig?.recharge?.accountName ||
financeConfig?.recharge?.accountNo ||
financeConfig?.recharge?.qrCodeImage,
)
}
function renderRechargeRequestSummary(row: WorkerFinanceRequest) {
const snapshot = getRechargeSnapshot(row)
if (!snapshot.channelName && !snapshot.accountName && !snapshot.accountNo) {
return <Typography.Text type="secondary">线</Typography.Text>
}
return (
<div className="cell-stack">
<Typography.Text>
{[snapshot.channelName, snapshot.accountName].filter(Boolean).join(' / ') || '线下充值'}
</Typography.Text>
<Typography.Text type="secondary">
{snapshot.accountNo ? maskAccountNo(snapshot.accountNo) : '等待管理员核对'}
</Typography.Text>
</div>
)
}
function getRechargeSnapshot(row: WorkerFinanceRequest) {
const payload = asRecord(row.payload)
const financeConfig = asRecord(payload.financeConfig)
return {
channelName: String(financeConfig.channelName || '').trim(),
accountName: String(financeConfig.accountName || '').trim(),
accountNo: String(financeConfig.accountNo || '').trim(),
}
}
function resolveRequestStatusColor(status: string) {
if (status === 'approved') return 'green'
if (status === 'pending') return 'gold'
@@ -1016,3 +1138,9 @@ function maskAccountNo(value: string) {
if (text.length <= 8) return text
return `${text.slice(0, 4)} **** ${text.slice(-4)}`
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
@@ -3,6 +3,8 @@ import type {
WorkCategory,
WorkOrder,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerListResponse,
WorkerUser,
@@ -110,6 +112,44 @@ export function creditAdminWorkerWallet(
)
}
export function fetchAdminWorkerFinanceConfig() {
return apiGet<WorkerFinanceConfig>('/api/v1/admin/worker-platform/finance-config')
}
export function saveAdminWorkerFinanceConfig(payload: {
recharge?: {
enabled?: boolean
channelName?: string
accountName?: string
accountNo?: string
qrCodeImage?: unknown
instructions?: string
}
withdraw?: {
enabled?: boolean
instructions?: string
}
}) {
return apiPost<WorkerFinanceConfig>('/api/v1/admin/worker-platform/finance-config', payload)
}
export function fetchAdminWorkerFinanceRequests(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkerFinanceRequest>>(
'/api/v1/admin/worker-platform/finance-requests',
params,
)
}
export function reviewAdminWorkerFinanceRequest(
requestId: number,
payload: { status: 'approved' | 'rejected' | 'cancelled'; reviewedNote?: string; note?: string },
) {
return apiPost<{ request: WorkerFinanceRequest }>(
`/api/v1/admin/worker-platform/finance-requests/${requestId}/review`,
payload,
)
}
export function fetchAdminWorkOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>(
'/api/v1/admin/worker-platform/orders',
@@ -157,6 +197,12 @@ export function publishAdminWorkOrder(workOrderId: number) {
)
}
export function unpublishAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/unpublish`,
)
}
export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`,
@@ -59,12 +59,34 @@ export type WorkerFinanceRequest = {
createdAt: string
updatedAt: string
reviewedAt: string | null
worker: null | {
workerId: number
username: string
displayName: string
phone: string
}
}
export type WorkerFinanceConfig = {
recharge: {
enabled: boolean
channelName: string
accountName: string
accountNo: string
qrCodeImage: UploadedFile | null
instructions: string
}
withdraw: {
enabled: boolean
instructions: string
}
}
export type WorkerProfileResponse = {
worker: WorkerUser
permissions: Record<string, unknown>
summary: WorkerProfileSummary
financeConfig: WorkerFinanceConfig
}
export type WorkerLevel = {