feat(work-order): 新增一次性补资料链接

This commit is contained in:
2026-11-01 10:46:31 +08:00
parent fba0a13d44
commit 15956e704f
26 changed files with 1768 additions and 4 deletions
@@ -32,6 +32,7 @@ import type { WorkerAnnouncementConfig } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
import HallConfigPanel from './HallConfigPanel'
import MaterialLinkConfigPanel from './MaterialLinkConfigPanel'
function isDepositUnfreezeJob(job: AdminScheduledJobItem) {
return job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze'
@@ -44,6 +45,7 @@ export default function AnnouncementPanel() {
<DepositUnfreezeCard />
<MockCustomShopsCard />
<HallConfigPanel />
<MaterialLinkConfigPanel />
</Space>
)
}
@@ -0,0 +1,74 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Button, Card, Form, InputNumber, Space, Typography } from 'antd'
import { useEffect, useState } from 'react'
import {
fetchAdminWorkerMaterialLinkConfig,
saveAdminWorkerMaterialLinkConfig,
} from '@/services/admin'
import type { WorkerMaterialLinkConfig } from '@/types/worker-platform'
export default function MaterialLinkConfigPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<WorkerMaterialLinkConfig>()
const [saving, setSaving] = useState(false)
const configQuery = useQuery({
queryKey: ['admin-worker-material-link-config'],
queryFn: fetchAdminWorkerMaterialLinkConfig,
})
const config = configQuery.data?.data
useEffect(() => {
if (config) {
form.setFieldsValue({ ttlMinutes: config.ttlMinutes })
}
}, [config, form])
async function saveConfig(values: WorkerMaterialLinkConfig) {
setSaving(true)
try {
await saveAdminWorkerMaterialLinkConfig({ ttlMinutes: Number(values.ttlMinutes) })
await queryClient.invalidateQueries({ queryKey: ['admin-worker-material-link-config'] })
message.success('补资料链接配置已保存')
} catch (error) {
message.error(error instanceof Error ? error.message : '保存补资料链接配置失败')
} finally {
setSaving(false)
}
}
return (
<Card title="补资料链接" bordered={false} loading={configQuery.isLoading}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 700 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
客服在「接单工单」复制的一次性补资料链接,客户只能修改补资料内容;提交成功后工单转为「未分配」,
链接立即失效,需要重新发送。修改有效期只影响新生成的链接。
</Typography.Paragraph>
<Form form={form} layout="vertical" onFinish={saveConfig}>
<Form.Item
name="ttlMinutes"
label="链接有效期(分钟)"
extra="默认 30 分钟,可设置 1 - 10080 分钟(7 天)。"
rules={[{ required: true, message: '请输入链接有效期' }]}
style={{ marginBottom: 12 }}
>
<InputNumber
min={1}
max={10080}
precision={0}
style={{ width: 220 }}
addonAfter="分钟"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={saving}>
保存配置
</Button>
</Form.Item>
</Form>
</Space>
</Card>
)
}
@@ -4,6 +4,7 @@ import {
DeleteOutlined,
EditOutlined,
FormOutlined,
LinkOutlined,
PushpinOutlined,
ReloadOutlined,
SearchOutlined,
@@ -59,6 +60,7 @@ import {
cancelAdminWorkOrderSharingPart,
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriend,
createAdminWorkOrderMaterialLink,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
disposeAdminWorkOrderShareProblem,
@@ -198,6 +200,40 @@ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo |
return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null
}
/** 公开补资料链接只在「待完善 / 未分配」阶段生成;已进大厅或已接单不允许客户改资料。 */
function canCopyMaterialLink(order: WorkOrder): boolean {
const status = String(order.rawStatus || order.status || '')
return status === 'pending_material' || status === 'unassigned'
}
/** 剪贴板 API 在 HTTP + IP 下可能不可用,补一个 textarea 兜底。 */
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
// 继续走 execCommand 兜底
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '0'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
/**
* 寄卖单号 = 来源提报单号(提报单号即寄卖单号),客服界面只认这一个号。
*
@@ -312,6 +348,11 @@ export default function WorkOrdersPanel() {
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
const [materialPasteText, setMaterialPasteText] = useState('')
const [materialLinkLoadingId, setMaterialLinkLoadingId] = useState<number | null>(null)
const [materialLinkFallback, setMaterialLinkFallback] = useState<{
url: string
expiresAt: string
} | null>(null)
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
const [reduceShare, setReduceShare] = useState<WorkOrderShare | null>(null)
@@ -1590,6 +1631,27 @@ export default function WorkOrdersPanel() {
.catch(() => message.error('复制失败,请手动复制'))
}
async function copyMaterialLink(order: WorkOrder) {
setMaterialLinkLoadingId(order.workOrderId)
try {
const response = await createAdminWorkOrderMaterialLink(order.workOrderId)
const url = `${window.location.origin}/supplement/${encodeURIComponent(response.data.token)}`
const copied = await copyTextToClipboard(url)
const expiresText = formatAdminDateTime(response.data.expiresAt)
if (copied) {
message.success(
`${response.data.reused ? '已复制现有链接' : '已生成并复制新链接'},${expiresText} 前有效,仅可提交一次`,
)
} else {
setMaterialLinkFallback({ url, expiresAt: response.data.expiresAt })
}
} catch (error) {
message.error(error instanceof Error ? error.message : '生成补资料链接失败')
} finally {
setMaterialLinkLoadingId(null)
}
}
const columns: TableColumnsType<WorkOrder> = [
{
title: '订单',
@@ -1899,6 +1961,16 @@ export default function WorkOrdersPanel() {
{['open', 'in_progress'].includes(row.status) ? '更新资料' : '补资料'}
</Button>
) : null}
{row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? (
<Button
icon={<LinkOutlined />}
loading={materialLinkLoadingId === row.workOrderId}
onClick={() => void copyMaterialLink(row)}
title="生成一次性补资料链接(有效期内仅可提交一次)"
>
复制补资料链接
</Button>
) : null}
{row.capabilities?.canPublish && canPublishOrder ? (
<Button
icon={<SendOutlined />}
@@ -3147,6 +3219,46 @@ export default function WorkOrdersPanel() {
</Form>
</Modal>
<Modal
title="补资料链接"
open={Boolean(materialLinkFallback)}
onCancel={() => setMaterialLinkFallback(null)}
footer={
<Space>
<Button onClick={() => setMaterialLinkFallback(null)}>关闭</Button>
<Button
type="primary"
onClick={() => {
if (!materialLinkFallback) return
void copyTextToClipboard(materialLinkFallback.url).then((ok) => {
if (ok) {
message.success('链接已复制')
setMaterialLinkFallback(null)
} else {
message.error('复制失败,请手动选择链接复制')
}
})
}}
>
复制链接
</Button>
</Space>
}
>
{materialLinkFallback ? (
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Typography.Text type="secondary">
链接仅可提交一次,{formatAdminDateTime(materialLinkFallback.expiresAt)} 前有效。
</Typography.Text>
<Input.TextArea
value={materialLinkFallback.url}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Space>
) : null}
</Modal>
<Modal
title={`指派打手 · ${assignOrder?.productName || ''}`}
open={Boolean(assignOrder)}