拆分打手平台通用模块
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { App, Button, Card, Form, Input, InputNumber, Select, Switch } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { createAdminMockWorkOrder } from '@/services/admin'
|
||||
import { fetchAdminKuaishouIndustryShops } from '@/services/admin/kuaishou-industry'
|
||||
import type { WorkOrderStatistics } from '@/types/worker-platform'
|
||||
import { buildMockWorkOrderShopOptions } from '@/utils/mock-work-order'
|
||||
|
||||
import { formatMoney, formatStatus } from './shared'
|
||||
|
||||
const WORK_ORDER_STATISTICS_STATUS_ORDER = [
|
||||
'pending_material',
|
||||
'unassigned',
|
||||
'open',
|
||||
'in_progress',
|
||||
'pending_acceptance',
|
||||
'vip_evidence_pending',
|
||||
'problem',
|
||||
'accepted',
|
||||
'cancelled',
|
||||
]
|
||||
|
||||
export function WorkOrderStatisticsBar({ statistics }: { statistics?: WorkOrderStatistics }) {
|
||||
if (!statistics) return null
|
||||
const profit = Number(statistics.userPaymentAmount || 0) - Number(statistics.rewardAmount || 0)
|
||||
const statusItems = WORK_ORDER_STATISTICS_STATUS_ORDER.map((status) => ({
|
||||
status,
|
||||
count: Number(statistics.statusCounts?.[status] || 0),
|
||||
}))
|
||||
const otherStatusItems = Object.entries(statistics.statusCounts || {})
|
||||
.filter(([status]) => !WORK_ORDER_STATISTICS_STATUS_ORDER.includes(status))
|
||||
.map(([status, count]) => ({ status, count: Number(count || 0) }))
|
||||
|
||||
return (
|
||||
<Card size="small" bordered={false} className="worker-orders-statistics-card">
|
||||
<div className="worker-orders-statistics-summary">
|
||||
本次筛选统计共 <strong>{statistics.totalCount}</strong> 笔,预计总发单价{' '}
|
||||
<strong>{formatMoney(statistics.rewardAmount)}</strong>(不含销售提成),总接单价{' '}
|
||||
<strong>{formatMoney(statistics.userPaymentAmount)}</strong>,总退款{' '}
|
||||
<strong>{formatMoney(statistics.refundAmount)}</strong>,预计利润{' '}
|
||||
<strong className={profit < 0 ? 'is-negative' : ''}>{formatMoney(profit)}</strong>。
|
||||
</div>
|
||||
<div className="worker-orders-statistics-statuses">
|
||||
其中:
|
||||
{[...statusItems, ...otherStatusItems].map(({ status, count }, index) => (
|
||||
<span key={status}>
|
||||
{index > 0 ? ',' : ''}
|
||||
{formatStatus(status)}
|
||||
{count}笔
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const shopsQuery = useQuery({
|
||||
queryKey: ['admin-kuaishou-industry-shops', 'mock-work-order'],
|
||||
queryFn: fetchAdminKuaishouIndustryShops,
|
||||
})
|
||||
const shopOptions = buildMockWorkOrderShopOptions(shopsQuery.data?.data.shops || [])
|
||||
|
||||
async function submit(values: {
|
||||
platformOrderId: string
|
||||
sellerId: string
|
||||
productName: string
|
||||
rewardAmount: number
|
||||
paymentAmount: 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 className="worker-order-mock-form" layout="inline" onFinish={submit}>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-order"
|
||||
label="订单号"
|
||||
name="platformOrderId"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写订单号' }]}
|
||||
>
|
||||
<Input placeholder="请输入订单号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-shop"
|
||||
label="店铺"
|
||||
name="sellerId"
|
||||
rules={[{ required: true, message: '请选择店铺' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={shopsQuery.isLoading}
|
||||
options={shopOptions}
|
||||
placeholder={shopOptions.length > 0 ? '请选择店铺' : '暂无可用店铺'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-product"
|
||||
label="商品"
|
||||
name="productName"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写商品名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入商品名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-amount"
|
||||
label="接单金额"
|
||||
name="rewardAmount"
|
||||
rules={[
|
||||
{ required: true, message: '请填写接单金额' },
|
||||
{ type: 'number', min: 0.01, message: '接单金额必须大于 0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-amount"
|
||||
label="用户付款金额"
|
||||
name="paymentAmount"
|
||||
rules={[
|
||||
{ required: true, message: '请填写用户付款金额' },
|
||||
{ type: 'number', min: 0.01, message: '用户付款金额必须大于 0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-complete"
|
||||
label="资料完整"
|
||||
name="materialComplete"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item className="worker-order-mock-action">
|
||||
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
||||
生成
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { App, Input, InputNumber, Typography } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { updateAdminWorkOrder } from '@/services/admin'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
|
||||
import { formatMoney } from './shared'
|
||||
import { getUserPaymentFen } from './work-order-panel-utils'
|
||||
|
||||
/** 商品名:单击看详情,双击原地修改(仅未接走工单) */
|
||||
export function ProductNameEditableCell({
|
||||
order,
|
||||
onOpenDetail,
|
||||
onSaved,
|
||||
}: {
|
||||
order: WorkOrder
|
||||
onOpenDetail: () => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const { message } = App.useApp()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const savingRef = useRef(false)
|
||||
const clickTimerRef = useRef<number | null>(null)
|
||||
const editable = order.capabilities?.canEdit === true
|
||||
|
||||
function handleClick() {
|
||||
if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = window.setTimeout(() => onOpenDetail(), 260)
|
||||
}
|
||||
|
||||
function handleDoubleClick() {
|
||||
if (clickTimerRef.current) {
|
||||
window.clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = null
|
||||
}
|
||||
if (!editable) return
|
||||
setDraft(order.productName || '')
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return
|
||||
const next = draft.trim()
|
||||
if (!next) {
|
||||
message.error('商品名不能为空')
|
||||
return
|
||||
}
|
||||
if (next === order.productName) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
try {
|
||||
await updateAdminWorkOrder(order.workOrderId, { productName: next })
|
||||
message.success('商品名已更新')
|
||||
setEditing(false)
|
||||
await onSaved()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Typography.Link
|
||||
title={editable ? '双击修改商品名' : undefined}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{order.productName || '未命名商品'}
|
||||
</Typography.Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
size="small"
|
||||
style={{ width: 200 }}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onPressEnter={() => void save()}
|
||||
onBlur={() => void save()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AmountEditableCell({
|
||||
order,
|
||||
field,
|
||||
onSaved,
|
||||
}: {
|
||||
order: WorkOrder
|
||||
field: 'rewardAmount' | 'requiredDepositAmount'
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const { message, modal } = App.useApp()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<number | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
const editable = order.capabilities?.canEdit === true
|
||||
const currentFen = Number(order[field] || 0)
|
||||
const currentYuan = currentFen / 100
|
||||
const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null
|
||||
const isLossOrder = paymentFen !== null && currentFen > paymentFen
|
||||
|
||||
async function doSave(nextYuan: number) {
|
||||
if (savingRef.current) return
|
||||
savingRef.current = true
|
||||
try {
|
||||
await updateAdminWorkOrder(order.workOrderId, { [field]: nextYuan })
|
||||
message.success('金额已更新')
|
||||
setEditing(false)
|
||||
await onSaved()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return
|
||||
if (draft === null || !Number.isFinite(Number(draft))) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
const nextYuan = Number(draft)
|
||||
if (nextYuan < 0) {
|
||||
message.error('金额不能为负数')
|
||||
return
|
||||
}
|
||||
if (Math.abs(nextYuan - currentYuan) < 0.005) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
const nextFen = Math.round(nextYuan * 100)
|
||||
if (paymentFen !== null && nextFen > paymentFen) {
|
||||
modal.confirm({
|
||||
title: '发单金额高于用户付款金额',
|
||||
content: (
|
||||
<span>
|
||||
该订单用户付款 {formatMoney(paymentFen)},发单金额{' '}
|
||||
<Typography.Text strong type="danger">
|
||||
{formatMoney(nextFen)}
|
||||
</Typography.Text>
|
||||
,本单平台将亏损。确认保存?
|
||||
</span>
|
||||
),
|
||||
okText: '确认保存',
|
||||
cancelText: '取消',
|
||||
onOk: () => doSave(nextYuan),
|
||||
})
|
||||
return
|
||||
}
|
||||
await doSave(nextYuan)
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Typography.Text
|
||||
title={editable ? '双击修改金额' : undefined}
|
||||
strong={isLossOrder}
|
||||
type={isLossOrder ? 'danger' : undefined}
|
||||
style={{
|
||||
cursor: editable ? 'pointer' : 'default',
|
||||
...(editable && !isLossOrder ? { color: 'rgba(0,0,0,0.65)' } : {}),
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!editable) return
|
||||
setDraft(currentYuan)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{formatMoney(currentFen)}
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
autoFocus
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
step={1}
|
||||
style={{ width: 92 }}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onPressEnter={() => void save()}
|
||||
onBlur={() => void save()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FormOutlined,
|
||||
PlusOutlined,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
@@ -51,7 +50,6 @@ import {
|
||||
assignAdminWorkOrderToWorker,
|
||||
cancelAdminWorkOrder,
|
||||
cancelAdminWorkOrderShare,
|
||||
createAdminMockWorkOrder,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkOrder,
|
||||
fetchAdminWorkCategories,
|
||||
@@ -69,18 +67,10 @@ import {
|
||||
updateAdminWorkOrder,
|
||||
updateAdminWorkOrderSharing,
|
||||
} from '@/services/admin'
|
||||
import { fetchAdminKuaishouIndustryShops } from '@/services/admin/kuaishou-industry'
|
||||
import type {
|
||||
CollectField,
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkOrderShare,
|
||||
WorkOrderStatistics,
|
||||
} from '@/types/worker-platform'
|
||||
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildMockWorkOrderShopOptions } from '@/utils/mock-work-order'
|
||||
import {
|
||||
DeadlineCountdown,
|
||||
formatTimeoutPolicyLabel,
|
||||
@@ -100,6 +90,18 @@ import {
|
||||
resolveSharingShareStatusColor,
|
||||
resolveStatusColor,
|
||||
} from './shared'
|
||||
import { AmountEditableCell, ProductNameEditableCell } from './WorkOrderTableCells'
|
||||
import { MockWorkOrderForm, WorkOrderStatisticsBar } from './WorkOrderPanelParts'
|
||||
import {
|
||||
formatRequirementFieldsText,
|
||||
formatUserPaymentAmount,
|
||||
getDisplayOrderNo,
|
||||
getUserPaymentFen,
|
||||
parseWorkerMaterialText,
|
||||
renderWorkOrderWorkers,
|
||||
setSkipPublishConfirm,
|
||||
shouldSkipPublishConfirm,
|
||||
} from './work-order-panel-utils'
|
||||
|
||||
const FILTERABLE_WORK_ORDER_STATUSES = [
|
||||
'pending_material',
|
||||
@@ -114,8 +116,6 @@ const FILTERABLE_WORK_ORDER_STATUSES = [
|
||||
] as const
|
||||
|
||||
const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
|
||||
const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm'
|
||||
|
||||
type MaterialFormValues = {
|
||||
fields?: Record<string, string>
|
||||
rewardAmount?: number
|
||||
@@ -1963,445 +1963,3 @@ export default function WorkOrdersPanel() {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const WORK_ORDER_STATISTICS_STATUS_ORDER = [
|
||||
'pending_material',
|
||||
'unassigned',
|
||||
'open',
|
||||
'in_progress',
|
||||
'pending_acceptance',
|
||||
'vip_evidence_pending',
|
||||
'problem',
|
||||
'accepted',
|
||||
'cancelled',
|
||||
]
|
||||
|
||||
function WorkOrderStatisticsBar({ statistics }: { statistics?: WorkOrderStatistics }) {
|
||||
if (!statistics) return null
|
||||
const profit = Number(statistics.userPaymentAmount || 0) - Number(statistics.rewardAmount || 0)
|
||||
const statusItems = WORK_ORDER_STATISTICS_STATUS_ORDER.map((status) => ({
|
||||
status,
|
||||
count: Number(statistics.statusCounts?.[status] || 0),
|
||||
}))
|
||||
const otherStatusItems = Object.entries(statistics.statusCounts || {})
|
||||
.filter(([status]) => !WORK_ORDER_STATISTICS_STATUS_ORDER.includes(status))
|
||||
.map(([status, count]) => ({ status, count: Number(count || 0) }))
|
||||
|
||||
return (
|
||||
<Card size="small" bordered={false} className="worker-orders-statistics-card">
|
||||
<div className="worker-orders-statistics-summary">
|
||||
本次筛选统计共 <strong>{statistics.totalCount}</strong> 笔,预计总发单价{' '}
|
||||
<strong>{formatMoney(statistics.rewardAmount)}</strong>(不含销售提成),总接单价{' '}
|
||||
<strong>{formatMoney(statistics.userPaymentAmount)}</strong>,总退款{' '}
|
||||
<strong>{formatMoney(statistics.refundAmount)}</strong>,预计利润{' '}
|
||||
<strong className={profit < 0 ? 'is-negative' : ''}>{formatMoney(profit)}</strong>。
|
||||
</div>
|
||||
<div className="worker-orders-statistics-statuses">
|
||||
其中:
|
||||
{[...statusItems, ...otherStatusItems].map(({ status, count }, index) => (
|
||||
<span key={status}>
|
||||
{index > 0 ? ',' : ''}
|
||||
{formatStatus(status)}
|
||||
{count}笔
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function shouldSkipPublishConfirm() {
|
||||
try {
|
||||
return localStorage.getItem(SKIP_PUBLISH_CONFIRM_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setSkipPublishConfirm() {
|
||||
try {
|
||||
localStorage.setItem(SKIP_PUBLISH_CONFIRM_KEY, '1')
|
||||
} catch {
|
||||
// 浏览器禁用本地存储时继续发布,但不记住本次选择。
|
||||
}
|
||||
}
|
||||
|
||||
function formatUserPaymentAmount(order: WorkOrder): string {
|
||||
const paymentFen = getUserPaymentFen(order)
|
||||
return paymentFen === null ? '-' : formatMoney(paymentFen)
|
||||
}
|
||||
|
||||
/** 拼单母单没有单一 assigned worker,改为展示当前参与打手及其承接份数。 */
|
||||
function renderWorkOrderWorkers(order: WorkOrder) {
|
||||
if (order.worker) return order.worker.displayName || order.worker.username || '-'
|
||||
if (!order.sharing?.enabled || !order.sharingWorkers?.length) return '-'
|
||||
const names = order.sharingWorkers
|
||||
.map((worker) => worker.displayName || worker.username || `打手#${worker.workerId}`)
|
||||
.join('、')
|
||||
const quantity = order.sharingWorkers.reduce(
|
||||
(sum, worker) => sum + Number(worker.quantity || 0),
|
||||
0,
|
||||
)
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text ellipsis={{ tooltip: names }}>{names}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{order.sharingWorkers.length} 人 · {quantity} 份
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getDisplayOrderNo(order: Pick<WorkOrder, 'platformOrderId'> | null | undefined): string {
|
||||
return String(order?.platformOrderId || '').trim() || '-'
|
||||
}
|
||||
|
||||
/** 用户付款金额(分);未知返回 null(不参与超价提示) */
|
||||
function getUserPaymentFen(order: WorkOrder): number | null {
|
||||
const material = asRecord(order.material)
|
||||
const source = asRecord(material.source)
|
||||
const sendCode = asRecord(source.kuaishouSendCode)
|
||||
const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen)
|
||||
return Number.isFinite(paymentFen) ? paymentFen : null
|
||||
}
|
||||
|
||||
/** 商品名:单击看详情,双击原地修改(仅未接走工单) */
|
||||
function ProductNameEditableCell({
|
||||
order,
|
||||
onOpenDetail,
|
||||
onSaved,
|
||||
}: {
|
||||
order: WorkOrder
|
||||
onOpenDetail: () => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const { message } = App.useApp()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const savingRef = useRef(false)
|
||||
const clickTimerRef = useRef<number | null>(null)
|
||||
|
||||
const editable = order.capabilities?.canEdit === true
|
||||
|
||||
function handleClick() {
|
||||
if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = window.setTimeout(() => onOpenDetail(), 260)
|
||||
}
|
||||
|
||||
function handleDoubleClick() {
|
||||
if (clickTimerRef.current) {
|
||||
window.clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = null
|
||||
}
|
||||
if (!editable) return
|
||||
setDraft(order.productName || '')
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return
|
||||
const next = draft.trim()
|
||||
if (!next) {
|
||||
message.error('商品名不能为空')
|
||||
return
|
||||
}
|
||||
if (next === order.productName) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
try {
|
||||
await updateAdminWorkOrder(order.workOrderId, { productName: next })
|
||||
message.success('商品名已更新')
|
||||
setEditing(false)
|
||||
await onSaved()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Typography.Link
|
||||
title={editable ? '双击修改商品名' : undefined}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{order.productName || '未命名商品'}
|
||||
</Typography.Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
size="small"
|
||||
style={{ width: 200 }}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onPressEnter={() => void save()}
|
||||
onBlur={() => void save()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AmountEditableCell({
|
||||
order,
|
||||
field,
|
||||
onSaved,
|
||||
}: {
|
||||
order: WorkOrder
|
||||
field: 'rewardAmount' | 'requiredDepositAmount'
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const { message, modal } = App.useApp()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<number | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
|
||||
const editable = order.capabilities?.canEdit === true
|
||||
const currentFen = Number(order[field] || 0)
|
||||
const currentYuan = currentFen / 100
|
||||
const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null
|
||||
const isLossOrder = paymentFen !== null && currentFen > paymentFen
|
||||
|
||||
async function doSave(nextYuan: number) {
|
||||
if (savingRef.current) return
|
||||
savingRef.current = true
|
||||
try {
|
||||
await updateAdminWorkOrder(order.workOrderId, { [field]: nextYuan })
|
||||
message.success('金额已更新')
|
||||
setEditing(false)
|
||||
await onSaved()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return
|
||||
if (draft === null || !Number.isFinite(Number(draft))) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
const nextYuan = Number(draft)
|
||||
if (nextYuan < 0) {
|
||||
message.error('金额不能为负数')
|
||||
return
|
||||
}
|
||||
if (Math.abs(nextYuan - currentYuan) < 0.005) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
const nextFen = Math.round(nextYuan * 100)
|
||||
if (paymentFen !== null && nextFen > paymentFen) {
|
||||
modal.confirm({
|
||||
title: '发单金额高于用户付款金额',
|
||||
content: (
|
||||
<span>
|
||||
该订单用户付款 {formatMoney(paymentFen)},发单金额{' '}
|
||||
<Typography.Text strong type="danger">
|
||||
{formatMoney(nextFen)}
|
||||
</Typography.Text>
|
||||
,本单平台将亏损。确认保存?
|
||||
</span>
|
||||
),
|
||||
okText: '确认保存',
|
||||
cancelText: '取消',
|
||||
onOk: () => doSave(nextYuan),
|
||||
})
|
||||
return
|
||||
}
|
||||
await doSave(nextYuan)
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Typography.Text
|
||||
title={editable ? '双击修改金额' : undefined}
|
||||
strong={isLossOrder}
|
||||
type={isLossOrder ? 'danger' : undefined}
|
||||
style={{
|
||||
cursor: editable ? 'pointer' : 'default',
|
||||
...(editable && !isLossOrder ? { color: 'rgba(0,0,0,0.65)' } : {}),
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!editable) return
|
||||
setDraft(currentYuan)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{formatMoney(currentFen)}
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
autoFocus
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
step={1}
|
||||
style={{ width: 92 }}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onPressEnter={() => void save()}
|
||||
onBlur={() => void save()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 解析客服粘贴文本(如「安卓还是苹果(必填):安卓」),返回字段 key → 值 */
|
||||
function parseWorkerMaterialText(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
const lines = String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(.+?)[::]\s*(.+)$/)
|
||||
if (!match) continue
|
||||
const label = match[1]
|
||||
.replace(/[((](必填|选填)[))]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const value = match[2].trim()
|
||||
if (!label || !value) continue
|
||||
|
||||
if (/系统|安卓|苹果/.test(label)) {
|
||||
if (value.includes('苹果')) result.system = '苹果'
|
||||
else if (value.includes('安卓')) result.system = '安卓'
|
||||
} else if (/区/.test(label) || /服/.test(label)) {
|
||||
const zoneValue = value.toLowerCase()
|
||||
if (zoneValue.includes('微信') || /^v/.test(zoneValue)) result.serverZone = '微信区'
|
||||
else if (zoneValue.includes('q')) result.serverZone = 'QQ区'
|
||||
} else if (/角色|昵称|名称/.test(label)) {
|
||||
result.gameNickname = value
|
||||
} else if (/编号|游戏id|账号id|^id|uid/.test(label)) {
|
||||
result.gameId = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatRequirementFieldsText(fields: CollectField[]): string {
|
||||
return fields
|
||||
.map((field) => {
|
||||
const labelPart = `${field.key}:${field.label}`
|
||||
const options = (field.options || []).filter(Boolean)
|
||||
return options.length > 0 ? `${labelPart}#${options.join(',')}` : labelPart
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const shopsQuery = useQuery({
|
||||
queryKey: ['admin-kuaishou-industry-shops', 'mock-work-order'],
|
||||
queryFn: fetchAdminKuaishouIndustryShops,
|
||||
})
|
||||
const shopOptions = buildMockWorkOrderShopOptions(shopsQuery.data?.data.shops || [])
|
||||
|
||||
async function submit(values: {
|
||||
platformOrderId: string
|
||||
sellerId: string
|
||||
productName: string
|
||||
rewardAmount: number
|
||||
paymentAmount: 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 className="worker-order-mock-form" layout="inline" onFinish={submit}>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-order"
|
||||
label="订单号"
|
||||
name="platformOrderId"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写订单号' }]}
|
||||
>
|
||||
<Input placeholder="请输入订单号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-shop"
|
||||
label="店铺"
|
||||
name="sellerId"
|
||||
rules={[{ required: true, message: '请选择店铺' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={shopsQuery.isLoading}
|
||||
options={shopOptions}
|
||||
placeholder={shopOptions.length > 0 ? '请选择店铺' : '暂无可用店铺'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-product"
|
||||
label="商品"
|
||||
name="productName"
|
||||
rules={[{ required: true, whitespace: true, message: '请填写商品名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入商品名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-amount"
|
||||
label="接单金额"
|
||||
name="rewardAmount"
|
||||
rules={[
|
||||
{ required: true, message: '请填写接单金额' },
|
||||
{ type: 'number', min: 0.01, message: '接单金额必须大于 0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-amount"
|
||||
label="用户付款金额"
|
||||
name="paymentAmount"
|
||||
rules={[
|
||||
{ required: true, message: '请填写用户付款金额' },
|
||||
{ type: 'number', min: 0.01, message: '用户付款金额必须大于 0' },
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="worker-order-mock-field-complete"
|
||||
label="资料完整"
|
||||
name="materialComplete"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item className="worker-order-mock-action">
|
||||
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
||||
生成
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Typography } from 'antd'
|
||||
|
||||
import type { CollectField, WorkOrder } from '@/types/worker-platform'
|
||||
|
||||
import { asRecord, formatMoney } from './shared'
|
||||
|
||||
const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm'
|
||||
|
||||
export function shouldSkipPublishConfirm() {
|
||||
try {
|
||||
return localStorage.getItem(SKIP_PUBLISH_CONFIRM_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setSkipPublishConfirm() {
|
||||
try {
|
||||
localStorage.setItem(SKIP_PUBLISH_CONFIRM_KEY, '1')
|
||||
} catch {
|
||||
// 浏览器禁用本地存储时继续发布,但不记住本次选择。
|
||||
}
|
||||
}
|
||||
|
||||
export function formatUserPaymentAmount(order: WorkOrder): string {
|
||||
const paymentFen = getUserPaymentFen(order)
|
||||
return paymentFen === null ? '-' : formatMoney(paymentFen)
|
||||
}
|
||||
|
||||
/** 拼单母单没有单一 assigned worker,改为展示当前参与打手及其承接份数。 */
|
||||
export function renderWorkOrderWorkers(order: WorkOrder) {
|
||||
if (order.worker) return order.worker.displayName || order.worker.username || '-'
|
||||
if (!order.sharing?.enabled || !order.sharingWorkers?.length) return '-'
|
||||
const names = order.sharingWorkers
|
||||
.map((worker) => worker.displayName || worker.username || `打手#${worker.workerId}`)
|
||||
.join('、')
|
||||
const quantity = order.sharingWorkers.reduce(
|
||||
(sum, worker) => sum + Number(worker.quantity || 0),
|
||||
0,
|
||||
)
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text ellipsis={{ tooltip: names }}>{names}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{order.sharingWorkers.length} 人 · {quantity} 份
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getDisplayOrderNo(
|
||||
order: Pick<WorkOrder, 'platformOrderId'> | null | undefined,
|
||||
): string {
|
||||
return String(order?.platformOrderId || '').trim() || '-'
|
||||
}
|
||||
|
||||
/** 用户付款金额(分);未知返回 null(不参与超价提示) */
|
||||
export function getUserPaymentFen(order: WorkOrder): number | null {
|
||||
const material = asRecord(order.material)
|
||||
const source = asRecord(material.source)
|
||||
const sendCode = asRecord(source.kuaishouSendCode)
|
||||
const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen)
|
||||
return Number.isFinite(paymentFen) ? paymentFen : null
|
||||
}
|
||||
|
||||
/** 解析客服粘贴文本(如「安卓还是苹果(必填):安卓」),返回字段 key → 值 */
|
||||
export function parseWorkerMaterialText(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
const lines = String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(.+?)[::]\s*(.+)$/)
|
||||
if (!match) continue
|
||||
const label = match[1]
|
||||
.replace(/[((](必填|选填)[))]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const value = match[2].trim()
|
||||
if (!label || !value) continue
|
||||
|
||||
if (/系统|安卓|苹果/.test(label)) {
|
||||
if (value.includes('苹果')) result.system = '苹果'
|
||||
else if (value.includes('安卓')) result.system = '安卓'
|
||||
} else if (/区/.test(label) || /服/.test(label)) {
|
||||
const zoneValue = value.toLowerCase()
|
||||
if (zoneValue.includes('微信') || /^v/.test(zoneValue)) result.serverZone = '微信区'
|
||||
else if (zoneValue.includes('q')) result.serverZone = 'QQ区'
|
||||
} else if (/角色|昵称|名称/.test(label)) {
|
||||
result.gameNickname = value
|
||||
} else if (/编号|游戏id|账号id|^id|uid/.test(label)) {
|
||||
result.gameId = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatRequirementFieldsText(fields: CollectField[]): string {
|
||||
return fields
|
||||
.map((field) => {
|
||||
const labelPart = `${field.key}:${field.label}`
|
||||
const options = (field.options || []).filter(Boolean)
|
||||
return options.length > 0 ? `${labelPart}#${options.join(',')}` : labelPart
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user