diff --git a/apps/backend/src/db/migrations/023_worker_withdrawal_accounts.sql b/apps/backend/src/db/migrations/023_worker_withdrawal_accounts.sql new file mode 100644 index 00000000..f8616c3d --- /dev/null +++ b/apps/backend/src/db/migrations/023_worker_withdrawal_accounts.sql @@ -0,0 +1,11 @@ +-- 打手提现账户:每位打手仅允许首次登记,后续变更由客服处理。 +CREATE TABLE IF NOT EXISTS worker_withdrawal_accounts ( + worker_id BIGINT PRIMARY KEY REFERENCES worker_users(id) ON DELETE CASCADE, + account_channel TEXT NOT NULL CHECK (account_channel IN ('alipay', 'wechat')), + account_name TEXT NOT NULL, + account_no TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +COMMENT ON TABLE worker_withdrawal_accounts IS '打手不可自行修改的提现账户'; +COMMENT ON COLUMN worker_withdrawal_accounts.account_channel IS '提现渠道:alipay / wechat'; diff --git a/apps/backend/src/db/migrations/024_worker_withdrawal_accounts_per_channel.sql b/apps/backend/src/db/migrations/024_worker_withdrawal_accounts_per_channel.sql new file mode 100644 index 00000000..9c9eb202 --- /dev/null +++ b/apps/backend/src/db/migrations/024_worker_withdrawal_accounts_per_channel.sql @@ -0,0 +1,8 @@ +-- 提现账户改为每个渠道各登记一次,保留既有账户数据。 +ALTER TABLE worker_withdrawal_accounts + DROP CONSTRAINT IF EXISTS worker_withdrawal_accounts_pkey; + +ALTER TABLE worker_withdrawal_accounts + ADD PRIMARY KEY (worker_id, account_channel); + +COMMENT ON TABLE worker_withdrawal_accounts IS '打手每个渠道仅可自行登记一次的提现账户'; diff --git a/apps/backend/src/db/migrations/025_worker_wechat_withdrawal_qr_code.sql b/apps/backend/src/db/migrations/025_worker_wechat_withdrawal_qr_code.sql new file mode 100644 index 00000000..7b819693 --- /dev/null +++ b/apps/backend/src/db/migrations/025_worker_wechat_withdrawal_qr_code.sql @@ -0,0 +1,5 @@ +-- 微信提现不使用账号,改为保存不可修改的收款二维码。 +ALTER TABLE worker_withdrawal_accounts + ADD COLUMN IF NOT EXISTS wechat_qr_code_json JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN worker_withdrawal_accounts.wechat_qr_code_json IS '微信收款二维码文件信息'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index d90a7ca7..06761b3d 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -94,6 +94,15 @@ export type WorkerFinanceRequestRow = { worker_phone?: string } +export type WorkerWithdrawalAccountRow = { + worker_id: number + account_channel: string + account_name: string + account_no: string + wechat_qr_code_json: string | Record + created_at: string +} + export type WorkCategoryRow = { id: number category_key: string diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index 6f9ea728..23ef9371 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -8,6 +8,7 @@ import type { WorkerFinanceRequestRow, WorkerLevelRow, WorkerUserRow, + WorkerWithdrawalAccountRow, WorkerWalletLedgerRow, WorkerWalletRow, } from './types.js' @@ -437,6 +438,65 @@ export async function createWorkerFinanceRequest(input: { return result.rows[0] || null } +export async function getWorkerWithdrawalAccount( + workerId: number | string, + accountChannel: string, +): Promise { + const result = await query( + ` + SELECT * + FROM worker_withdrawal_accounts + WHERE worker_id = $1 AND account_channel = $2 + LIMIT 1 + `, + [Number(workerId), accountChannel], + ) + return result.rows[0] || null +} + +export async function listWorkerWithdrawalAccounts( + workerId: number | string, +): Promise { + const result = await query( + ` + SELECT * + FROM worker_withdrawal_accounts + WHERE worker_id = $1 + ORDER BY account_channel ASC + `, + [Number(workerId)], + ) + return result.rows +} + +export async function createWorkerWithdrawalAccount(input: { + workerId: number + accountChannel: string + accountName: string + accountNo: string + wechatQrCodeJson: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO worker_withdrawal_accounts ( + worker_id, account_channel, account_name, account_no, wechat_qr_code_json, created_at + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6) + ON CONFLICT (worker_id, account_channel) DO NOTHING + RETURNING * + `, + [ + input.workerId, + input.accountChannel, + input.accountName, + input.accountNo, + input.wechatQrCodeJson, + input.now, + ], + ) + return result.rows[0] || null +} + export async function getWorkerFinanceRequestById( requestId: number | string, ): Promise { diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 5a06311b..a8f03dc2 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -6,6 +6,7 @@ import { uploadFileAsset } from '../services/file-storage/file-storage-service.j import { changeWorkerPassword, createWorkerRechargeRequest, + createWorkerWithdrawalAccount, createWorkerWithdrawRequest, getWorkerProfile, getWorkerSessionSummary, @@ -147,6 +148,19 @@ router.post( ), ) +router.post( + '/profile/withdrawal-account', + requireActiveWorker, + createRouteHandler( + (req) => createWorkerWithdrawalAccount(req.body || {}, getRequiredWorkerSession(req)), + { + successMessage: '提现信息已添加', + errorMessage: '添加提现信息失败', + scope: '[worker/profile/withdrawal-account]', + }, + ), +) + router.post( '/profile/withdraw-requests', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 38a0ab72..baf944f3 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -260,6 +260,11 @@ export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) { if (!request) return null + const payload = safeParseJson(request.payload_json) + const wechatQrCodeImage = refreshUploadedFileUrls(payload.wechatQrCodeImage) + if (String(wechatQrCodeImage.url || '').trim()) { + payload.wechatQrCodeImage = wechatQrCodeImage + } return { requestId: Number(request.id), requestType: request.request_type || '', @@ -270,7 +275,7 @@ export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | unde accountNo: request.account_no || '', note: request.note || '', reviewedNote: request.reviewed_note || '', - payload: safeParseJson(request.payload_json), + payload, createdAt: request.created_at, updatedAt: request.updated_at, reviewedAt: request.reviewed_at, @@ -901,7 +906,7 @@ export function normalizeAdminFinanceReviewStatus(value: unknown) { export function normalizeWithdrawChannel(value: unknown) { const channel = String(value || '').trim() - if (['alipay', 'wechat', 'bank'].includes(channel)) { + if (['alipay', 'wechat'].includes(channel)) { return channel } throw createHttpError('提现方式不正确', { @@ -910,6 +915,16 @@ export function normalizeWithdrawChannel(value: unknown) { }) } +/** 提现金额超过 100 元时,仅允许使用支付宝。 */ +export function assertWithdrawChannelAllowedForAmount(amount: number, channel: string) { + if (amount > 10_000 && channel !== 'alipay') { + throw createHttpError('提现金额超过 100 元时,仅支持支付宝提现', { + statusCode: 400, + errorCode: 'worker_withdraw_channel_amount_limited', + }) + } +} + export function normalizeMatchType(value: unknown) { const matchType = String(value || '').trim() if (matchType === 'exact') return 'exact' diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 58ecff87..caaf8e45 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -3,7 +3,9 @@ import test from 'node:test' import type { WorkOrderRow, WorkerUserRow } from '../../repositories/worker-platform/index.js' import { + assertWithdrawChannelAllowedForAmount, assertWorkerLoginAllowed, + normalizeWithdrawChannel, normalizeRequirementFields, resolveCollectSubmitTargetWorkOrder, resolveSkuNameQuantity, @@ -72,6 +74,28 @@ test('assertWorkerLoginAllowed blocks disabled workers with worker_disabled', () ) }) +test('微信提现金额不超过 100 元时允许提交', () => { + assert.doesNotThrow(() => assertWithdrawChannelAllowedForAmount(10_000, 'wechat')) +}) + +test('微信提现金额超过 100 元时被拒绝', () => { + assert.throws( + () => assertWithdrawChannelAllowedForAmount(10_001, 'wechat'), + (error: unknown) => + Boolean( + error && + typeof error === 'object' && + (error as { errorCode?: string }).errorCode === 'worker_withdraw_channel_amount_limited', + ), + ) +}) + +test('提现渠道仅支持支付宝和微信', () => { + assert.equal(normalizeWithdrawChannel('alipay'), 'alipay') + assert.equal(normalizeWithdrawChannel('wechat'), 'wechat') + assert.throws(() => normalizeWithdrawChannel('bank')) +}) + test('normalizeRequirementFields default template keeps gameId/gameNickname as text inputs', () => { const fields = normalizeRequirementFields([]) const gameId = fields.find((field) => field.key === 'gameId') diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 41bc562b..bd3e657c 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -15,12 +15,14 @@ import { createWorkOrderEvent, createWorkerFinanceRequest, createWorkerUser, + createWorkerWithdrawalAccount as createWorkerWithdrawalAccountRecord, getWorkerFinanceRequestSummary, getWorkCategoryByKey, getWorkerLevelByKey, getLatestWorkerSmsCode, getWorkerUserByInviteCode, getWorkerUserById, + getWorkerWithdrawalAccount, getWorkerUserByPhone, getWorkerUserByUsername, getWorkerUserByDisplayName, @@ -35,6 +37,7 @@ import { listWorkerSharesByWorker, listWorkerWalletLedgers, listWorkerWorkOrderNotes, + listWorkerWithdrawalAccounts, listWorkCategories, listWorkOrders, listWorkOrderEventsByOrderId, @@ -63,6 +66,7 @@ import { nowIso } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' import { getSmsProvider } from '../sms/index.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' +import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { DEFAULT_CATEGORY_KEY, @@ -84,6 +88,7 @@ import { mapWorkOrderShare, mapWorkerUser, normalizeAmountFen, + assertWithdrawChannelAllowedForAmount, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, @@ -109,6 +114,8 @@ import { verifyWorkerPassword, } from './mappers.js' +const WORKER_DAILY_WITHDRAW_LIMIT = 3 + export type WorkerSession = { sessionId: string workerId: number @@ -505,14 +512,21 @@ export function requireActiveWorkerSession(session: WorkerSession | null | undef export async function getWorkerProfile(session: WorkerSession) { const worker = await getRequiredWorker(session.workerId) - const [financeSummary, acceptedOrderCount, activeOrderCount, timeoutOrderCount, financeConfig] = - await Promise.all([ - getWorkerFinanceRequestSummary(session.workerId), - countWorkerAcceptedOrders(session.workerId), - countWorkerActiveOrders(session.workerId), - countWorkerTimeoutEvents(session.workerId), - Promise.resolve(getWorkerFinanceConfig()), - ]) + const [ + financeSummary, + acceptedOrderCount, + activeOrderCount, + timeoutOrderCount, + financeConfig, + withdrawalAccounts, + ] = await Promise.all([ + getWorkerFinanceRequestSummary(session.workerId), + countWorkerAcceptedOrders(session.workerId), + countWorkerActiveOrders(session.workerId), + countWorkerTimeoutEvents(session.workerId), + Promise.resolve(getWorkerFinanceConfig()), + listWorkerWithdrawalAccounts(session.workerId), + ]) const permissions = resolveWorkerPermissions(worker) const levelProgress = await resolveLevelProgress(worker, acceptedOrderCount) return { @@ -528,6 +542,7 @@ export async function getWorkerProfile(session: WorkerSession) { }, levelProgress, financeConfig, + withdrawalAccounts: withdrawalAccounts.map(mapWithdrawalAccount), orderTimelineVisible: runtimeConfig.worker.orderTimelineVisible === true, } } @@ -696,8 +711,8 @@ export async function createWorkerWithdrawRequest( resolveChinaDayRange().start, resolveChinaDayRange().end, ) - if (withdrawCountToday > 0) { - throw createHttpError('每天限提现 1 次,今天已提交过提现申请', { + if (withdrawCountToday >= WORKER_DAILY_WITHDRAW_LIMIT) { + throw createHttpError('每天最多提现 3 次(支付宝、微信共享次数),今天已达上限', { statusCode: 409, errorCode: 'worker_withdraw_daily_limit_reached', }) @@ -712,18 +727,19 @@ export async function createWorkerWithdrawRequest( } const accountChannel = normalizeWithdrawChannel(payload.accountChannel || payload.channel) - const accountName = String(payload.accountName || payload.realName || '').trim() - const accountNo = String(payload.accountNo || payload.account || '').trim() - if (!accountName) { - throw createHttpError('请填写收款人姓名', { - statusCode: 400, - errorCode: 'worker_withdraw_account_name_required', + const withdrawalAccount = await getWorkerWithdrawalAccount(worker.id, accountChannel) + if (!withdrawalAccount) { + throw createHttpError('请先添加对应的提现信息;修改提现信息请联系客服处理', { + statusCode: 409, + errorCode: 'worker_withdrawal_account_required', }) } - if (!accountNo) { - throw createHttpError('请填写收款账号', { - statusCode: 400, - errorCode: 'worker_withdraw_account_no_required', + assertWithdrawChannelAllowedForAmount(amount, accountChannel) + const wechatQrCodeImage = resolveWechatWithdrawalQrCode(withdrawalAccount) + if (accountChannel === 'wechat' && !wechatQrCodeImage) { + throw createHttpError('微信提现信息缺少收款二维码,请联系客服处理', { + statusCode: 409, + errorCode: 'worker_withdrawal_wechat_qr_required', }) } @@ -743,13 +759,14 @@ export async function createWorkerWithdrawRequest( requestType: 'withdraw', amount, accountChannel, - accountName, - accountNo, + accountName: withdrawalAccount.account_name, + accountNo: withdrawalAccount.account_no, note: note || '个人中心提现申请', payloadJson: JSON.stringify({ source: 'worker_profile_withdraw', username: worker.username, phone: worker.phone || '', + wechatQrCodeImage, }), now: nowIso(), }) @@ -765,6 +782,97 @@ export async function createWorkerWithdrawRequest( } } +export async function createWorkerWithdrawalAccount( + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const accountChannel = normalizeWithdrawChannel(payload.accountChannel || payload.channel) + const existing = await getWorkerWithdrawalAccount(worker.id, accountChannel) + if (existing) { + throw createHttpError('该提现方式的信息已添加,修改请联系客服处理', { + statusCode: 409, + errorCode: 'worker_withdrawal_account_already_set', + }) + } + + const accountName = String(payload.accountName || payload.realName || '').trim() + const accountNo = + accountChannel === 'alipay' ? String(payload.accountNo || payload.account || '').trim() : '' + const wechatQrCodeImage = + accountChannel === 'wechat' + ? normalizeProofFiles(payload.wechatQrCodeImages || payload.wechatQrCodes)[0] || null + : null + if (!accountName) { + throw createHttpError('请填写收款人姓名', { + statusCode: 400, + errorCode: 'worker_withdraw_account_name_required', + }) + } + if (accountChannel === 'alipay' && !accountNo) { + throw createHttpError('请填写收款账号', { + statusCode: 400, + errorCode: 'worker_withdraw_account_no_required', + }) + } + if (accountChannel === 'wechat' && !wechatQrCodeImage) { + throw createHttpError('请上传微信收款二维码', { + statusCode: 400, + errorCode: 'worker_withdraw_wechat_qr_required', + }) + } + + const created = await createWorkerWithdrawalAccountRecord({ + workerId: worker.id, + accountChannel, + accountName, + accountNo, + wechatQrCodeJson: JSON.stringify(wechatQrCodeImage || {}), + now: nowIso(), + }) + if (!created) { + throw createHttpError('该提现方式的信息已添加,修改请联系客服处理', { + statusCode: 409, + errorCode: 'worker_withdrawal_account_already_set', + }) + } + return { + withdrawalAccount: mapWithdrawalAccount(created), + } +} + +function mapWithdrawalAccount(account: { + account_channel: string + account_name: string + account_no: string + wechat_qr_code_json: string | Record + created_at: string +}) { + const wechatQrCodeImage = resolveWechatWithdrawalQrCode(account) + return { + accountChannel: account.account_channel, + accountName: account.account_name, + accountNoMasked: + account.account_channel === 'alipay' ? maskWithdrawalAccountNo(account.account_no) : '', + wechatQrCodeImage, + createdAt: account.created_at, + } +} + +function resolveWechatWithdrawalQrCode(account: { + wechat_qr_code_json: string | Record +}) { + const file = refreshUploadedFileUrls(safeParseJson(account.wechat_qr_code_json)) + return String(file.url || '').trim() ? file : null +} + +function maskWithdrawalAccountNo(accountNo: string) { + const normalized = String(accountNo || '').trim() + if (normalized.length <= 4) return normalized + return `${'*'.repeat(Math.max(4, normalized.length - 4))}${normalized.slice(-4)}` +} + export async function changeWorkerPassword(payload: JsonObject = {}, session: WorkerSession) { const worker = await getRequiredWorker(session.workerId) const currentPassword = normalizePassword(payload.currentPassword || payload.oldPassword) diff --git a/apps/frontend/src/pages/admin/panels/FinancePanel.tsx b/apps/frontend/src/pages/admin/panels/FinancePanel.tsx index 5d645e33..a8816dae 100644 --- a/apps/frontend/src/pages/admin/panels/FinancePanel.tsx +++ b/apps/frontend/src/pages/admin/panels/FinancePanel.tsx @@ -68,6 +68,7 @@ export default function FinancePanel() { const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [savingConfig, setSavingConfig] = useState(false) + const [configExpanded, setConfigExpanded] = useState(false) const [reviewing, setReviewing] = useState(false) const [reviewState, setReviewState] = useState<{ action: FinanceReviewAction @@ -207,14 +208,16 @@ export default function FinancePanel() { render: (_, row) => renderFinanceRequestAccount(row), }, { - title: '付款凭证', + title: '凭证/收款码', minWidth: 140, - render: (_, row) => - row.requestType === 'recharge' ? ( - - ) : ( - '-' - ), + render: (_, row) => ( + + ), }, { title: '备注', @@ -276,87 +279,102 @@ export default function FinancePanel() { bordered={false} loading={financeConfigQuery.isLoading} extra={ - + + + + } > - {financeConfigQuery.error ? ( + {configExpanded && financeConfigQuery.error ? ( {financeConfigQuery.error instanceof Error ? financeConfigQuery.error.message : '读取资金配置失败'} ) : null} -
- - - - - - - - - - - - - - - - - - - - + {configExpanded ? ( + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - 解冻期间如订单出现问题,可在"接单工单"中对已验收工单扣减待解冻押金(全额或部分)。 - - + + + + + + 解冻期间如订单出现问题,可在"接单工单"中对已验收工单扣减待解冻押金(全额或部分)。 + + - - - - - - - - + + + + + + + + - -
+ + + ) : null} {renderFinanceRequestAccountText(reviewState.request)} + {reviewState.request.requestType === 'withdraw' && + reviewState.request.accountChannel === 'alipay' ? ( + + {reviewState.request.accountNo ? ( + {reviewState.request.accountNo} + ) : ( + '-' + )} + + ) : null} {reviewState.request.requestType === 'recharge' ? ( <> @@ -473,6 +501,15 @@ export default function FinancePanel() { ) : null} + {reviewState.request.requestType === 'withdraw' && + reviewState.request.accountChannel === 'wechat' ? ( + + + + ) : null} {reviewState.request.note || '-'} @@ -551,7 +588,7 @@ function renderFinanceRequestAccount(request: WorkerFinanceRequest) {
{renderFinanceRequestAccountText(request)} {request.requestType === 'withdraw' && request.accountNo ? ( - {maskFinanceAccount(request.accountNo)} + {request.accountNo} ) : null}
) @@ -606,6 +643,25 @@ function getRechargeProofs(request: WorkerFinanceRequest): UploadedFile[] { .filter((item): item is UploadedFile => Boolean(item)) } +function getWechatWithdrawQrCode(request: WorkerFinanceRequest): UploadedFile[] { + if (request.requestType !== 'withdraw' || request.accountChannel !== 'wechat') return [] + const payload = asRecord(request.payload) + const file = asRecord(payload.wechatQrCodeImage) + const url = String(file.url || '').trim() + if (!url) return [] + return [ + { + url, + mediumUrl: String(file.mediumUrl || '').trim(), + thumbnailUrl: String(file.thumbnailUrl || '').trim(), + objectKey: String(file.objectKey || '').trim(), + filename: String(file.filename || '').trim(), + contentType: String(file.contentType || '').trim(), + size: Number(file.size || 0), + }, + ] +} + function getRechargePaidAt(request: WorkerFinanceRequest): string { const payload = asRecord(request.payload) return String(payload.paidAt || '').trim() diff --git a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx index 54ae141e..5a519009 100644 --- a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +++ b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx @@ -40,6 +40,7 @@ import ImageUpload from '@/components/files/ImageUpload' import { changeWorkerPassword, createWorkerRechargeRequest, + createWorkerWithdrawalAccount, createWorkerWithdrawRequest, fetchWorkerFinanceRequests, fetchWorkerProfile, @@ -66,10 +67,15 @@ type RechargeFormValues = { type WithdrawFormValues = { amount?: number + accountChannel?: string + note?: string +} + +type WithdrawalAccountFormValues = { accountChannel?: string accountName?: string accountNo?: string - note?: string + wechatQrCodeImages?: UploadedFile[] } type PasswordFormValues = { @@ -94,14 +100,19 @@ export default function WorkerProfilePage() { const [requestStatus, setRequestStatus] = useState('') const [rechargeOpen, setRechargeOpen] = useState(false) const [withdrawOpen, setWithdrawOpen] = useState(false) + const [withdrawalAccountOpen, setWithdrawalAccountOpen] = useState(false) const [passwordOpen, setPasswordOpen] = useState(false) const [contactOpen, setContactOpen] = useState(false) const [submittingRecharge, setSubmittingRecharge] = useState(false) const [submittingWithdraw, setSubmittingWithdraw] = useState(false) + const [submittingWithdrawalAccount, setSubmittingWithdrawalAccount] = useState(false) const [submittingPassword, setSubmittingPassword] = useState(false) const [rechargeForm] = Form.useForm() const [withdrawForm] = Form.useForm() + const [withdrawalAccountForm] = Form.useForm() const [passwordForm] = Form.useForm() + const selectedWithdrawChannel = Form.useWatch('accountChannel', withdrawForm) + const selectedWithdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm) const profileQuery = useQuery({ queryKey: ['worker-profile'], @@ -113,6 +124,10 @@ export default function WorkerProfilePage() { const summary = profileQuery.data?.data.summary const levelProgress = profileQuery.data?.data.levelProgress const financeConfig = profileQuery.data?.data.financeConfig + const withdrawalAccounts = profileQuery.data?.data.withdrawalAccounts || [] + const selectedWithdrawalAccount = withdrawalAccounts.find( + (account) => account.accountChannel === selectedWithdrawChannel, + ) const canUseFinanceActions = worker?.status === 'active' async function copyInviteCode() { @@ -191,9 +206,26 @@ export default function WorkerProfilePage() { message.warning('当前提现入口暂未开放,请联系管理员') return } + if (withdrawalAccounts.length === 0) { + openWithdrawalAccountModal() + return + } + withdrawForm.setFieldValue('accountChannel', withdrawalAccounts[0].accountChannel) setWithdrawOpen(true) } + function openWithdrawalAccountModal() { + const nextChannel = ['alipay', 'wechat'].find( + (channel) => !withdrawalAccounts.some((account) => account.accountChannel === channel), + ) + if (!nextChannel) { + message.info('支付宝和微信提现信息均已添加,修改请联系客服处理') + return + } + withdrawalAccountForm.setFieldValue('accountChannel', nextChannel) + setWithdrawalAccountOpen(true) + } + async function submitRecharge(values: RechargeFormValues) { setSubmittingRecharge(true) try { @@ -222,9 +254,7 @@ export default function WorkerProfilePage() { try { await createWorkerWithdrawRequest({ amount: values.amount, - accountChannel: values.accountChannel, - accountName: String(values.accountName || '').trim(), - accountNo: String(values.accountNo || '').trim(), + accountChannel: String(values.accountChannel || ''), note: String(values.note || '').trim(), }) message.success('提现申请已提交,请等待管理员审核') @@ -241,6 +271,28 @@ export default function WorkerProfilePage() { } } + async function submitWithdrawalAccount(values: WithdrawalAccountFormValues) { + setSubmittingWithdrawalAccount(true) + try { + await createWorkerWithdrawalAccount({ + accountChannel: String(values.accountChannel || ''), + accountName: String(values.accountName || '').trim(), + accountNo: String(values.accountNo || '').trim(), + wechatQrCodeImages: values.wechatQrCodeImages || [], + }) + message.success('提现信息已添加,该方式后续修改请联系客服处理') + setWithdrawalAccountOpen(false) + withdrawalAccountForm.resetFields() + await refreshAll() + withdrawForm.setFieldValue('accountChannel', String(values.accountChannel || '')) + setWithdrawOpen(true) + } catch (error) { + message.error(error instanceof Error ? error.message : '添加提现信息失败') + } finally { + setSubmittingWithdrawalAccount(false) + } + } + async function submitPasswordChange(values: PasswordFormValues) { setSubmittingPassword(true) try { @@ -1055,6 +1107,15 @@ export default function WorkerProfilePage() { > 提现申请 + {withdrawalAccounts.length < 2 ? ( + + ) : null} @@ -1122,6 +1183,37 @@ export default function WorkerProfilePage() { {formatMoney(summary?.pendingWithdrawAmount)} + + {withdrawalAccounts.length > 0 ? ( + + {withdrawalAccounts.map((account) => ( + + + {formatWithdrawChannel(account.accountChannel)} + + {account.accountName} + + {account.accountChannel === 'wechat' + ? '已上传收款二维码' + : account.accountNoMasked} + + + 已锁定,修改请联系客服 + + + ))} + + ) : ( + + )} + {formatMoney(worker.level?.permissions.depositFreeAmount || 0)} @@ -1387,6 +1479,69 @@ export default function WorkerProfilePage() { + { + setWithdrawalAccountOpen(false) + withdrawalAccountForm.resetFields() + }} + onOk={() => withdrawalAccountForm.submit()} + > + +
+ + + + {selectedWithdrawalAccountChannel === 'wechat' ? ( + + + + ) : ( + + + + )} +
+
+ withdrawForm.submit()} > -
+ {financeConfig?.withdraw.instructions ? ( + + + {formatWithdrawChannel(selectedWithdrawalAccount?.accountChannel || '')} ·{' '} + {selectedWithdrawalAccount?.accountName || '-'} ·{' '} + {selectedWithdrawalAccount?.accountChannel === 'wechat' + ? '收款二维码' + : selectedWithdrawalAccount?.accountNoMasked || '-'} + + {formatMoney(resolveAvailableForWithdraw(worker, summary))} - - - - - - + diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index d0290770..237a48e7 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -83,9 +83,7 @@ export function createWorkerRechargeRequest(payload: { export function createWorkerWithdrawRequest(payload: { amount?: number - accountChannel?: string - accountName?: string - accountNo?: string + accountChannel: string note?: string }) { return apiPost<{ request: WorkerFinanceRequest }>( @@ -94,6 +92,15 @@ export function createWorkerWithdrawRequest(payload: { ) } +export function createWorkerWithdrawalAccount(payload: { + accountChannel: string + accountName: string + accountNo?: string + wechatQrCodeImages?: UploadedFile[] +}) { + return apiPost('/api/v1/worker/profile/withdrawal-account', payload) +} + export function changeWorkerPassword(payload: { currentPassword: string; newPassword: string }) { return apiPost<{ worker: WorkerUser; reloginRequired: boolean }>( '/api/v1/worker/profile/change-password', diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 5cf5a283..e16b3ef7 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -105,12 +105,21 @@ export type WorkerFinanceConfig = { } } +export type WorkerWithdrawalAccount = { + accountChannel: string + accountName: string + accountNoMasked: string + wechatQrCodeImage: UploadedFile | null + createdAt: string | null +} + export type WorkerProfileResponse = { worker: WorkerUser permissions: Record summary: WorkerProfileSummary levelProgress: WorkerLevelProgress | null financeConfig: WorkerFinanceConfig + withdrawalAccounts: WorkerWithdrawalAccount[] /** 打手端订单详情是否展示流转记录(后台配置开关,默认关闭) */ orderTimelineVisible?: boolean }