接单工单支持任务时限,超时自动判定失败并处置
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user