接单工单支持任务时限,超时自动判定失败并处置

This commit is contained in:
yml2213
2026-08-01 19:02:57 +08:00
parent 66193c5d59
commit 23ad60a1ef
18 changed files with 681 additions and 61 deletions
@@ -0,0 +1,34 @@
import { Tag } from 'antd'
import { useEffect, useState } from 'react'
export function DeadlineCountdown({
deadlineAt,
showExpired = false,
}: {
deadlineAt: string
showExpired?: boolean
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const remainingMs = new Date(deadlineAt).getTime() - now
if (remainingMs <= 0) {
return <Tag color="red"></Tag>
}
const totalSeconds = Math.floor(remainingMs / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
const label = minutes > 0 ? `${minutes}${seconds}` : `${seconds}`
const color = remainingMs < 10 * 60 * 1000 ? 'orange' : 'blue'
return <Tag color={color}> {label}</Tag>
}
export function formatTimeoutPolicyLabel(policy?: string) {
if (policy === 'cancel_release') return '超时取消退押金'
if (policy === 'cancel_deduct') return '超时取消扣押金'
return '超时退回大厅'
}
@@ -113,6 +113,8 @@ export default function ProductRulesPanel() {
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingTotalAmount?: number
timeoutMinutes?: number
timeoutPolicy?: string
fieldsText?: string
enabled?: boolean
autoCreate?: boolean
@@ -389,6 +391,27 @@ export default function ProductRulesPanel() {
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label="默认任务时限"
name="timeoutMinutes"
tooltip="自动创建的工单继承该时限,打手抢单后开始计时;0 表示不限时"
>
<InputNumber min={0} step={5} addonAfter="分钟" />
</Form.Item>
<Form.Item
label="默认超时策略"
name="timeoutPolicy"
tooltip="reopen:释放押金退回大厅;cancel_release:取消退押金;cancel_deduct:取消扣押金"
>
<Select
style={{ width: 260 }}
options={[
{ value: 'reopen', label: '释放押金退回大厅' },
{ value: 'cancel_release', label: '取消订单并退还押金' },
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
@@ -87,6 +87,8 @@ import {
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
DeadlineCountdown,
formatTimeoutPolicyLabel,
getAcceptanceFiles,
getAcceptanceImageUrls,
getAcceptanceSubmittedAt,
@@ -129,6 +131,8 @@ export default function WorkOrdersPanel() {
rewardAmount?: number
requiredDepositAmount?: number
fieldsText?: string
timeoutMinutes?: number
timeoutPolicy?: string
}>()
const [sharingForm] = Form.useForm<{
enabled?: boolean
@@ -320,6 +324,8 @@ export default function WorkOrdersPanel() {
requiredDepositAmount:
Math.round(Number(row.requiredDepositAmount || 0)) / 100,
fieldsText: formatRequirementFieldsText(getRequirementFields(row)),
timeoutMinutes: Number(row.timeoutMinutes || 0),
timeoutPolicy: row.timeoutPolicy || 'reopen',
})
}
@@ -330,6 +336,8 @@ export default function WorkOrdersPanel() {
rewardAmount?: number
requiredDepositAmount?: number
fieldsText?: string
timeoutMinutes?: number
timeoutPolicy?: string
}) {
if (!editOrder) return
const succeeded = await runAction(
@@ -341,6 +349,8 @@ export default function WorkOrdersPanel() {
rewardAmount: Number(values.rewardAmount || 0),
requiredDepositAmount: Number(values.requiredDepositAmount || 0),
fieldsText: String(values.fieldsText || ''),
timeoutMinutes: Math.max(0, Number(values.timeoutMinutes || 0)),
timeoutPolicy: values.timeoutPolicy || 'reopen',
}),
'工单信息已保存',
)
@@ -398,6 +408,34 @@ export default function WorkOrdersPanel() {
width: 110,
render: (_, row) => formatMoney(row.requiredDepositAmount),
},
{
title: '时限',
width: 190,
render: (_, row) => {
const minutes = Number(row.timeoutMinutes || 0)
if (minutes <= 0) {
return <Typography.Text type="secondary"></Typography.Text>
}
return (
<div className="cell-stack">
<Space wrap size={6}>
<Tag color="blue">{minutes} </Tag>
<Typography.Text type="secondary">
{formatTimeoutPolicyLabel(row.timeoutPolicy)}
</Typography.Text>
</Space>
{row.status === 'in_progress' && row.deadlineAt ? (
<DeadlineCountdown deadlineAt={row.deadlineAt} showExpired />
) : null}
{row.deadlineAt ? (
<Typography.Text type="secondary">
{formatAdminDateTime(row.deadlineAt)}
</Typography.Text>
) : null}
</div>
)
},
},
{
title: '拼单',
width: 190,
@@ -699,6 +737,29 @@ export default function WorkOrdersPanel() {
<Descriptions.Item label="押金">
{formatMoney(detailOrder.requiredDepositAmount)}
</Descriptions.Item>
<Descriptions.Item label="任务时限">
{Number(detailOrder.timeoutMinutes || 0) > 0
? `${detailOrder.timeoutMinutes} 分钟`
: '不限时'}
</Descriptions.Item>
<Descriptions.Item label="超时策略">
{formatTimeoutPolicyLabel(detailOrder.timeoutPolicy)}
</Descriptions.Item>
<Descriptions.Item label="超时截止">
{detailOrder.deadlineAt ? (
<Space wrap size={8}>
<DeadlineCountdown
deadlineAt={detailOrder.deadlineAt}
showExpired
/>
<Typography.Text type="secondary">
{formatAdminDateTime(detailOrder.deadlineAt)}
</Typography.Text>
</Space>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="发布时间">
{formatAdminDateTime(detailOrder.publishedAt)}
</Descriptions.Item>
@@ -857,6 +918,32 @@ export default function WorkOrdersPanel() {
>
<Input.TextArea rows={5} />
</Form.Item>
<Form.Item
label="任务时限"
name="timeoutMinutes"
tooltip="打手抢单后开始计时,超时由系统自动按策略处置;0 表示不限时"
>
<InputNumber
min={0}
step={5}
addonAfter="分钟"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item
label="超时策略"
name="timeoutPolicy"
tooltip="reopen:释放押金退回大厅继续可抢;cancel_release:取消订单并退还押金;cancel_deduct:取消订单并扣除押金"
>
<Select
style={{ width: 320 }}
options={[
{ value: 'reopen', label: '释放押金退回大厅' },
{ value: 'cancel_release', label: '取消订单并退还押金' },
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
]}
/>
</Form.Item>
</Form>
) : null}
</Modal>
@@ -1,8 +1,14 @@
import { Descriptions, Space, Tag, Typography } from 'antd'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
DeadlineCountdown,
formatTimeoutPolicyLabel,
} from '@/components/DeadlineCountdown'
import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
export { DeadlineCountdown, formatTimeoutPolicyLabel }
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields)
@@ -267,17 +267,22 @@ function NotificationPanel({
const [testResult, setTestResult] = useState<AdminNotificationTestResult | null>(null)
const source = notificationConfig.source
const monitorAccounts = scheduledJobs.cloudtentaclesAccounts || []
const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => ({
...job,
config: {
...job.config,
accounts: mergeScheduledJobAccounts(
job.config?.accounts || [],
monitorAccounts,
Number(job.config?.assetThreshold ?? 500),
),
},
}))
const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => {
if (job.type !== 'cloudtentacles_health') {
return job
}
return {
...job,
config: {
...job.config,
accounts: mergeScheduledJobAccounts(
job.config?.accounts || [],
monitorAccounts,
Number(job.config?.assetThreshold ?? 500),
),
},
}
})
async function saveNotification() {
setSavingNotification(true)
@@ -313,18 +318,23 @@ function NotificationPanel({
try {
const payload: AdminScheduledJobsConfig = {
...scheduledJobs.source,
jobs: jobsWithMergedAccounts.map((job) => ({
...job,
config: {
assetThreshold: Number(job.config?.assetThreshold ?? 500),
accounts: (job.config?.accounts || []).map((account) => ({
sourceKey: account.sourceKey,
label: account.label,
enabled: account.enabled !== false,
assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500),
})),
},
})),
jobs: jobsWithMergedAccounts.map((job) => {
if (job.type !== 'cloudtentacles_health') {
return { ...job }
}
return {
...job,
config: {
assetThreshold: Number(job.config?.assetThreshold ?? 500),
accounts: (job.config?.accounts || []).map((account) => ({
sourceKey: account.sourceKey,
label: account.label,
enabled: account.enabled !== false,
assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500),
})),
},
}
}),
}
const response = await saveAdminScheduledJobsConfig(payload)
onScheduledJobsChange(response.data)
@@ -587,6 +597,72 @@ function ScheduledJobCard({
onRun: () => void
onChange: (job: AdminScheduledJobItem) => void
}) {
if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') {
return (
<Card
size="small"
title={
<Space wrap>
<Switch
checked={job.enabled !== false}
onChange={(enabled) => onChange({ ...job, enabled })}
/>
<span>{formatScheduledJobTitle(job)}</span>
<Tag color={job.enabled ? 'green' : 'default'}>
{job.enabled ? '已启用' : '已停用'}
</Tag>
</Space>
}
extra={
<Button icon={<ReloadOutlined />} loading={running} onClick={onRun}>
</Button>
}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
退 / 退 /
</Typography.Paragraph>
<div className="platform-form-grid">
<NumberField
label="执行间隔(秒)"
value={job.intervalSeconds}
min={1}
onChange={(intervalSeconds) => onChange({ ...job, intervalSeconds })}
/>
<NumberField
label="每次扫描数量"
value={Number(job.config?.scanLimit ?? 50)}
min={1}
onChange={(scanLimit) =>
onChange({ ...job, config: { ...job.config, scanLimit } })
}
/>
</div>
{runtime ? (
<Alert
className="platform-section-gap"
type={
runtime.lastStatus === 'ok' || runtime.lastStatus === 'success'
? 'success'
: runtime.lastStatus
? 'warning'
: 'info'
}
showIcon
message={runtime.lastMessage || '尚未运行'}
description={[
`扫描 ${runtime.lastCheckedCount ?? 0}`,
`处置 ${runtime.lastAsset ?? 0}`,
`跳过 ${runtime.lastFailedCount ?? 0}`,
`上次:${formatAdminDateTime(runtime.lastFinishedAt || runtime.lastRunAt)}`,
`下次:${formatAdminDateTime(runtime.nextRunAt)}`,
].join(' · ')}
/>
) : null}
</Card>
)
}
const accounts = job.config?.accounts || []
const defaultThreshold = Number(job.config?.assetThreshold ?? 500)
@@ -812,6 +888,9 @@ function formatScheduledJobTitle(job: AdminScheduledJobItem) {
if (job.type === 'cloudtentacles_health' || job.id === 'cloudtentacles-health') {
return 'kuaishou-lewan 健康检查'
}
if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') {
return '接单工单超时扫描'
}
return job.id || job.type || '定时任务'
}
@@ -25,8 +25,9 @@ import {
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import ImageUpload from '@/components/files/ImageUpload'
import {
@@ -227,6 +228,9 @@ export default function WorkerOrdersPage() {
render: (_, row) => (
<div className="cell-stack">
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.status === 'in_progress' && row.deadlineAt ? (
<DeadlineCountdown deadlineAt={row.deadlineAt} />
) : null}
<Typography.Text type="secondary">
{getStatusHint(row.status)}
</Typography.Text>
@@ -104,6 +104,8 @@ export function saveAdminWorkProductRule(payload: {
sharingTotalQuantity?: number
sharingUnitReward?: number
sharingTotalAmount?: number
timeoutMinutes?: number
timeoutPolicy?: string
fieldsText?: string
sortOrder?: number
}) {
@@ -228,6 +230,8 @@ export function updateAdminWorkOrder(
rewardAmount?: number
requiredDepositAmount?: number
fieldsText?: string
timeoutMinutes?: number
timeoutPolicy?: string
},
) {
return apiPut<{ order: WorkOrder }>(
@@ -12,8 +12,10 @@ export interface AdminScheduledJobItem {
intervalSeconds: number
cooldownSeconds: number
config: {
assetThreshold: number
accounts: AdminScheduledJobCloudtentaclesAccount[]
assetThreshold?: number
accounts?: AdminScheduledJobCloudtentaclesAccount[]
scanLimit?: number
[key: string]: unknown
}
}
@@ -148,6 +148,8 @@ export type WorkProductRule = {
requirement: {
fields: CollectField[]
}
timeoutMinutes?: number
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
sortOrder: number
createdAt: string
updatedAt: string
@@ -221,6 +223,9 @@ export type WorkOrder = {
joinedQuantity: number
pendingSubmissionCount: number
}
timeoutMinutes?: number
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
deadlineAt?: string | null
material: Record<string, unknown>
requirement: Record<string, unknown>
acceptance: {