拆分打手个人中心

This commit is contained in:
yml2213
2026-08-21 16:00:00 +08:00
parent 41f353b1ba
commit b7aa20f0ad
7 changed files with 2118 additions and 1675 deletions
@@ -0,0 +1,225 @@
import {
Alert,
Button,
Descriptions,
Form,
Image,
Input,
Modal,
Space,
Table,
Typography,
} from 'antd'
import type { FormInstance } from 'antd'
import type { WorkerFinanceConfig, WorkerSessionDevice, WorkerUser } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import { isWorkerPasswordStrong, WORKER_PASSWORD_RULE_MESSAGE } from '@/utils/worker-password'
import { formatMoney, formatWorkerStatus } from './worker-profile-view-utils'
export type PasswordFormValues = {
currentPassword?: string
newPassword?: string
confirmPassword?: string
}
export function WorkerProfileAccountModals({
isMobile,
worker,
financeConfig,
devicesOpen,
setDevicesOpen,
devices,
maxDevices,
devicesLoading,
removeWorkerDevice,
passwordOpen,
setPasswordOpen,
submittingPassword,
passwordForm,
submitPasswordChange,
contactOpen,
setContactOpen,
}: {
isMobile: boolean
worker: WorkerUser | undefined
financeConfig: WorkerFinanceConfig | undefined
devicesOpen: boolean
setDevicesOpen: (open: boolean) => void
devices: WorkerSessionDevice[]
maxDevices: number
devicesLoading: boolean
removeWorkerDevice: (device: WorkerSessionDevice) => Promise<void>
passwordOpen: boolean
setPasswordOpen: (open: boolean) => void
submittingPassword: boolean
passwordForm: FormInstance<PasswordFormValues>
submitPasswordChange: (values: PasswordFormValues) => Promise<void>
contactOpen: boolean
setContactOpen: (open: boolean) => void
}) {
return (
<>
<Modal
title="登录设备"
open={devicesOpen}
width={isMobile ? '92%' : 720}
destroyOnHidden
footer={null}
onCancel={() => setDevicesOpen(false)}
>
<Alert
showIcon
type="info"
message={`所有设备合计最多同时在线 ${maxDevices} 台,删除旧设备后才能在新设备登录。`}
/>
<Table<WorkerSessionDevice>
size="small"
loading={devicesLoading}
rowKey="sessionId"
dataSource={devices}
pagination={false}
style={{ marginTop: 16 }}
columns={[
{
title: '设备',
render: (_, item) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{item.deviceName || item.deviceType}</Typography.Text>
<Typography.Text type="secondary">{item.ipAddress || '未知 IP'}</Typography.Text>
</Space>
),
},
{ title: '最近活动', render: (_, item) => formatDateTime(item.lastSeenAt) },
{
title: '操作',
width: 100,
render: (_, item) => (
<Button
danger
type="link"
disabled={item.current}
onClick={() => void removeWorkerDevice(item)}
>
{item.current ? '当前设备' : '删除'}
</Button>
),
},
]}
/>
</Modal>
<Modal
title="修改密码"
open={passwordOpen}
width={isMobile ? '92%' : 480}
destroyOnHidden
confirmLoading={submittingPassword}
onCancel={() => {
setPasswordOpen(false)
passwordForm.resetFields()
}}
onOk={() => passwordForm.submit()}
>
<Form form={passwordForm} layout="vertical" onFinish={submitPasswordChange}>
<Form.Item
label="当前密码"
name="currentPassword"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item
label="新密码"
name="newPassword"
rules={[
{ required: true, message: '请输入新密码' },
{
validator: (_, value) =>
!value || isWorkerPasswordStrong(value)
? Promise.resolve()
: Promise.reject(new Error(WORKER_PASSWORD_RULE_MESSAGE)),
},
]}
extra={WORKER_PASSWORD_RULE_MESSAGE}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
label="确认新密码"
name="confirmPassword"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
return !value || value === getFieldValue('newPassword')
? Promise.resolve()
: Promise.reject(new Error('两次输入的新密码不一致'))
},
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
</Form>
</Modal>
<Modal
title="联系管理员"
open={contactOpen}
width={isMobile ? '92%' : 500}
destroyOnHidden
footer={
<Button type="primary" onClick={() => setContactOpen(false)}>
</Button>
}
onCancel={() => setContactOpen(false)}
>
<Space direction="vertical" size={16} className="full-width">
<Alert
showIcon
type="info"
message="如需取消已接订单,请扫码联系管理员处理。充值、提现与审核问题也由后台管理员人工处理。"
/>
{financeConfig?.adminContact?.wechatQrCodeImage ? (
<div style={{ textAlign: 'center' }}>
<Image
width={200}
src={
financeConfig.adminContact.wechatQrCodeImage.mediumUrl ||
financeConfig.adminContact.wechatQrCodeImage.url
}
alt="管理员微信二维码"
/>
</div>
) : (
<Typography.Text type="secondary">
</Typography.Text>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="账号">
<Typography.Text copyable>{worker?.username || '-'}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="昵称">{worker?.displayName || '-'}</Descriptions.Item>
<Descriptions.Item label="手机号">
<Typography.Text copyable>{worker?.phone || '-'}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="账号状态">
{formatWorkerStatus(worker?.status || '')}
</Descriptions.Item>
<Descriptions.Item label="当前余额">
{formatMoney(worker?.wallet.availableAmount)}
</Descriptions.Item>
</Descriptions>
<Typography.Paragraph className="worker-profile-help-text">
</Typography.Paragraph>
</Space>
</Modal>
</>
)
}
@@ -0,0 +1,286 @@
import {
BankOutlined,
CopyOutlined,
CustomerServiceOutlined,
GlobalOutlined,
LockOutlined,
LogoutOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { Avatar, Button, Card, Descriptions, Progress, Space, Tag, Typography } from 'antd'
import type {
WorkerLevelProgress,
WorkerProfileSummary,
WorkerUser,
WorkerWithdrawalAccount,
} from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import {
formatMoney,
formatWorkerStatus,
formatWithdrawChannel,
resolveWorkerInitial,
resolveWorkerStatusColor,
} from './worker-profile-view-utils'
export function WorkerProfileDesktopIdentity({
worker,
summary,
levelProgress,
onCopyInviteCode,
}: {
worker: WorkerUser
summary: WorkerProfileSummary | undefined
levelProgress: WorkerLevelProgress | null | undefined
onCopyInviteCode: () => void
}) {
return (
<Card size="small">
<Space size={16} wrap align="start">
<Avatar size={64}>{resolveWorkerInitial(worker)}</Avatar>
<Space direction="vertical" size={2}>
<Typography.Title level={4} style={{ margin: 0 }}>
{worker.displayName || worker.username}
</Typography.Title>
<Typography.Text type="secondary">{worker.username}</Typography.Text>
<Typography.Text type="secondary">{worker.phone || '-'}</Typography.Text>
</Space>
<Space size={[8, 8]} wrap>
<Tag color={resolveWorkerStatusColor(worker.status)}>
{formatWorkerStatus(worker.status)}
</Tag>
<Tag color={worker.workerType === 'internal' ? 'blue' : 'default'}>
{worker.workerType === 'internal' ? '内部打手' : '外部打手'}
</Tag>
<Tag>{worker.level?.name || '未分级'}</Tag>
</Space>
</Space>
<Space direction="vertical" size={4} style={{ marginTop: 12, width: '100%' }}>
<Space wrap size={[8, 8]}>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text strong>{worker.inviteCode || '-'}</Typography.Text>
<Button
size="small"
type="link"
icon={<CopyOutlined />}
disabled={!worker.inviteCode}
onClick={onCopyInviteCode}
>
</Button>
</Space>
{levelProgress && levelProgress.nextThreshold !== null ? (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text type="secondary">
{summary?.acceptedOrderCount || 0} {' '}
{Math.max(0, levelProgress.nextThreshold - (summary?.acceptedOrderCount || 0))}
</Typography.Text>
<Progress
percent={levelProgress.progressPercent}
size="small"
format={(percent) => `${percent}%`}
/>
</Space>
) : (
<Typography.Text type="secondary">
{summary?.acceptedOrderCount || 0}
</Typography.Text>
)}
</Space>
</Card>
)
}
export function WorkerProfileDesktopWallet({ worker }: { worker: WorkerUser }) {
const items = [
['可用余额', worker.wallet.availableAmount, '可用于抢单冻结或提现申请'],
['冻结押金', worker.wallet.frozenDepositAmount, '抢单后冻结,验收后进入待解冻'],
['待解冻押金', worker.wallet.pendingUnfreezeAmount, '验收后按配置天数解冻到账'],
['累计结算', worker.wallet.totalSettledAmount, '已结算入账的报酬合计'],
] as const
return (
<Card size="small" className="worker-profile-wallet-card">
<div className="worker-profile-wallet-strip">
{items.map(([label, amount, note]) => (
<div key={label} className="worker-profile-wallet-item">
<span className="worker-profile-wallet-label">{label}</span>
<strong className="worker-profile-wallet-value">{formatMoney(amount)}</strong>
<small className="worker-profile-wallet-note">{note}</small>
</div>
))}
</div>
</Card>
)
}
export function WorkerProfileDesktopActions({
canUseFinanceActions,
canAddWithdrawalAccount,
needsAlipayQrCode,
onRecharge,
onWithdraw,
onAddWithdrawalAccount,
onAddAlipayQrCode,
onChangePassword,
onManageDevices,
onContact,
onLogout,
}: {
canUseFinanceActions: boolean
canAddWithdrawalAccount: boolean
needsAlipayQrCode: boolean
onRecharge: () => void
onWithdraw: () => void
onAddWithdrawalAccount: () => void
onAddAlipayQrCode: () => void
onChangePassword: () => void
onManageDevices: () => void
onContact: () => void
onLogout: () => void
}) {
return (
<Card title="快捷操作" size="small">
<Space wrap size={[12, 12]}>
<Button
type="primary"
icon={<WalletOutlined />}
disabled={!canUseFinanceActions}
onClick={onRecharge}
>
</Button>
<Button
type="primary"
icon={<BankOutlined />}
disabled={!canUseFinanceActions}
onClick={onWithdraw}
>
</Button>
{canAddWithdrawalAccount ? (
<Button
icon={<BankOutlined />}
disabled={!canUseFinanceActions}
onClick={onAddWithdrawalAccount}
>
</Button>
) : null}
{needsAlipayQrCode ? (
<Button
icon={<BankOutlined />}
disabled={!canUseFinanceActions}
onClick={onAddAlipayQrCode}
>
</Button>
) : null}
<Button icon={<LockOutlined />} onClick={onChangePassword}>
</Button>
<Button icon={<GlobalOutlined />} onClick={onManageDevices}>
</Button>
<Button icon={<CustomerServiceOutlined />} onClick={onContact}>
</Button>
<Button danger icon={<LogoutOutlined />} onClick={onLogout}>
退
</Button>
</Space>
</Card>
)
}
export function WorkerProfileDesktopAccountDetails({
worker,
summary,
withdrawalAccounts,
canUseFinanceActions,
onCopyInviteCode,
onAddWithdrawalAccount,
}: {
worker: WorkerUser
summary: WorkerProfileSummary | undefined
withdrawalAccounts: WorkerWithdrawalAccount[]
canUseFinanceActions: boolean
onCopyInviteCode: () => void
onAddWithdrawalAccount: () => void
}) {
return (
<Card size="small">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="账号">{worker.username}</Descriptions.Item>
<Descriptions.Item label="昵称">{worker.displayName || '-'}</Descriptions.Item>
<Descriptions.Item label="手机号">{worker.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="账号状态">
<Tag color={resolveWorkerStatusColor(worker.status)}>
{formatWorkerStatus(worker.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="当前等级">{worker.level?.name || '-'}</Descriptions.Item>
<Descriptions.Item label="我的邀请码">
<Space size={4}>
{worker.inviteCode || '-'}
{worker.inviteCode ? (
<Button size="small" type="link" icon={<CopyOutlined />} onClick={onCopyInviteCode} />
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="邀请人">
{worker.inviter ? worker.inviter.displayName || worker.inviter.username : '-'}
</Descriptions.Item>
<Descriptions.Item label="已累计完成">
{summary?.acceptedOrderCount || 0}
</Descriptions.Item>
<Descriptions.Item label="超时订单">{summary?.timeoutOrderCount || 0} </Descriptions.Item>
<Descriptions.Item label="累计充值">
{formatMoney(worker.wallet.totalCreditedAmount)}
</Descriptions.Item>
<Descriptions.Item label="提现中金额">
{formatMoney(summary?.pendingWithdrawAmount)}
</Descriptions.Item>
<Descriptions.Item label="提现信息" span={2}>
{withdrawalAccounts.length > 0 ? (
<Space direction="vertical" size={2}>
{withdrawalAccounts.map((account) => (
<Space key={account.accountChannel} wrap size={4}>
<Typography.Text>{formatWithdrawChannel(account.accountChannel)}</Typography.Text>
<Typography.Text>{account.accountName}</Typography.Text>
<Typography.Text type="secondary">
{account.accountChannel === 'wechat'
? '已上传收款二维码'
: `${account.accountNoMasked} / ${account.alipayQrCodeImage ? '已上传收款二维码' : '未上传收款二维码'}`}
</Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
</Space>
))}
</Space>
) : (
<Button
type="link"
size="small"
disabled={!canUseFinanceActions}
onClick={onAddWithdrawalAccount}
>
</Button>
)}
</Descriptions.Item>
<Descriptions.Item label="免押额度">
{formatMoney(worker.level?.permissions.depositFreeAmount || 0)}
</Descriptions.Item>
<Descriptions.Item label="最大同时接单">
{worker.level?.permissions.maxActiveOrders || 0}
</Descriptions.Item>
<Descriptions.Item label="审核备注">{worker.reviewNote || '-'}</Descriptions.Item>
<Descriptions.Item label="注册时间">{formatDateTime(worker.createdAt)}</Descriptions.Item>
<Descriptions.Item label="最近更新时间">
{formatDateTime(worker.updatedAt)}
</Descriptions.Item>
</Descriptions>
</Card>
)
}
@@ -0,0 +1,393 @@
import {
Alert,
DatePicker,
Descriptions,
Form,
Image,
Input,
InputNumber,
Modal,
Select,
Space,
Typography,
} from 'antd'
import type { FormInstance } from 'antd'
import ImageUpload from '@/components/files/ImageUpload'
import type {
UploadedFile,
WorkerFinanceConfig,
WorkerProfileSummary,
WorkerUser,
WorkerWithdrawalAccount,
} from '@/types/worker-platform'
import {
formatMoney,
formatWithdrawChannel,
hasRechargeConfig,
resolveAvailableForWithdraw,
} from './worker-profile-view-utils'
type RechargeFormValues = {
amount?: number
note?: string
paidAt?: string
proofFiles?: UploadedFile[]
}
type WithdrawFormValues = {
amount?: number
accountChannel?: string
note?: string
}
type WithdrawalAccountFormValues = {
accountChannel?: string
accountName?: string
accountNo?: string
alipayQrCodeImages?: UploadedFile[]
wechatQrCodeImages?: UploadedFile[]
}
type AlipayQrCodeFormValues = {
alipayQrCodeImages?: UploadedFile[]
}
export function WorkerProfileFinanceModals({
isMobile,
worker,
summary,
financeConfig,
withdrawalAccounts,
selectedWithdrawalAccount,
selectedWithdrawChannel,
selectedWithdrawalAccountChannel,
rechargeOpen,
setRechargeOpen,
submittingRecharge,
rechargeForm,
submitRecharge,
withdrawalAccountOpen,
setWithdrawalAccountOpen,
submittingWithdrawalAccount,
withdrawalAccountForm,
submitWithdrawalAccount,
alipayQrCodeOpen,
setAlipayQrCodeOpen,
alipayQrCodeForm,
submitAlipayQrCode,
withdrawOpen,
setWithdrawOpen,
submittingWithdraw,
withdrawForm,
submitWithdraw,
}: {
isMobile: boolean
worker: WorkerUser | undefined
summary: WorkerProfileSummary | undefined
financeConfig: WorkerFinanceConfig | undefined
withdrawalAccounts: WorkerWithdrawalAccount[]
selectedWithdrawalAccount: WorkerWithdrawalAccount | undefined
selectedWithdrawChannel: string | undefined
selectedWithdrawalAccountChannel: string | undefined
rechargeOpen: boolean
setRechargeOpen: (open: boolean) => void
submittingRecharge: boolean
rechargeForm: FormInstance<RechargeFormValues>
submitRecharge: (values: RechargeFormValues) => Promise<void>
withdrawalAccountOpen: boolean
setWithdrawalAccountOpen: (open: boolean) => void
submittingWithdrawalAccount: boolean
withdrawalAccountForm: FormInstance<WithdrawalAccountFormValues>
submitWithdrawalAccount: (values: WithdrawalAccountFormValues) => Promise<void>
alipayQrCodeOpen: boolean
setAlipayQrCodeOpen: (open: boolean) => void
alipayQrCodeForm: FormInstance<AlipayQrCodeFormValues>
submitAlipayQrCode: (values: AlipayQrCodeFormValues) => Promise<void>
withdrawOpen: boolean
setWithdrawOpen: (open: boolean) => void
submittingWithdraw: boolean
withdrawForm: FormInstance<WithdrawFormValues>
submitWithdraw: (values: WithdrawFormValues) => Promise<void>
}) {
return (
<>
<Modal
title="充值申请"
open={rechargeOpen}
width={isMobile ? '92%' : 540}
destroyOnHidden
confirmLoading={submittingRecharge}
onCancel={() => {
setRechargeOpen(false)
rechargeForm.resetFields()
}}
onOk={() => rechargeForm.submit()}
>
<Form
form={rechargeForm}
layout="vertical"
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"
rules={[{ required: true, message: '请输入充值金额' }]}
>
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
</Form.Item>
<Form.Item
label="付款时间"
name="paidAt"
rules={[{ required: true, message: '请选择付款时间' }]}
>
<DatePicker
showTime
style={{ width: '100%' }}
placeholder="选择转账完成时间"
format="YYYY-MM-DD HH:mm"
/>
</Form.Item>
<Form.Item
label="付款凭证"
name="proofFiles"
extra="上传转账成功截图,便于管理员快速核对入账"
>
<ImageUpload scene="worker-finance-proof" scope="worker" maxCount={5} />
</Form.Item>
<Form.Item label="备注说明" name="note">
<Input.TextArea
rows={4}
placeholder="填写转账备注、支付时间、截图说明等,方便管理员尽快核对。"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="添加提现信息"
open={withdrawalAccountOpen}
width={isMobile ? '92%' : 500}
destroyOnHidden
confirmLoading={submittingWithdrawalAccount}
onCancel={() => {
setWithdrawalAccountOpen(false)
withdrawalAccountForm.resetFields()
}}
onOk={() => withdrawalAccountForm.submit()}
>
<Alert
showIcon
type="warning"
message="支付宝和微信可分别添加一次;收款人姓名和账号添加后不可自行修改。历史支付宝账户缺少二维码时,仅可补充一次。"
style={{ marginBottom: 16 }}
/>
<Form form={withdrawalAccountForm} layout="vertical" onFinish={submitWithdrawalAccount}>
<Form.Item
label="提现方式"
name="accountChannel"
rules={[{ required: true, message: '请选择提现方式' }]}
>
<Select
options={[
...(!withdrawalAccounts.some((account) => account.accountChannel === 'alipay')
? [{ value: 'alipay', label: '支付宝' }]
: []),
...(!withdrawalAccounts.some((account) => account.accountChannel === 'wechat')
? [{ value: 'wechat', label: '微信收款(仅支持 100 元及以下提现)' }]
: []),
]}
/>
</Form.Item>
<Form.Item
label="收款人姓名"
name="accountName"
rules={[{ required: true, message: '请输入收款人姓名' }]}
>
<Input placeholder="请输入真实姓名" />
</Form.Item>
{selectedWithdrawalAccountChannel === 'wechat' ? (
<Form.Item
label="微信收款二维码"
name="wechatQrCodeImages"
rules={[{ required: true, message: '请上传微信收款二维码' }]}
extra="微信提现仅使用收款二维码,不需要填写微信账号。"
>
<ImageUpload scene="worker-withdrawal-wechat-qr" scope="worker" maxCount={1} />
</Form.Item>
) : (
<>
<Form.Item
label="支付宝账号"
name="accountNo"
rules={[{ required: true, message: '请输入支付宝账号' }]}
>
<Input placeholder="请输入支付宝账号" />
</Form.Item>
<Form.Item
label="支付宝收款二维码"
name="alipayQrCodeImages"
rules={[{ required: true, message: '请上传支付宝收款二维码' }]}
>
<ImageUpload scene="worker-withdrawal-alipay-qr" scope="worker" maxCount={1} />
</Form.Item>
</>
)}
</Form>
</Modal>
<Modal
title="补充支付宝收款二维码"
open={alipayQrCodeOpen}
width={isMobile ? '92%' : 500}
destroyOnHidden
onCancel={() => {
setAlipayQrCodeOpen(false)
alipayQrCodeForm.resetFields()
}}
onOk={() => alipayQrCodeForm.submit()}
>
<Alert
showIcon
type="info"
message="历史账户仅可补充一次支付宝收款二维码,保存后不可自行修改;已登记的收款人姓名和账号保持锁定。"
style={{ marginBottom: 16 }}
/>
<Form form={alipayQrCodeForm} layout="vertical" onFinish={submitAlipayQrCode}>
<Form.Item
label="支付宝收款二维码"
name="alipayQrCodeImages"
rules={[{ required: true, message: '请上传支付宝收款二维码' }]}
>
<ImageUpload scene="worker-withdrawal-alipay-qr" scope="worker" maxCount={1} />
</Form.Item>
</Form>
</Modal>
<Modal
title="提现申请"
open={withdrawOpen}
width={isMobile ? '92%' : 540}
destroyOnHidden
confirmLoading={submittingWithdraw}
onCancel={() => {
setWithdrawOpen(false)
withdrawForm.resetFields()
}}
onOk={() => withdrawForm.submit()}
>
<Form form={withdrawForm} layout="vertical" onFinish={submitWithdraw}>
{financeConfig?.withdraw.instructions ? (
<Alert
showIcon
type="info"
message={financeConfig.withdraw.instructions}
style={{ marginBottom: 16 }}
/>
) : null}
<Alert
showIcon
type="warning"
message={`每天最多提现 3 次(支付宝、微信共享次数);100 元及以下支持微信和支付宝,超过 100 元仅支持支付宝;有押金的工单验收通过后,押金需 ${Number(financeConfig?.depositUnfreezeDays ?? 3)} 天解冻到账后方可提现。`}
style={{ marginBottom: 16 }}
/>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="提现账户">
{formatWithdrawChannel(selectedWithdrawalAccount?.accountChannel || '')} ·{' '}
{selectedWithdrawalAccount?.accountName || '-'} ·{' '}
{selectedWithdrawalAccount?.accountChannel === 'wechat'
? '收款二维码'
: selectedWithdrawalAccount?.accountNoMasked || '-'}
</Descriptions.Item>
</Descriptions>
<Form.Item label="可提现余额">
<Typography.Text strong>
{formatMoney(resolveAvailableForWithdraw(worker, summary))}
</Typography.Text>
</Form.Item>
<Form.Item
label="提现方式"
name="accountChannel"
rules={[{ required: true, message: '请选择提现方式' }]}
>
<Select
options={withdrawalAccounts.map((account) => ({
value: account.accountChannel,
label:
account.accountChannel === 'wechat'
? '微信收款 · 收款二维码'
: `${formatWithdrawChannel(account.accountChannel)} · ${account.accountNoMasked}`,
}))}
/>
</Form.Item>
<Form.Item
label="提现金额"
name="amount"
rules={[
{ required: true, message: '请输入提现金额' },
{
validator(_, value) {
return Number(value || 0) > 100 && selectedWithdrawChannel !== 'alipay'
? Promise.reject(
new Error('超过 100 元仅支持支付宝提现,请联系客服修改提现信息'),
)
: Promise.resolve()
},
},
]}
>
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
</Form.Item>
<Form.Item label="备注说明" name="note">
<Input.TextArea rows={4} placeholder="可补充到账要求、手机号、开户行等说明。" />
</Form.Item>
</Form>
</Modal>
</>
)
}
@@ -0,0 +1,369 @@
import {
BankOutlined,
CopyOutlined,
CustomerServiceOutlined,
GlobalOutlined,
LockOutlined,
LogoutOutlined,
ReloadOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { Alert, Avatar, Button, Progress, Tag } from 'antd'
import type { ReactNode } from 'react'
import type { WorkerLevelProgress, WorkerProfileSummary, WorkerUser } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import {
formatMoney,
formatWorkerStatus,
resolveWorkerInitial,
resolveWorkerStatusColor,
} from './worker-profile-view-utils'
type WorkerProfileMobileViewProps = {
worker: WorkerUser
summary: WorkerProfileSummary | undefined
levelProgress: WorkerLevelProgress | null | undefined
canUseFinanceActions: boolean
needsAlipayQrCode: boolean
activeTab: string
loading: boolean
ledgerRecords: ReactNode
requestRecords: ReactNode
onActiveTabChange: (tab: string) => void
onRefresh: () => void
onCopyInviteCode: () => void
onRecharge: () => void
onWithdraw: () => void
onChangePassword: () => void
onManageDevices: () => void
onContact: () => void
onAddAlipayQrCode: () => void
onLogout: () => void
}
export function WorkerProfileMobileView({
worker,
summary,
levelProgress,
canUseFinanceActions,
needsAlipayQrCode,
activeTab,
loading,
ledgerRecords,
requestRecords,
onActiveTabChange,
onRefresh,
onCopyInviteCode,
onRecharge,
onWithdraw,
onChangePassword,
onManageDevices,
onContact,
onAddAlipayQrCode,
onLogout,
}: WorkerProfileMobileViewProps) {
return (
<div className="worker-profile-mobile-page">
<div className="worker-profile-mobile-hero">
<div className="worker-profile-mobile-user-row">
<Avatar size={52} className="worker-profile-mobile-avatar">
{resolveWorkerInitial(worker)}
</Avatar>
<div className="worker-profile-mobile-user-info">
<div className="worker-profile-mobile-name-row">
<strong className="worker-profile-mobile-name">
{worker.displayName || worker.username}
</strong>
</div>
<div className="worker-profile-mobile-tags-row">
<span className="worker-profile-mobile-vip-badge">
{worker.level?.name || '未分级'}
</span>
<Tag
color={resolveWorkerStatusColor(worker.status)}
className="worker-profile-mobile-tag"
>
{formatWorkerStatus(worker.status)}
</Tag>
<Tag className="worker-profile-mobile-tag">
{worker.workerType === 'internal' ? '内部打手' : '外部打手'}
</Tag>
</div>
<div className="worker-profile-mobile-account-text">
: {worker.username} {worker.phone ? `· ${worker.phone}` : ''}
</div>
</div>
<Button
type="text"
shape="circle"
icon={<ReloadOutlined />}
className="worker-profile-mobile-refresh-btn"
loading={loading}
onClick={onRefresh}
/>
</div>
<div className="worker-profile-mobile-hero-bottom">
<div className="worker-profile-mobile-invite-bar">
<span className="worker-profile-mobile-invite-label">:</span>
<strong className="worker-profile-mobile-invite-code">
{worker.inviteCode || '-'}
</strong>
{worker.inviteCode ? (
<button
type="button"
className="worker-profile-mobile-copy-btn"
onClick={onCopyInviteCode}
>
<CopyOutlined />
</button>
) : null}
</div>
{levelProgress && levelProgress.nextThreshold !== null ? (
<div className="worker-profile-mobile-level-box">
<div className="worker-profile-mobile-level-text">
{summary?.acceptedOrderCount || 0} · {' '}
{Math.max(0, levelProgress.nextThreshold - (summary?.acceptedOrderCount || 0))}
</div>
<Progress
percent={levelProgress.progressPercent}
size="small"
strokeColor={{ '0%': '#f97316', '100%': '#fbbf24' }}
showInfo={false}
/>
</div>
) : (
<div className="worker-profile-mobile-level-text">
{summary?.acceptedOrderCount || 0} ·
</div>
)}
</div>
</div>
{!canUseFinanceActions ? (
<Alert
showIcon
type="warning"
message="当前账号尚未通过审核,充值申请、提现申请和接单功能暂不可用。"
/>
) : null}
<div className="worker-profile-mobile-wallet-card">
<div className="worker-profile-mobile-wallet-top">
<div className="worker-profile-mobile-wallet-main">
<span className="worker-profile-mobile-wallet-title"></span>
<strong className="worker-profile-mobile-wallet-amount">
{formatMoney(worker.wallet.availableAmount)}
</strong>
</div>
<div className="worker-profile-mobile-wallet-actions">
<Button
type="primary"
size="middle"
icon={<WalletOutlined />}
disabled={!canUseFinanceActions}
className="worker-profile-mobile-recharge-btn"
onClick={onRecharge}
>
</Button>
<Button
size="middle"
icon={<BankOutlined />}
disabled={!canUseFinanceActions}
className="worker-profile-mobile-withdraw-btn"
onClick={onWithdraw}
>
</Button>
</div>
</div>
<div className="worker-profile-mobile-wallet-grid">
<MobileWalletItem label="冻结押金" amount={worker.wallet.frozenDepositAmount} />
<MobileWalletItem label="待解冻押金" amount={worker.wallet.pendingUnfreezeAmount} />
<MobileWalletItem label="累计结算" amount={worker.wallet.totalSettledAmount} />
</div>
</div>
<div className="worker-profile-mobile-tools-card">
<MobileTool
label="修改密码"
icon={<LockOutlined />}
color="#3b82f6"
background="#eff6ff"
onClick={onChangePassword}
/>
<MobileTool
label="登录设备"
icon={<GlobalOutlined />}
color="#0f766e"
background="#f0fdfa"
onClick={onManageDevices}
/>
<MobileTool
label="联系客服"
icon={<CustomerServiceOutlined />}
color="#16a34a"
background="#f0fdf4"
onClick={onContact}
/>
<MobileTool
label="邀请好友"
icon={<CopyOutlined />}
color="#d97706"
background="#fef3c7"
disabled={!worker.inviteCode}
onClick={onCopyInviteCode}
/>
{needsAlipayQrCode ? (
<MobileTool
label="补充支付宝收款码"
icon={<BankOutlined />}
color="#7c3aed"
background="#f5f3ff"
disabled={!canUseFinanceActions}
onClick={onAddAlipayQrCode}
/>
) : null}
<MobileTool
label="退出登录"
icon={<LogoutOutlined />}
color="#ef4444"
background="#fef2f2"
onClick={onLogout}
/>
</div>
<div className="worker-profile-mobile-sections-card">
<div className="worker-profile-mobile-tab-nav">
<MobileTab
active={activeTab === 'ledger'}
label="资金流水"
onClick={() => onActiveTabChange('ledger')}
/>
<MobileTab
active={activeTab === 'requests'}
label="申请记录"
onClick={() => onActiveTabChange('requests')}
/>
<MobileTab
active={activeTab === 'account'}
label="账号详情"
onClick={() => onActiveTabChange('account')}
/>
</div>
<div className="worker-profile-mobile-tab-body">
{activeTab === 'ledger' ? ledgerRecords : null}
{activeTab === 'requests' ? requestRecords : null}
{activeTab === 'account' ? (
<WorkerProfileMobileAccountDetails worker={worker} summary={summary} />
) : null}
</div>
</div>
</div>
)
}
function MobileWalletItem({ label, amount }: { label: string; amount: number }) {
return (
<div className="worker-profile-mobile-wallet-col">
<span className="worker-profile-mobile-sub-label">{label}</span>
<strong className="worker-profile-mobile-sub-val">{formatMoney(amount)}</strong>
</div>
)
}
function MobileTool({
label,
icon,
color,
background,
disabled = false,
onClick,
}: {
label: string
icon: ReactNode
color: string
background: string
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
className="worker-profile-mobile-tool-item"
disabled={disabled}
onClick={onClick}
>
<div className="worker-profile-mobile-tool-icon" style={{ background, color }}>
{icon}
</div>
<span>{label}</span>
</button>
)
}
function MobileTab({
active,
label,
onClick,
}: {
active: boolean
label: string
onClick: () => void
}) {
return (
<button
type="button"
className={`worker-profile-mobile-tab-btn ${active ? 'active' : ''}`}
onClick={onClick}
>
{label}
</button>
)
}
function WorkerProfileMobileAccountDetails({
worker,
summary,
}: {
worker: WorkerUser
summary: WorkerProfileSummary | undefined
}) {
const items: Array<[string, ReactNode]> = [
['账号', worker.username],
['昵称', worker.displayName || '-'],
['手机号', worker.phone || '-'],
[
'账号状态',
<Tag key="worker-status" color={resolveWorkerStatusColor(worker.status)}>
{formatWorkerStatus(worker.status)}
</Tag>,
],
['当前等级', worker.level?.name || '-'],
['免押额度', formatMoney(worker.level?.permissions.depositFreeAmount || 0)],
['最大同时接单', `${worker.level?.permissions.maxActiveOrders || 0}`],
['邀请人', worker.inviter ? worker.inviter.displayName || worker.inviter.username : '-'],
['已完成订单', `${summary?.acceptedOrderCount || 0}`],
['累计充值', formatMoney(worker.wallet.totalCreditedAmount)],
['注册时间', formatDateTime(worker.createdAt)],
]
return (
<div className="worker-profile-mobile-tab-pane">
<div className="worker-profile-mobile-account-list">
{items.map(([label, value]) => (
<div key={label} className="worker-profile-mobile-account-item">
<span className="worker-profile-mobile-acc-label">{label}</span>
<span className="worker-profile-mobile-acc-val">{value}</span>
</div>
))}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,368 @@
import { ReloadOutlined } from '@ant-design/icons'
import { Button, Card, Empty, Pagination, Select, Space, Spin, Table, Tag } from 'antd'
import type {
WorkerFinanceRequest,
WorkerListResponse,
WorkerWalletLedger,
} from '@/types/worker-platform'
import type { ApiEnvelope } from '@/lib/http'
import { formatDateTime } from '@/utils/date-time'
import {
formatLedgerType,
formatMoney,
formatRequestStatus,
formatRequestType,
formatWithdrawChannel,
maskAccountNo,
renderRechargeRequestSummary,
resolveLedgerTagColor,
resolveRequestStatusColor,
workerFinanceRequestColumns,
workerLedgerColumns,
} from './worker-profile-view-utils'
const LEDGER_TYPE_OPTIONS = [
{ value: '', label: '全部流水' },
{ value: 'manual_credit', label: '人工充值' },
{ value: 'withdraw_paid', label: '提现打款' },
{ value: 'deposit_freeze', label: '冻结押金' },
{ value: 'deposit_release', label: '释放押金' },
{ value: 'deposit_pending_unfreeze', label: '押金待解冻' },
{ value: 'deposit_unfreeze', label: '押金已解冻' },
{ value: 'deposit_deduction', label: '扣除押金' },
{ value: 'reward_settlement', label: '结算报酬' },
]
const REQUEST_TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'recharge', label: '充值申请' },
{ value: 'withdraw', label: '提现申请' },
]
const REQUEST_STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
]
type LedgerRecordsProps = {
data: ApiEnvelope<WorkerListResponse<WorkerWalletLedger>> | undefined
error: unknown
loading: boolean
refreshing: boolean
ledgerType: string
onLedgerTypeChange: (ledgerType: string) => void
onReload: () => void
page: number
pageSize: number
onPageChange: (page: number, pageSize: number) => void
}
type RequestRecordsProps = {
data: ApiEnvelope<WorkerListResponse<WorkerFinanceRequest>> | undefined
error: unknown
loading: boolean
refreshing: boolean
requestType: string
requestStatus: string
onRequestTypeChange: (requestType: string) => void
onRequestStatusChange: (requestStatus: string) => void
onReload: () => void
page: number
pageSize: number
onPageChange: (page: number, pageSize: number) => void
}
export function WorkerProfileMobileLedgerRecords({
data,
loading,
refreshing,
ledgerType,
onLedgerTypeChange,
onReload,
page,
pageSize,
onPageChange,
}: LedgerRecordsProps) {
const items = data?.data.items || []
const pagination = data?.data.pagination
return (
<div className="worker-profile-mobile-tab-pane">
<div className="worker-profile-mobile-filter-row">
<Select
size="small"
value={ledgerType}
style={{ width: 140 }}
options={LEDGER_TYPE_OPTIONS}
onChange={onLedgerTypeChange}
/>
<Button size="small" icon={<ReloadOutlined />} loading={refreshing} onClick={onReload}>
</Button>
</div>
<div className="worker-profile-mobile-records">
{loading ? (
<div className="worker-hall-loading">
<Spin size="large" />
</div>
) : items.length === 0 ? (
<Empty description="暂无钱包流水" />
) : (
items.map((row) => (
<div key={row.ledgerId} className="worker-profile-record-card">
<div className="worker-profile-record-row-head">
<Tag color={resolveLedgerTagColor(row.ledgerType)}>
{formatLedgerType(row.ledgerType)}
</Tag>
<strong
className={row.amount >= 0 ? 'worker-amount-positive' : 'worker-amount-negative'}
style={{ fontSize: 16 }}
>
{row.amount >= 0 ? '+' : ''}
{formatMoney(row.amount)}
</strong>
</div>
<div className="worker-profile-record-row-meta">
<span>: {formatMoney(row.balanceAfter)}</span>
<span>: {formatMoney(row.frozenAfter)}</span>
</div>
{row.note ? <div className="worker-profile-record-note">: {row.note}</div> : null}
{row.relatedPlatformOrderId ? (
<div className="worker-profile-record-sub"> {row.relatedPlatformOrderId}</div>
) : null}
<div className="worker-profile-record-time">
{formatDateTime(String(row.createdAt || ''))}
</div>
</div>
))
)}
{(pagination?.total || 0) > 0 ? (
<div className="worker-orders-mobile-pagination">
<Pagination
current={pagination?.page || page}
pageSize={pagination?.pageSize || pageSize}
total={pagination?.total || 0}
simple
onChange={onPageChange}
/>
</div>
) : null}
</div>
</div>
)
}
export function WorkerProfileMobileRequestRecords({
data,
loading,
refreshing,
requestType,
requestStatus,
onRequestTypeChange,
onRequestStatusChange,
onReload,
page,
pageSize,
onPageChange,
}: RequestRecordsProps) {
const items = data?.data.items || []
const pagination = data?.data.pagination
return (
<div className="worker-profile-mobile-tab-pane">
<div className="worker-profile-mobile-filter-row">
<Space size={6} wrap>
<Select
size="small"
value={requestType}
style={{ width: 110 }}
options={REQUEST_TYPE_OPTIONS}
onChange={onRequestTypeChange}
/>
<Select
size="small"
value={requestStatus}
style={{ width: 110 }}
options={REQUEST_STATUS_OPTIONS}
onChange={onRequestStatusChange}
/>
<Button size="small" icon={<ReloadOutlined />} loading={refreshing} onClick={onReload} />
</Space>
</div>
<div className="worker-profile-mobile-records">
{loading ? (
<div className="worker-hall-loading">
<Spin size="large" />
</div>
) : items.length === 0 ? (
<Empty description="暂无申请记录" />
) : (
items.map((row) => (
<div key={row.requestId} className="worker-profile-record-card">
<div className="worker-profile-record-row-head">
<Space size={4}>
<Tag color={row.requestType === 'withdraw' ? 'green' : 'blue'}>
{formatRequestType(row.requestType)}
</Tag>
<Tag color={resolveRequestStatusColor(row.status)}>
{formatRequestStatus(row.status)}
</Tag>
</Space>
<strong style={{ fontSize: 16 }}>{formatMoney(row.amount)}</strong>
</div>
{row.requestType === 'withdraw' ? (
<div className="worker-profile-record-row-meta">
: {formatWithdrawChannel(row.accountChannel)} / {row.accountName || '-'} (
{maskAccountNo(row.accountNo)})
</div>
) : (
<div className="worker-profile-record-row-meta">
{renderRechargeRequestSummary(row)}
</div>
)}
{row.note ? <div className="worker-profile-record-note">: {row.note}</div> : null}
{row.reviewedNote ? (
<div className="worker-profile-record-note" style={{ color: '#d97706' }}>
: {row.reviewedNote}
</div>
) : null}
<div className="worker-profile-record-time">
{formatDateTime(String(row.createdAt || ''))}
</div>
</div>
))
)}
{(pagination?.total || 0) > 0 ? (
<div className="worker-orders-mobile-pagination">
<Pagination
current={pagination?.page || page}
pageSize={pagination?.pageSize || pageSize}
total={pagination?.total || 0}
simple
onChange={onPageChange}
/>
</div>
) : null}
</div>
</div>
)
}
export function WorkerProfileDesktopLedgerRecords({
data,
error,
loading,
refreshing,
ledgerType,
onLedgerTypeChange,
onReload,
page,
pageSize,
onPageChange,
}: LedgerRecordsProps) {
const pagination = data?.data.pagination
return (
<Card
size="small"
extra={
<Space wrap>
<Select
value={ledgerType}
style={{ width: 170 }}
options={LEDGER_TYPE_OPTIONS}
onChange={onLedgerTypeChange}
/>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={onReload}>
</Button>
</Space>
}
>
<Table<WorkerWalletLedger>
rowKey="ledgerId"
size="small"
loading={loading}
dataSource={data?.data.items || []}
columns={workerLedgerColumns}
locale={{ emptyText: resolveEmptyText(error, '读取资金流水失败', '暂无钱包流水') }}
pagination={{
current: pagination?.page || page,
pageSize: pagination?.pageSize || pageSize,
total: pagination?.total || 0,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
showTotal: (total) => `${total}`,
onChange: onPageChange,
}}
scroll={{ x: 980 }}
/>
</Card>
)
}
export function WorkerProfileDesktopRequestRecords({
data,
error,
loading,
refreshing,
requestType,
requestStatus,
onRequestTypeChange,
onRequestStatusChange,
onReload,
page,
pageSize,
onPageChange,
}: RequestRecordsProps) {
const pagination = data?.data.pagination
return (
<Card
size="small"
extra={
<Space wrap>
<Select
value={requestType}
style={{ width: 140 }}
options={REQUEST_TYPE_OPTIONS}
onChange={onRequestTypeChange}
/>
<Select
value={requestStatus}
style={{ width: 140 }}
options={REQUEST_STATUS_OPTIONS}
onChange={onRequestStatusChange}
/>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={onReload}>
</Button>
</Space>
}
>
<Table<WorkerFinanceRequest>
rowKey="requestId"
size="small"
loading={loading}
dataSource={data?.data.items || []}
columns={workerFinanceRequestColumns}
locale={{ emptyText: resolveEmptyText(error, '读取申请记录失败', '暂无申请记录') }}
pagination={{
current: pagination?.page || page,
pageSize: pagination?.pageSize || pageSize,
total: pagination?.total || 0,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
showTotal: (total) => `${total}`,
onChange: onPageChange,
}}
scroll={{ x: 980 }}
/>
</Card>
)
}
function resolveEmptyText(error: unknown, fallback: string, emptyText: string) {
if (!error) return emptyText
return error instanceof Error ? error.message : fallback
}
@@ -0,0 +1,259 @@
import { Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import type {
WorkerFinanceRequest,
WorkerProfileSummary,
WorkerUser,
WorkerWalletLedger,
} from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
export function resolveAvailableForWithdraw(
worker: WorkerUser | undefined,
summary: WorkerProfileSummary | undefined,
) {
return Math.max(
0,
Number(worker?.wallet.availableAmount || 0) - Number(summary?.pendingWithdrawAmount || 0),
)
}
export function resolveWorkerInitial(worker: WorkerUser | undefined) {
return String(worker?.displayName || worker?.username || '打')
.trim()
.slice(0, 1)
.toUpperCase()
}
export function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
export function formatWorkerStatus(status: string) {
if (status === 'active') return '已启用'
if (status === 'pending_review') return '待处理'
if (status === 'rejected') return '已冻结'
if (status === 'disabled') return '已停用'
return status || '-'
}
export function resolveWorkerStatusColor(status: string) {
if (status === 'active') return 'green'
if (status === 'pending_review') return 'gold'
if (status === 'rejected') return 'red'
if (status === 'disabled') return 'default'
return 'blue'
}
export function formatLedgerType(ledgerType: string) {
const labels: Record<string, string> = {
manual_credit: '人工充值',
withdraw_paid: '提现打款',
deposit_freeze: '冻结押金',
deposit_release: '释放押金',
deposit_pending_unfreeze: '押金待解冻',
deposit_unfreeze: '押金已解冻',
deposit_deduction: '扣除押金',
reward_settlement: '结算报酬',
}
return labels[ledgerType] || ledgerType || '-'
}
export 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_pending_unfreeze') return 'orange'
if (ledgerType === 'deposit_unfreeze') return 'green'
if (ledgerType === 'deposit_deduction') return 'red'
if (ledgerType === 'reward_settlement') return 'cyan'
return 'default'
}
export function formatRequestType(requestType: string) {
if (requestType === 'recharge') return '充值申请'
if (requestType === 'withdraw') return '提现申请'
return requestType || '-'
}
export function formatRequestStatus(status: string) {
const labels: Record<string, string> = {
pending: '待处理',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已取消',
}
return labels[status] || status || '-'
}
export function hasRechargeConfig(
financeConfig:
| {
recharge?: { accountName?: string; accountNo?: string; qrCodeImage?: unknown }
}
| undefined,
) {
return Boolean(
financeConfig?.recharge?.accountName ||
financeConfig?.recharge?.accountNo ||
financeConfig?.recharge?.qrCodeImage,
)
}
export 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>
)
}
export function resolveRequestStatusColor(status: string) {
if (status === 'approved') return 'green'
if (status === 'pending') return 'gold'
if (status === 'rejected') return 'red'
if (status === 'cancelled') return 'default'
return 'blue'
}
export function formatWithdrawChannel(channel: string) {
if (channel === 'alipay') return '支付宝'
if (channel === 'wechat') return '微信收款'
if (channel === 'bank') return '银行卡'
return channel || '-'
}
export function maskAccountNo(value: string) {
const text = String(value || '').trim()
if (!text) return '-'
if (text.length <= 8) return text
return `${text.slice(0, 4)} **** ${text.slice(-4)}`
}
export const workerLedgerColumns: TableColumnsType<WorkerWalletLedger> = [
{
title: '时间',
dataIndex: 'createdAt',
width: 180,
render: (value) => formatDateTime(String(value || '')),
},
{
title: '类型',
width: 150,
render: (_, row) => (
<Tag color={resolveLedgerTagColor(row.ledgerType)}>{formatLedgerType(row.ledgerType)}</Tag>
),
},
{
title: '金额',
width: 120,
render: (_, row) => (
<Typography.Text
strong
className={row.amount >= 0 ? 'worker-amount-positive' : 'worker-amount-negative'}
>
{row.amount >= 0 ? '+' : ''}
{formatMoney(row.amount)}
</Typography.Text>
),
},
{ title: '余额', width: 120, render: (_, row) => formatMoney(row.balanceAfter) },
{ title: '冻结押金', width: 120, render: (_, row) => formatMoney(row.frozenAfter) },
{
title: '说明',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text>{row.note || '-'}</Typography.Text>
{row.relatedPlatformOrderId ? (
<Typography.Text type="secondary"> {row.relatedPlatformOrderId}</Typography.Text>
) : null}
</div>
),
},
]
export const workerFinanceRequestColumns: TableColumnsType<WorkerFinanceRequest> = [
{
title: '申请时间',
dataIndex: 'createdAt',
width: 180,
render: (value) => formatDateTime(String(value || '')),
},
{
title: '类型',
width: 120,
render: (_, row) => (
<Tag color={row.requestType === 'withdraw' ? 'green' : 'blue'}>
{formatRequestType(row.requestType)}
</Tag>
),
},
{
title: '金额',
width: 120,
render: (_, row) => <Typography.Text strong>{formatMoney(row.amount)}</Typography.Text>,
},
{
title: '收款信息',
minWidth: 220,
render: (_, row) =>
row.requestType === 'withdraw' ? (
<div className="cell-stack">
<Typography.Text>
{formatWithdrawChannel(row.accountChannel)} / {row.accountName || '-'}
</Typography.Text>
<Typography.Text type="secondary">{maskAccountNo(row.accountNo)}</Typography.Text>
</div>
) : (
renderRechargeRequestSummary(row)
),
},
{
title: '状态',
width: 130,
render: (_, row) => (
<Tag color={resolveRequestStatusColor(row.status)}>{formatRequestStatus(row.status)}</Tag>
),
},
{
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>
),
},
]
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 asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}