拆分接单平台超长文件,按域组织代码
- 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:
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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user