拆分接单平台超长文件,按域组织代码

- worker-platform-service.ts (2733行) 拆为 worker-service/admin-service/mappers + index 聚合
- worker-platform-repo.ts (2456行) 拆为 repositories/worker-platform/ 目录(types/shared/worker-repo/work-order-repo)
- AdminWorkerPlatformPage.tsx (2784行) 拆为主页面 + panels/ 目录(7 个面板文件)
- 引用方统一改指向 index 聚合,行为不变
This commit is contained in:
yml2213
2026-08-01 16:44:20 +08:00
parent c33e859e96
commit 70e64155bc
27 changed files with 7200 additions and 6463 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,262 @@
import {
CheckOutlined,
FormOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
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,
deleteAdminWorkCategory,
deleteAdminWorkerLevel,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import type {
CollectField,
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
export default function CategoriesPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [editingCategory, setEditingCategory] = useState<WorkCategory | null>(null)
const [deletingCategory, setDeletingCategory] = useState<WorkCategory | null>(null)
const categoriesQuery = useQuery({
queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(),
})
async function refreshCategories() {
await categoriesQuery.refetch()
}
function startEdit(category: WorkCategory) {
setEditingCategory(category)
form.setFieldsValue({
categoryKey: category.categoryKey,
name: category.name,
sortOrder: category.sortOrder,
status: category.status,
})
}
function cancelEdit() {
setEditingCategory(null)
form.resetFields()
}
async function saveCategory(values: {
categoryKey: string
name: string
sortOrder?: number
status?: string
}) {
try {
await saveAdminWorkCategory(values)
message.success(editingCategory ? '分类已更新' : '分类已保存')
cancelEdit()
await refreshCategories()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
}
}
async function toggleCategory(category: WorkCategory) {
try {
await saveAdminWorkCategory({
categoryKey: category.categoryKey,
name: category.name,
sortOrder: category.sortOrder,
status: category.status === 'active' ? 'disabled' : 'active',
})
message.success(category.status === 'active' ? '分类已停用' : '分类已启用')
await refreshCategories()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
}
}
async function submitDelete() {
if (!deletingCategory) return
try {
await deleteAdminWorkCategory(deletingCategory.categoryId)
message.success('分类已删除')
setDeletingCategory(null)
await refreshCategories()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除失败')
setDeletingCategory(null)
}
}
const columns: TableColumnsType<WorkCategory> = [
{ title: '分类', dataIndex: 'name' },
{ title: '标识', dataIndex: 'categoryKey' },
{ title: '排序', dataIndex: 'sortOrder', width: 100 },
{
title: '状态',
width: 120,
render: (_, row) => (
<Tag color={row.status === 'active' ? 'green' : 'default'}>
{row.status === 'active' ? '启用' : '停用'}
</Tag>
),
},
{
title: '操作',
width: 240,
render: (_, row) => (
<Space wrap>
<Button size="small" onClick={() => startEdit(row)}>
</Button>
<Button size="small" onClick={() => toggleCategory(row)}>
{row.status === 'active' ? '停用' : '启用'}
</Button>
<Button size="small" danger onClick={() => setDeletingCategory(row)}>
</Button>
</Space>
),
},
]
return (
<section className="platform-panel-stack">
<Card title={editingCategory ? `编辑分类:${editingCategory.name}` : '保存分类'} bordered={false}>
<Form
form={form}
layout="inline"
initialValues={{ status: 'active', sortOrder: 100 }}
onFinish={saveCategory}
>
<Form.Item
label="标识"
name="categoryKey"
rules={[{ required: true, message: '请输入标识' }]}
>
<Input />
</Form.Item>
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input />
</Form.Item>
<Form.Item label="排序" name="sortOrder">
<InputNumber min={0} max={9999} />
</Form.Item>
<Form.Item label="状态" name="status">
<Select
style={{ width: 110 }}
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
{editingCategory ? <Button onClick={cancelEdit}></Button> : null}
</Space>
</Form.Item>
</Form>
</Card>
<Card title="分类列表" bordered={false}>
<Table<WorkCategory>
rowKey="categoryId"
loading={categoriesQuery.isLoading}
dataSource={categoriesQuery.data?.data.items || []}
columns={columns}
pagination={false}
/>
</Card>
<Modal
title="删除分类"
open={Boolean(deletingCategory)}
onOk={submitDelete}
onCancel={() => setDeletingCategory(null)}
okButtonProps={{ danger: true }}
okText="删除"
cancelText="取消"
>
<Typography.Text>
{deletingCategory?.name}
</Typography.Text>
</Modal>
</section>
)
}
@@ -0,0 +1,660 @@
import {
CheckOutlined,
FormOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
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,
deleteAdminWorkCategory,
deleteAdminWorkerLevel,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import type {
CollectField,
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { asRecord, formatMoney, formatSharingShareStatus, resolveSharingShareStatusColor } from './shared'
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 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: 140,
render: (_, row) =>
row.requestType === 'recharge' ? (
<ImagePreviewList files={getRechargeProofs(row)} size={48} />
) : (
'-'
),
},
{
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>
{reviewState.request.requestType === 'recharge' ? (
<>
<Descriptions.Item label="付款时间">
{getRechargePaidAt(reviewState.request)
? formatAdminDateTime(getRechargePaidAt(reviewState.request))
: '-'}
</Descriptions.Item>
<Descriptions.Item label="付款凭证">
<ImagePreviewList files={getRechargeProofs(reviewState.request)} size={88} />
</Descriptions.Item>
</>
) : null}
<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 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 getRechargeProofs(request: WorkerFinanceRequest): UploadedFile[] {
if (request.requestType !== 'recharge') return []
const payload = asRecord(request.payload)
const rawFiles = Array.isArray(payload.proofFiles) ? payload.proofFiles : []
return rawFiles
.map((item) => {
const file = asRecord(item)
const url = String(file.url || '').trim()
if (!url) return null
return {
url,
mediumUrl: String(file.mediumUrl || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || '').trim(),
} as UploadedFile
})
.filter((item): item is UploadedFile => Boolean(item))
}
function getRechargePaidAt(request: WorkerFinanceRequest): string {
const payload = asRecord(request.payload)
return String(payload.paidAt || '').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)}`
}
@@ -0,0 +1,285 @@
import {
CheckOutlined,
FormOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
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,
deleteAdminWorkCategory,
deleteAdminWorkerLevel,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import type {
CollectField,
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './shared'
export default function LevelsPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const levelsQuery = useQuery({
queryKey: ['admin-worker-platform-levels'],
queryFn: () => fetchAdminWorkerLevels(),
})
const [form] = Form.useForm()
const [editingLevel, setEditingLevel] = useState<WorkerLevel | null>(null)
const [deletingLevel, setDeletingLevel] = useState<WorkerLevel | null>(null)
async function refreshLevels() {
await levelsQuery.refetch()
}
function startEdit(level: WorkerLevel) {
setEditingLevel(level)
form.setFieldsValue({
levelKey: level.levelKey,
name: level.name,
depositFreeAmount: level.depositFreeAmount,
maxActiveOrders: level.maxActiveOrders,
upgradeThreshold: level.upgradeThreshold,
})
}
function cancelEdit() {
setEditingLevel(null)
form.resetFields()
}
async function saveLevel(values: {
levelKey: string
name: string
depositFreeAmount?: number
maxActiveOrders?: number
upgradeThreshold?: number
}) {
try {
await saveAdminWorkerLevel(values)
message.success(editingLevel ? '等级已更新' : '等级已保存')
cancelEdit()
await refreshLevels()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
}
}
async function toggleLevel(level: WorkerLevel) {
try {
await saveAdminWorkerLevel({
levelKey: level.levelKey,
name: level.name,
depositFreeAmount: level.depositFreeAmount,
maxActiveOrders: level.maxActiveOrders,
upgradeThreshold: level.upgradeThreshold,
status: level.status === 'active' ? 'disabled' : 'active',
})
message.success(level.status === 'active' ? '等级已停用' : '等级已启用')
await refreshLevels()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
}
}
async function submitDelete() {
if (!deletingLevel) return
try {
await deleteAdminWorkerLevel(deletingLevel.levelId)
message.success('等级已删除')
setDeletingLevel(null)
await refreshLevels()
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-workers'],
})
} catch (error) {
message.error(error instanceof Error ? error.message : '删除失败')
setDeletingLevel(null)
}
}
const columns: TableColumnsType<WorkerLevel> = [
{ title: '等级', dataIndex: 'name' },
{ title: '标识', dataIndex: 'levelKey' },
{
title: '免押额度',
render: (_, row) => formatMoney(row.depositFreeAmount),
},
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
{ title: '升级所需累计完成', dataIndex: 'upgradeThreshold' },
{
title: '状态',
width: 110,
render: (_, row) => (
<Tag color={row.status === 'active' ? 'green' : 'default'}>
{row.status === 'active' ? '启用' : '停用'}
</Tag>
),
},
{
title: '操作',
width: 240,
render: (_, row) => (
<Space wrap>
<Button size="small" onClick={() => startEdit(row)}>
</Button>
<Button size="small" onClick={() => toggleLevel(row)}>
{row.status === 'active' ? '停用' : '启用'}
</Button>
<Button size="small" danger onClick={() => setDeletingLevel(row)}>
</Button>
</Space>
),
},
]
return (
<section className="platform-panel-stack">
<Card
title={editingLevel ? `编辑等级:${editingLevel.name}` : '保存等级'}
bordered={false}
>
<Form
form={form}
layout="inline"
initialValues={{
levelKey: 'vip1',
name: 'VIP1',
depositFreeAmount: 0,
maxActiveOrders: 1,
upgradeThreshold: 0,
}}
onFinish={saveLevel}
>
<Form.Item
label="标识"
name="levelKey"
rules={[{ required: true, message: '请输入标识' }]}
>
<Input />
</Form.Item>
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input />
</Form.Item>
<Form.Item label="免押额度" name="depositFreeAmount">
<InputNumber min={0} step={10} addonAfter="元" />
</Form.Item>
<Form.Item label="最大同时接单" name="maxActiveOrders">
<InputNumber min={1} max={999} />
</Form.Item>
<Form.Item
label="升级所需累计完成单数"
name="upgradeThreshold"
tooltip="打手累计验收通过该单数后自动升级到本等级"
>
<InputNumber min={0} max={100000} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
{editingLevel ? <Button onClick={cancelEdit}></Button> : null}
</Space>
</Form.Item>
</Form>
</Card>
<Card title="等级列表" bordered={false}>
<Table<WorkerLevel>
rowKey="levelId"
loading={levelsQuery.isLoading}
dataSource={levelsQuery.data?.data.items || []}
columns={columns}
pagination={false}
/>
</Card>
<Modal
title="删除等级"
open={Boolean(deletingLevel)}
onOk={submitDelete}
onCancel={() => setDeletingLevel(null)}
okButtonProps={{ danger: true }}
okText="删除"
cancelText="取消"
>
<Typography.Text>
{deletingLevel?.name}使
</Typography.Text>
</Modal>
</section>
)
}
@@ -0,0 +1,418 @@
import {
CheckOutlined,
FormOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
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,
deleteAdminWorkCategory,
deleteAdminWorkerLevel,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import type {
CollectField,
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './shared'
export default function ProductRulesPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm()
const rulesQuery = useQuery({
queryKey: ['admin-worker-platform-product-rules'],
queryFn: () => fetchAdminWorkProductRules(),
})
const categoriesQuery = useQuery({
queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(),
})
async function saveRule(values: {
ruleKey: string
provider?: string
platform?: string
shopId?: string
skuCode?: string
productName?: string
matchType?: string
categoryId?: number
rewardAmount?: number
requiredDepositAmount?: number
depositThresholdAmount?: number
sharingEnabled?: boolean
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingTotalAmount?: number
fieldsText?: string
enabled?: boolean
autoCreate?: boolean
}) {
try {
await saveAdminWorkProductRule(values)
message.success('物品规则已保存')
form.resetFields()
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-product-rules'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
])
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
}
}
function syncSharingRuleForm(next: {
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingTotalAmount?: number
}) {
const values = form.getFieldsValue()
const unitReward = Number(next.sharingUnitReward ?? values.sharingUnitReward ?? 0)
const totalQuantity = Number(
next.sharingTotalQuantity ?? values.sharingTotalQuantity ?? 1,
)
const totalAmount = Number(next.sharingTotalAmount ?? values.sharingTotalAmount ?? 0)
if (next.sharingTotalAmount !== undefined) {
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
form.setFieldsValue({
sharingTotalQuantity: quantity,
sharingTotalAmount: quantity * unitReward,
})
return
}
form.setFieldsValue({
sharingTotalAmount: unitReward * totalQuantity,
})
}
const columns: TableColumnsType<WorkProductRule> = [
{
title: '规则',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>
{row.productName || row.skuCode}
</Typography.Text>
<Typography.Text type="secondary">{row.ruleKey}</Typography.Text>
</div>
),
},
{
title: 'SKU',
dataIndex: 'skuCode',
width: 180,
render: (value) => String(value || '-'),
},
{ title: '分类', width: 140, render: (_, row) => row.categoryName || '-' },
{
title: '接单金额',
width: 120,
render: (_, row) => formatMoney(row.rewardAmount),
},
{
title: '押金',
width: 120,
render: (_, row) => formatMoney(row.requiredDepositAmount),
},
{
title: '匹配',
width: 130,
render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'),
},
{
title: '状态',
width: 150,
render: (_, row) => (
<Space>
<Tag color={row.enabled ? 'green' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
{row.autoCreate ? <Tag color="blue"></Tag> : null}
{row.sharing?.enabled ? (
<Tag color="purple">
{formatMoney(row.sharing.unitReward)}/
</Tag>
) : null}
</Space>
),
},
]
return (
<section className="platform-panel-stack">
<Card title="保存物品规则" bordered={false}>
<Form
form={form}
layout="vertical"
className="worker-rule-form"
initialValues={{
platform: 'kuaishou',
matchType: 'contains',
rewardAmount: 25,
depositThresholdAmount: 200,
enabled: true,
autoCreate: false,
sharingEnabled: false,
sharingTotalQuantity: 10,
sharingUnitReward: 5,
sharingTotalAmount: 50,
fieldsText:
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
}}
onFinish={saveRule}
>
<div className="worker-rule-grid">
<Form.Item
label="规则标识"
name="ruleKey"
rules={[{ required: true, message: '请输入规则标识' }]}
>
<Input placeholder="skin-training" />
</Form.Item>
<Form.Item label="来源" name="provider">
<Input placeholder="留空通配,如 91kaquan" />
</Form.Item>
<Form.Item label="平台" name="platform">
<Input placeholder="kuaishou" />
</Form.Item>
<Form.Item label="店铺" name="shopId">
<Input placeholder="留空通配" />
</Form.Item>
<Form.Item label="SKU" name="skuCode">
<Input placeholder="优先精确匹配" />
</Form.Item>
<Form.Item label="商品名" name="productName">
<Input placeholder="用于包含/精确匹配" />
</Form.Item>
<Form.Item label="匹配方式" name="matchType">
<Select
options={[
{ value: 'contains', label: '包含' },
{ value: 'exact', label: '精确' },
]}
/>
</Form.Item>
<Form.Item label="分类" name="categoryId">
<Select
allowClear
loading={categoriesQuery.isLoading}
options={(categoriesQuery.data?.data.items || []).map(
(item) => ({
value: item.categoryId,
label: item.name,
}),
)}
/>
</Form.Item>
<Form.Item label="接单金额" name="rewardAmount">
<InputNumber
min={0.01}
step={1}
addonAfter="元"
className="full-width"
/>
</Form.Item>
<Form.Item label="押金阈值" name="depositThresholdAmount">
<InputNumber
min={0}
step={10}
addonAfter="元"
className="full-width"
/>
</Form.Item>
<Form.Item label="固定押金" name="requiredDepositAmount">
<InputNumber
min={0}
step={10}
addonAfter="元"
className="full-width"
/>
</Form.Item>
<Form.Item label="排序" name="sortOrder">
<InputNumber min={0} max={9999} className="full-width" />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label="自动创建"
name="autoCreate"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="启用拼单"
name="sharingEnabled"
valuePropName="checked"
tooltip="开启后按下方总数量与单价生成拼单工单"
>
<Switch />
</Form.Item>
</div>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('sharingEnabled') ? (
<Space size={16} wrap style={{ marginBottom: 16 }}>
<Form.Item
label="拼单总数量"
name="sharingTotalQuantity"
rules={[{ required: true, message: '请填写总数量' }]}
>
<InputNumber
min={1}
max={100000}
addonAfter="份"
onChange={(value) =>
syncSharingRuleForm({
sharingTotalQuantity: Number(value) || 0,
})
}
/>
</Form.Item>
<Form.Item
label="拼单单价"
name="sharingUnitReward"
rules={[{ required: true, message: '请填写单价' }]}
tooltip="每个数量份对应的报酬,修改后总价自动重算"
>
<InputNumber
min={0.01}
step={1}
addonAfter="元"
onChange={(value) =>
syncSharingRuleForm({
sharingUnitReward: Number(value) || 0,
})
}
/>
</Form.Item>
<Form.Item
label="拼单总价"
name="sharingTotalAmount"
rules={[{ required: true, message: '请填写总价' }]}
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
>
<InputNumber
min={0.01}
step={1}
addonAfter="元"
onChange={(value) =>
syncSharingRuleForm({
sharingTotalAmount: Number(value) || 0,
})
}
/>
</Form.Item>
</Space>
) : null
}
</Form.Item>
<Form.Item label="资料字段" name="fieldsText">
<Input.TextArea rows={4} />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
</Card>
<Card
title="物品规则"
extra={
<Button
icon={<ReloadOutlined />}
loading={rulesQuery.isFetching}
onClick={() => rulesQuery.refetch()}
>
</Button>
}
bordered={false}
>
<Table<WorkProductRule>
rowKey="ruleId"
loading={rulesQuery.isLoading}
dataSource={rulesQuery.data?.data.items || []}
columns={columns}
pagination={false}
scroll={{ x: 1200 }}
/>
</Card>
</section>
)
}
@@ -0,0 +1,984 @@
import {
CheckOutlined,
FormOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
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,
deleteAdminWorkCategory,
deleteAdminWorkerLevel,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import type {
CollectField,
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
getAcceptanceFiles,
getAcceptanceImageUrls,
getAcceptanceSubmittedAt,
getMaterialFields,
getMaterialDetailItems,
getRequirementFields,
renderDetailFieldValues,
renderMaterialSummary,
renderRequirementFieldValues,
formatMoney,
formatSharingShareStatus,
formatStatus,
resolveSharingShareStatusColor,
resolveStatusColor,
type DetailFieldItem,
} from './shared'
export default function WorkOrdersPanel() {
const { message, modal } = App.useApp()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [problemForm] = Form.useForm()
const [resolutionForm] = Form.useForm()
const [materialForm] = Form.useForm()
const [sharingForm] = Form.useForm<{
enabled?: boolean
totalQuantity?: number
unitReward?: number
totalAmount?: number
}>()
const ordersQuery = useQuery({
queryKey: ['admin-worker-platform-orders', status, page, pageSize],
queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }),
})
const ordersPagination = ordersQuery.data?.data.pagination
const sharingQuery = useQuery({
queryKey: ['admin-worker-platform-order-sharing', sharingOrder?.workOrderId],
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
enabled: Boolean(sharingOrder),
})
function openSharingConfigModal(row: WorkOrder) {
setSharingOrder(row)
const unitRewardFen = Number(row.sharing?.unitReward || 0)
const totalQuantity = Number(row.sharing?.totalQuantity || 1)
const unitRewardYuan = unitRewardFen / 100
sharingForm.setFieldsValue({
enabled: row.sharing?.enabled,
totalQuantity,
unitReward: unitRewardYuan,
totalAmount: unitRewardYuan * totalQuantity,
})
}
function syncSharingForm(next: {
totalQuantity?: number
unitReward?: number
totalAmount?: number
}) {
const values = sharingForm.getFieldsValue()
const unitReward = Number(next.unitReward ?? values.unitReward ?? 0)
const totalQuantity = Number(next.totalQuantity ?? values.totalQuantity ?? 1)
const totalAmount = Number(next.totalAmount ?? values.totalAmount ?? 0)
if (next.totalAmount !== undefined) {
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
sharingForm.setFieldsValue({
totalQuantity: quantity,
totalAmount: quantity * unitReward,
})
return
}
sharingForm.setFieldsValue({
totalAmount: unitReward * totalQuantity,
})
}
async function submitSharingConfig(values: {
enabled?: boolean
totalQuantity?: number
unitReward?: number
totalAmount?: number
}) {
if (!sharingOrder) return
const succeeded = await runAction(
() =>
updateAdminWorkOrderSharing(sharingOrder.workOrderId, {
enabled: values.enabled !== false,
totalQuantity: Number(values.totalQuantity || 1),
unitReward: Number(values.unitReward || 0),
totalAmount: Number(values.totalAmount || 0),
}),
'拼单配置已保存',
)
if (!succeeded) return
setSharingOrder(null)
sharingForm.resetFields()
}
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-orders'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
])
}
async function runAction(
action: () => Promise<unknown>,
successMessage: string,
) {
try {
await action()
message.success(successMessage)
await refreshAll()
return true
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
return false
}
}
async function submitProblem(values: { note?: string }) {
if (!problemOrder) return
const succeeded = await runAction(
() =>
markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
'已标记问题单',
)
if (!succeeded) return
setProblemOrder(null)
problemForm.resetFields()
}
async function submitResolution(values: {
action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
note?: string
}) {
if (!resolutionOrder || !values.action) return
const action = values.action
const succeeded = await runAction(
() =>
resolveAdminProblemWorkOrder(resolutionOrder.workOrderId, {
action,
note: values.note || '',
}),
'问题单已处置',
)
if (!succeeded) return
setResolutionOrder(null)
resolutionForm.resetFields()
}
async function submitMaterial(values: { fields?: Record<string, string> }) {
if (!materialOrder) return
const succeeded = await runAction(
() =>
submitAdminWorkOrderMaterial(
materialOrder.workOrderId,
values.fields || {},
),
'订单资料已保存',
)
if (!succeeded) return
setMaterialOrder(null)
materialForm.resetFields()
}
async function submitSourceSync(values: { platformOrderId?: string }) {
try {
const result = await syncAdminWorkerOrdersFromSource({
platformOrderId: String(values.platformOrderId || '').trim(),
})
message.success(
`同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount}`,
)
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步失败')
}
}
function openMaterialModal(row: WorkOrder) {
setMaterialOrder(row)
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
}
function openDetailDrawer(row: WorkOrder) {
setDetailOrder(row)
}
const columns: TableColumnsType<WorkOrder> = [
{
title: '订单',
minWidth: 300,
render: (_, row) => (
<div className="cell-stack">
<Typography.Link onClick={() => openDetailDrawer(row)}>
{row.productName || row.workOrderNo}
</Typography.Link>
<Space size={[6, 6]} wrap>
{row.categoryName ? <Tag>{row.categoryName}</Tag> : null}
{row.orderId ? (
<Typography.Text type="secondary">
#{row.orderId}
</Typography.Text>
) : null}
</Space>
<Typography.Text type="secondary">
{row.platformOrderId} · {row.workOrderNo}
</Typography.Text>
</div>
),
},
{
title: '金额',
width: 110,
render: (_, row) => formatMoney(row.rewardAmount),
},
{
title: '押金',
width: 110,
render: (_, row) => formatMoney(row.requiredDepositAmount),
},
{
title: '拼单',
width: 190,
render: (_, row) =>
row.sharing?.enabled ? (
<div className="cell-stack">
<Tag color="purple">
{formatMoney(row.sharing.unitReward)} / {row.sharing.totalQuantity}
</Tag>
<Typography.Text type="secondary">
{row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity}
{row.sharingProgress?.pendingSubmissionCount
? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交`
: ''}
</Typography.Text>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '状态',
width: 130,
render: (_, row) => (
<Tag color={resolveStatusColor(row.status)}>
{formatStatus(row.status)}
</Tag>
),
},
{
title: '打手',
width: 140,
render: (_, row) =>
row.worker?.displayName || row.worker?.username || '-',
},
{
title: '资料',
width: 160,
render: (_, row) => renderMaterialSummary(row),
},
{
title: '验收图',
width: 170,
render: (_, row) => (
<ImagePreviewList
files={getAcceptanceFiles(row)}
imageUrls={getAcceptanceImageUrls(row)}
size={44}
/>
),
},
{
title: '操作',
width: 400,
render: (_, row) => (
<Space wrap>
<Button onClick={() => openDetailDrawer(row)}></Button>
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
<Button
icon={<TeamOutlined />}
onClick={() => openSharingConfigModal(row)}
>
</Button>
) : null}
{row.status === 'pending_material' ? (
<Button
icon={<FormOutlined />}
onClick={() => openMaterialModal(row)}
>
</Button>
) : null}
{row.status === 'unassigned' ? (
<Button
icon={<SendOutlined />}
onClick={() =>
runAction(
() => publishAdminWorkOrder(row.workOrderId),
'已发布到大厅',
)
}
>
</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 />}
type="primary"
onClick={() =>
runAction(
() => acceptAdminWorkOrder(row.workOrderId),
'已验收通过',
)
}
>
</Button>
) : null}
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
<Button
icon={<WarningOutlined />}
danger
onClick={() => setProblemOrder(row)}
>
</Button>
) : null}
{row.status === 'problem' ? (
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
</Button>
) : null}
</Space>
),
},
]
return (
<section className="platform-panel-stack">
<Card title="从源订单同步" bordered={false}>
<Form layout="inline" onFinish={submitSourceSync}>
<Form.Item
label="平台订单号"
name="platformOrderId"
rules={[{ required: true, message: '请输入平台订单号' }]}
>
<Input
placeholder="用户填写链接 A 的订单号"
style={{ width: 260 }}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SyncOutlined />} htmlType="submit">
</Button>
</Form.Item>
</Form>
</Card>
<Card title="生成本地接单工单 Mock" bordered={false}>
<MockWorkOrderForm onCreated={refreshAll} />
</Card>
<Card
title="接单工单"
extra={
<Space>
<Select
value={status}
style={{ width: 150 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending_material', label: '待完善' },
{ value: 'unassigned', label: '未分配' },
{ value: 'open', label: '待抢单' },
{ value: 'in_progress', label: '代练中' },
{ value: 'pending_acceptance', label: '待验收' },
{ value: 'problem', label: '问题单' },
{ value: 'accepted', label: '已验收' },
{ value: 'cancelled', label: '已取消' },
]}
onChange={(nextStatus) => {
setStatus(nextStatus)
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={ordersQuery.isFetching}
onClick={() => ordersQuery.refetch()}
>
</Button>
</Space>
}
bordered={false}
>
<Table<WorkOrder>
rowKey="workOrderId"
loading={ordersQuery.isLoading}
dataSource={ordersQuery.data?.data.items || []}
columns={columns}
pagination={buildAdminTablePagination({
current: ordersPagination?.page || page,
pageSize: ordersPagination?.pageSize || pageSize,
total: ordersPagination?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1240 }}
/>
</Card>
<Drawer
title={
detailOrder
? `${detailOrder.productName || '工单详情'} · ${detailOrder.workOrderNo}`
: '工单详情'
}
width={860}
open={Boolean(detailOrder)}
destroyOnHidden
onClose={() => setDetailOrder(null)}
extra={
detailOrder ? (
<Space>
<Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)}
</Tag>
{detailOrder.orderId ? (
<Button
onClick={() => {
const orderId = detailOrder.orderId
setDetailOrder(null)
navigate(`/admin/orders/${orderId}`)
}}
>
</Button>
) : null}
</Space>
) : null
}
>
{detailOrder ? (
<div className="worker-order-detail-stack">
<Card title="基础信息" size="small">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="工单号">
{detailOrder.workOrderNo}
</Descriptions.Item>
<Descriptions.Item label="平台订单号">
{detailOrder.platformOrderId || '-'}
</Descriptions.Item>
<Descriptions.Item label="源订单 ID">
{detailOrder.orderId || '-'}
</Descriptions.Item>
<Descriptions.Item label="订单子项 ID">
{detailOrder.orderItemId || '-'}
</Descriptions.Item>
<Descriptions.Item label="商品">
{detailOrder.productName || '-'}
</Descriptions.Item>
<Descriptions.Item label="分类">
{detailOrder.categoryName || '-'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="打手">
{detailOrder.worker?.displayName ||
detailOrder.worker?.username ||
'-'}
</Descriptions.Item>
<Descriptions.Item label="接单金额">
{formatMoney(detailOrder.rewardAmount)}
</Descriptions.Item>
<Descriptions.Item label="押金">
{formatMoney(detailOrder.requiredDepositAmount)}
</Descriptions.Item>
<Descriptions.Item label="发布时间">
{formatAdminDateTime(detailOrder.publishedAt)}
</Descriptions.Item>
<Descriptions.Item label="接单时间">
{formatAdminDateTime(detailOrder.assignedAt)}
</Descriptions.Item>
<Descriptions.Item label="提交验收">
{formatAdminDateTime(getAcceptanceSubmittedAt(detailOrder))}
</Descriptions.Item>
<Descriptions.Item label="验收通过">
{formatAdminDateTime(detailOrder.acceptedAt)}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatAdminDateTime(detailOrder.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatAdminDateTime(detailOrder.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label="问题备注" span={2}>
<span className="worker-order-detail-value">
{String(detailOrder.problemNote || '').trim() || '-'}
</span>
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="资料信息" size="small">
{renderDetailFieldValues(
getMaterialDetailItems(detailOrder),
'当前还没有填写资料',
)}
</Card>
<Card title="资料要求" size="small">
{renderRequirementFieldValues(detailOrder)}
</Card>
<Card title="验收信息" size="small">
<div className="worker-order-detail-stack">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="提交时间">
{formatAdminDateTime(getAcceptanceSubmittedAt(detailOrder))}
</Descriptions.Item>
<Descriptions.Item label="验收图片">
{getAcceptanceFiles(detailOrder).length +
getAcceptanceImageUrls(detailOrder).length || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
<span className="worker-order-detail-value">
{String(detailOrder.acceptance?.note || '').trim() || '-'}
</span>
</Descriptions.Item>
</Descriptions>
{getAcceptanceFiles(detailOrder).length ||
getAcceptanceImageUrls(detailOrder).length ? (
<ImagePreviewList
files={getAcceptanceFiles(detailOrder)}
imageUrls={getAcceptanceImageUrls(detailOrder)}
size={72}
/>
) : (
<Typography.Text type="secondary">
</Typography.Text>
)}
</div>
</Card>
<Card title="原始数据" size="small">
<Tabs
className="worker-order-detail-json-tabs"
items={[
{
key: 'material',
label: '资料',
children: <JsonPreview value={detailOrder.material} />,
},
{
key: 'requirement',
label: '需求',
children: <JsonPreview value={detailOrder.requirement} />,
},
{
key: 'acceptance',
label: '验收',
children: <JsonPreview value={detailOrder.acceptance} />,
},
]}
/>
</Card>
</div>
) : null}
</Drawer>
<Modal
title="标记问题单"
open={Boolean(problemOrder)}
onCancel={() => {
setProblemOrder(null)
problemForm.resetFields()
}}
onOk={() => problemForm.submit()}
destroyOnHidden
>
<Form form={problemForm} layout="vertical" onFinish={submitProblem}>
<Form.Item
label="问题备注"
name="note"
rules={[{ required: true, message: '请填写问题备注' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
<Modal
title="处置问题单"
open={Boolean(resolutionOrder)}
onCancel={() => {
setResolutionOrder(null)
resolutionForm.resetFields()
}}
onOk={() => resolutionForm.submit()}
destroyOnHidden
>
<Form
form={resolutionForm}
layout="vertical"
initialValues={{ action: 'return_to_worker' }}
onFinish={submitResolution}
>
<Form.Item
label="处置方式"
name="action"
rules={[{ required: true, message: '请选择处置方式' }]}
>
<Select
options={[
{ value: 'return_to_worker', label: '退回打手继续代练' },
{ value: 'reopen', label: '释放押金并重新放回大厅' },
{ value: 'cancel_release', label: '取消订单并退还押金' },
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
]}
/>
</Form.Item>
<Form.Item
label="处置备注"
name="note"
rules={[{ required: true, message: '请填写处置备注' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
<Modal
title="人工补充资料"
open={Boolean(materialOrder)}
onCancel={() => {
setMaterialOrder(null)
materialForm.resetFields()
}}
onOk={() => materialForm.submit()}
destroyOnHidden
>
<Form form={materialForm} layout="vertical" onFinish={submitMaterial}>
{getRequirementFields(materialOrder).map((field) => (
<Form.Item
key={field.key}
label={field.label}
name={['fields', field.key]}
rules={
field.required
? [{ required: true, message: `请填写${field.label}` }]
: undefined
}
>
<Input />
</Form.Item>
))}
</Form>
</Modal>
<Modal
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
open={Boolean(sharingOrder)}
onCancel={() => {
setSharingOrder(null)
sharingForm.resetFields()
}}
onOk={() => sharingForm.submit()}
destroyOnHidden
width={720}
>
{sharingOrder ? (
<Space direction="vertical" size={16} className="full-width">
<Form
form={sharingForm}
layout="vertical"
initialValues={{
enabled: false,
totalQuantity: 1,
unitReward: 0,
}}
onFinish={submitSharingConfig}
>
<Form.Item
label="启用拼单"
name="enabled"
valuePropName="checked"
tooltip="启用后打手可在抢单大厅按单价×数量参与拼单,未拼满时整单仍可被抢走"
>
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('enabled') ? (
<Space size={16} wrap>
<Form.Item
label="总数量"
name="totalQuantity"
rules={[{ required: true, message: '请填写总数量' }]}
>
<InputNumber
min={1}
max={100000}
addonAfter="份"
onChange={(value) =>
syncSharingForm({
totalQuantity: Number(value) || 0,
})
}
/>
</Form.Item>
<Form.Item
label="单价"
name="unitReward"
rules={[{ required: true, message: '请填写单价' }]}
tooltip="每个数量份对应的报酬,修改后总价自动重算"
>
<InputNumber
min={0.01}
step={1}
addonAfter="元"
onChange={(value) =>
syncSharingForm({ unitReward: Number(value) || 0 })
}
/>
</Form.Item>
<Form.Item
label="拼单总价"
name="totalAmount"
rules={[{ required: true, message: '请填写总价' }]}
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
>
<InputNumber
min={0.01}
step={1}
addonAfter="元"
onChange={(value) =>
syncSharingForm({ totalAmount: Number(value) || 0 })
}
/>
</Form.Item>
</Space>
) : null
}
</Form.Item>
</Form>
<Card size="small" title="拼单明细">
{sharingQuery.isLoading ? (
<Typography.Text type="secondary">...</Typography.Text>
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Table<WorkOrderShare>
rowKey="shareId"
size="small"
dataSource={sharingQuery.data?.data.shares || []}
pagination={false}
columns={[
{
title: '打手',
render: (_, share) =>
share.worker?.displayName || share.worker?.username || '-',
},
{ title: '数量', dataIndex: 'quantity' },
{
title: '份额报酬',
render: (_, share) => formatMoney(share.shareReward),
},
{
title: '状态',
render: (_, share) => (
<Tag color={resolveSharingShareStatusColor(share.status)}>
{formatSharingShareStatus(share.status)}
</Tag>
),
},
{
title: '提交时间',
render: (_, share) =>
share.submittedAt
? formatAdminDateTime(share.submittedAt)
: '-',
},
]}
/>
)}
</Card>
</Space>
) : null}
</Modal>
</section>
)
}
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
const { message } = App.useApp()
const [loading, setLoading] = useState(false)
async function submit(values: {
platformOrderId?: string
productName?: string
rewardAmount?: number
materialComplete?: boolean
}) {
setLoading(true)
try {
await createAdminMockWorkOrder(values)
message.success('Mock 工单已生成')
await onCreated()
} catch (error) {
message.error(error instanceof Error ? error.message : '生成失败')
} finally {
setLoading(false)
}
}
return (
<Form
layout="inline"
initialValues={{
productName: '指挥官秘钥1个',
rewardAmount: 25,
materialComplete: false,
}}
onFinish={submit}
>
<Form.Item label="订单号" name="platformOrderId">
<Input placeholder="留空自动生成" style={{ width: 180 }} />
</Form.Item>
<Form.Item label="商品" name="productName">
<Input style={{ width: 220 }} />
</Form.Item>
<Form.Item label="接单金额" name="rewardAmount">
<InputNumber min={0.01} step={1} addonAfter="元" />
</Form.Item>
<Form.Item
label="资料完整"
name="materialComplete"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Button
type="primary"
icon={<PlusOutlined />}
htmlType="submit"
loading={loading}
>
</Button>
</Form.Item>
</Form>
)
}
@@ -0,0 +1,346 @@
import {
CheckOutlined,
FormOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SyncOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
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,
deleteAdminWorkCategory,
deleteAdminWorkerLevel,
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminWorkProductRules,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerFinanceConfig,
saveAdminWorkCategory,
saveAdminWorkProductRule,
saveAdminWorkerLevel,
submitAdminWorkOrderMaterial,
syncAdminWorkerOrdersFromSource,
unpublishAdminWorkOrder,
updateAdminWorkOrderSharing,
} from '@/services/admin'
import type {
CollectField,
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
WorkerFinanceRequest,
WorkerLevel,
WorkerUser,
} from '@/types/worker-platform'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney, formatWorkerStatus } from './shared'
export default function WorkersPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
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() {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-workers'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
])
}
async function review(worker: WorkerUser, nextStatus: string) {
try {
await reviewAdminWorkerUser(worker.workerId, { status: nextStatus })
message.success('打手状态已更新')
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
}
}
async function submitCredit(values: { amount?: number; note?: string }) {
if (!creditWorker) return
try {
await creditAdminWorkerWallet(creditWorker.workerId, {
amount: Number(values.amount || 0),
note: values.note,
})
message.success('余额已增加')
setCreditWorker(null)
creditForm.resetFields()
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '充值失败')
}
}
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: '打手',
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>
{row.displayName || row.username}
</Typography.Text>
<Typography.Text type="secondary">{row.username}</Typography.Text>
</div>
),
},
{ title: '等级', width: 140, render: (_, row) => row.level?.name || '-' },
{
title: '邀请人',
width: 160,
render: (_, row) =>
row.inviter ? (
<div className="cell-stack">
<Typography.Text>{row.inviter.displayName || row.inviter.username}</Typography.Text>
<Typography.Text type="secondary">{row.inviter.username}</Typography.Text>
</div>
) : (
'-'
),
},
{
title: '余额',
width: 120,
render: (_, row) => formatMoney(row.wallet.availableAmount),
},
{
title: '冻结',
width: 120,
render: (_, row) => formatMoney(row.wallet.frozenDepositAmount),
},
{
title: '状态',
width: 130,
render: (_, row) => (
<Tag color={row.status === 'active' ? 'green' : 'orange'}>
{formatWorkerStatus(row.status)}
</Tag>
),
},
{
title: '操作',
width: 340,
render: (_, row) => (
<Space wrap>
{row.status !== 'active' ? (
<Button onClick={() => review(row, 'active')}></Button>
) : null}
{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>
),
},
]
return (
<Card
title="打手"
extra={
<Space>
<Select
value={status}
style={{ width: 150 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending_review', label: '待审核' },
{ value: 'active', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
{ value: 'disabled', label: '已停用' },
]}
onChange={(nextStatus) => {
setStatus(nextStatus)
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={workersQuery.isFetching}
onClick={() => workersQuery.refetch()}
>
</Button>
</Space>
}
bordered={false}
>
<Table<WorkerUser>
rowKey="workerId"
loading={workersQuery.isLoading}
dataSource={workersQuery.data?.data.items || []}
columns={columns}
pagination={buildAdminTablePagination({
current: workersPagination?.page || page,
pageSize: workersPagination?.pageSize || pageSize,
total: workersPagination?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
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)}
onCancel={() => {
setCreditWorker(null)
creditForm.resetFields()
}}
onOk={() => creditForm.submit()}
destroyOnHidden
>
<Form form={creditForm} layout="vertical" onFinish={submitCredit}>
<Form.Item
label="金额"
name="amount"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber
min={0.01}
step={10}
addonAfter="元"
className="full-width"
/>
</Form.Item>
<Form.Item label="备注" name="note">
<Input />
</Form.Item>
</Form>
</Modal>
</Card>
)
}
@@ -0,0 +1,225 @@
import { Descriptions, Space, Tag, Typography } from 'antd'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields)
? order.requirement.fields
: []
return rawFields
.map((item) => {
const source = asRecord(item)
const key = String(source.key || '').trim()
if (!key) return null
return {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
}
})
.filter((item): item is CollectField => Boolean(item))
}
export type DetailFieldItem = {
key: string
label: string
required: boolean
value: string
}
export function getMaterialFields(order: WorkOrder): Record<string, string> {
const material = asRecord(order.material)
const collect = asRecord(material.collect)
const fields = asRecord(collect.fields || material.fields)
return Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, String(value || '')]),
)
}
export function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] {
const values = getMaterialFields(order)
const requiredFields = getRequirementFields(order)
const usedKeys = new Set<string>()
const items = requiredFields.map((field) => {
usedKeys.add(field.key)
return {
key: field.key,
label: field.label,
required: field.required,
value: String(values[field.key] || ''),
}
})
Object.entries(values).forEach(([key, value]) => {
if (usedKeys.has(key)) return
items.push({
key,
label: key,
required: false,
value: String(value || ''),
})
})
return items
}
export function renderDetailFieldValues(
items: DetailFieldItem[],
emptyText: string,
) {
if (items.length === 0) {
return <Typography.Text type="secondary">{emptyText}</Typography.Text>
}
return (
<Descriptions column={1} bordered size="small">
{items.map((item) => (
<Descriptions.Item
key={item.key}
label={
<Space size={6} wrap>
<span>{item.label}</span>
{item.required ? <Tag color="orange"></Tag> : null}
</Space>
}
>
<span className="worker-order-detail-value">
{String(item.value || '').trim() || '-'}
</span>
</Descriptions.Item>
))}
</Descriptions>
)
}
export function renderRequirementFieldValues(order: WorkOrder) {
const fields = getRequirementFields(order)
if (fields.length === 0) {
return <Typography.Text type="secondary"></Typography.Text>
}
return (
<Descriptions column={1} bordered size="small">
{fields.map((field) => (
<Descriptions.Item key={field.key} label={field.label}>
<Space size={6} wrap>
<Typography.Text code>{field.key}</Typography.Text>
<Tag color={field.required ? 'orange' : 'default'}>
{field.required ? '必填' : '选填'}
</Tag>
</Space>
</Descriptions.Item>
))}
</Descriptions>
)
}
export function renderMaterialSummary(order: WorkOrder) {
const fieldLabelMap = new Map(
getRequirementFields(order).map((field) => [field.key, field.label]),
)
const fields = getMaterialFields(order)
const entries = Object.entries(fields).filter(([, value]) =>
String(value || '').trim(),
)
if (entries.length === 0) {
return <Typography.Text type="secondary">-</Typography.Text>
}
return (
<div className="cell-stack">
{entries.slice(0, 2).map(([key, value]) => (
<Typography.Text key={key} ellipsis>
{fieldLabelMap.get(key) || key}: {value}
</Typography.Text>
))}
{entries.length > 2 ? (
<Typography.Text type="secondary">
{entries.length - 2}
</Typography.Text>
) : null}
</div>
)
}
export function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
}
export function getAcceptanceImageUrls(row: WorkOrder): string[] {
return Array.isArray(row.acceptance?.imageUrls)
? row.acceptance.imageUrls
.map((item) => String(item || '').trim())
.filter(Boolean)
: []
}
export function getAcceptanceSubmittedAt(order: WorkOrder): string | null {
const acceptanceSubmittedAt =
typeof order.acceptance?.submittedAt === 'string'
? order.acceptance.submittedAt
: null
return order.submittedAt || acceptanceSubmittedAt || null
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
export function formatStatus(status: string) {
const labels: Record<string, string> = {
pending_material: '待完善',
unassigned: '未分配',
open: '待抢单',
in_progress: '代练中',
pending_acceptance: '待验收',
problem: '问题单',
accepted: '已验收',
cancelled: '已取消',
}
return labels[status] || status
}
export function resolveStatusColor(status: string) {
if (status === 'accepted') return 'green'
if (status === 'problem') return 'red'
if (status === 'pending_acceptance') return 'gold'
if (status === 'open') return 'blue'
return 'default'
}
export function formatWorkerStatus(status: string) {
const labels: Record<string, string> = {
pending_review: '待审核',
active: '已通过',
rejected: '已拒绝',
disabled: '已停用',
}
return labels[status] || status
}
export function formatSharingShareStatus(status: string) {
const labels: Record<string, string> = {
joined: '已参与',
submitted: '已提交验收',
accepted: '已验收',
cancelled: '已取消',
}
return labels[status] || status
}
export function resolveSharingShareStatusColor(status: string) {
if (status === 'accepted') return 'green'
if (status === 'submitted') return 'gold'
if (status === 'cancelled') return 'default'
return 'purple'
}