Merge branch 'dev'

This commit is contained in:
2026-11-02 12:04:32 +08:00
38 changed files with 1615 additions and 942 deletions
@@ -32,7 +32,6 @@ 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'
@@ -45,7 +44,6 @@ export default function AnnouncementPanel() {
<DepositUnfreezeCard />
<MockCustomShopsCard />
<HallConfigPanel />
<MaterialLinkConfigPanel />
</Space>
)
}
@@ -1,74 +0,0 @@
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>
)
}
@@ -60,7 +60,6 @@ import {
cancelAdminWorkOrderSharingPart,
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriend,
createAdminWorkOrderMaterialLink,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
disposeAdminWorkOrderShareProblem,
@@ -78,6 +77,7 @@ import {
reduceAdminWorkOrderShareQuantity,
reopenAdminCancelledWorkOrder,
resetAdminWorkOrderGiftMaterial,
resetAdminWorkOrderMaterialStatus,
resolveAdminProblemWorkOrder,
submitAdminWorkOrderMaterial,
unpublishAdminWorkOrder,
@@ -106,6 +106,7 @@ import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { buildCollectMaterialLinkUrl } from '@/utils/collect-link'
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
import {
DeadlineCountdown,
@@ -203,10 +204,11 @@ 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'
const platformOrderId = String(order.platformOrderId || '').trim()
return (status === 'pending_material' || status === 'unassigned') && Boolean(platformOrderId)
}
/** 剪贴板 API 在 HTTP + IP 下可能不可用,补一个 textarea 兜底。 */
@@ -351,11 +353,7 @@ 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 [materialLinkFallback, setMaterialLinkFallback] = useState<{ url: string } | null>(null)
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
const [reduceShare, setReduceShare] = useState<WorkOrderShare | null>(null)
@@ -1491,7 +1489,7 @@ export default function WorkOrdersPanel() {
modal.confirm({
title: `确认删除订单 ${getDisplayOrderNo(row)}?`,
content:
'删除后工单将从列表中移除,且不可恢复。仅未分配、待完善或已取消且无拼单参与的工单可删除。',
'删除后工单将从列表中移除,且不可恢复。仅待分配、待完善或已取消且无拼单参与的工单可删除。',
okText: '确认删除',
okButtonProps: { danger: true },
cancelText: '取消',
@@ -1577,6 +1575,22 @@ export default function WorkOrdersPanel() {
})
}
function requestResetMaterial(row: WorkOrder) {
modal.confirm({
title: '重置补资料状态?',
content:
'将把该工单重置为“待完善”,客户可用订单号重新提交一次资料。已填资料会保留,客户重新提交后覆盖;此操作会记录在订单流转记录中。',
okText: '确认重置',
okButtonProps: { type: 'primary' },
cancelText: '取消',
onOk: () =>
runAction(
() => resetAdminWorkOrderMaterialStatus(row.workOrderId),
'已重置为待完善,客户刷新补资料页即可重新提交',
),
})
}
function copyBuyerMaterialInfo(order: WorkOrder) {
const items = getMaterialDetailItems(order).filter((item) => String(item.value || '').trim())
if (items.length === 0) {
@@ -1635,24 +1649,23 @@ export default function WorkOrdersPanel() {
}
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 platformOrderId = String(order.platformOrderId || '').trim()
if (!platformOrderId) {
message.error('复制补资料链接失败:缺少平台订单号')
return
}
const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId)
if (!url) {
message.error('复制补资料链接失败:链接地址为空')
return
}
const copied = await copyTextToClipboard(url)
if (copied) {
message.success(`补资料链接已复制(订单号 ${platformOrderId})`)
return
}
// 剪贴板 API 不可用时退回弹窗,让客服手动复制。
setMaterialLinkFallback({ url })
}
const columns: TableColumnsType<WorkOrder> = [
@@ -1914,13 +1927,13 @@ export default function WorkOrdersPanel() {
onClick={() => {
modal.confirm({
title: '确认重新启用该订单?',
content: '订单将恢复为未分配状态。请确认资料与金额后,再发布到抢单大厅。',
content: '订单将恢复为待分配状态。请确认资料与金额后,再发布到抢单大厅。',
okText: '重新启用',
cancelText: '取消',
onOk: () =>
runAction(
() => reopenAdminCancelledWorkOrder(row.workOrderId),
'订单已恢复为未分配状态,可编辑后发布',
'订单已恢复为待分配状态,可编辑后发布',
),
})
}}
@@ -1967,13 +1980,21 @@ export default function WorkOrdersPanel() {
{row.capabilities?.canUpdateMaterial && canUpdateMaterial && canCopyMaterialLink(row) ? (
<Button
icon={<LinkOutlined />}
loading={materialLinkLoadingId === row.workOrderId}
onClick={() => void copyMaterialLink(row)}
title="生成一次性补资料链接(有效期内仅可提交一次)"
title="复制补资料链接(按平台订单号打开)"
>
复制补资料链接
</Button>
) : null}
{row.capabilities?.canResetMaterial && canUpdateMaterial ? (
<Button
icon={<RollbackOutlined />}
onClick={() => requestResetMaterial(row)}
title="把已提交的补资料重置为待完善,客户可重新提交一次"
>
重置补资料
</Button>
) : null}
{row.capabilities?.canPublish && canPublishOrder ? (
<Button
icon={<SendOutlined />}
@@ -2006,11 +2027,11 @@ export default function WorkOrdersPanel() {
onClick={() => {
modal.confirm({
title: '确认下架该订单?',
content: '下架后订单会回到未分配状态,不再在抢单大厅展示。',
content: '下架后订单会回到待分配状态,不再在抢单大厅展示。',
okText: '确认下架',
cancelText: '取消',
onOk: () =>
runAction(() => unpublishAdminWorkOrder(row.workOrderId), '订单已撤回到未分配'),
runAction(() => unpublishAdminWorkOrder(row.workOrderId), '订单已撤回到待分配'),
})
}}
>
@@ -3251,7 +3272,7 @@ export default function WorkOrdersPanel() {
{materialLinkFallback ? (
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Typography.Text type="secondary">
链接仅可提交一次,{formatAdminDateTime(materialLinkFallback.expiresAt)} 前有效。
该链接按平台订单号打开,可查看该订单号下全部工单的补资料状态;每个工单仅可提交一次。
</Typography.Text>
<Input.TextArea
value={materialLinkFallback.url}
@@ -205,7 +205,7 @@ export function formatMoney(value: number | undefined) {
export function formatStatus(status: string) {
const labels: Record<string, string> = {
pending_material: '待完善',
unassigned: '未分配',
unassigned: '待分配',
open: '待抢单',
in_progress: '代练中',
pending_acceptance: '待验收',
@@ -0,0 +1,67 @@
import { ArrowRightOutlined } from '@ant-design/icons'
import { App, Button, Card, Form, Input, Space, Typography } from 'antd'
import { useNavigate } from 'react-router'
import { normalizeOrderNoInput } from '@/utils/collect-link'
import { useCollectLinkPageMeta } from './use-collect-link-page-meta'
const { Text, Title } = Typography
/**
* 补资料入口页(`/supplement/`)。
*
* 客户只知道平台订单号时,从这里输入订单号再跳到 `/supplement/<订单号>`;
* 如果客服已经发了完整链接,可以直接打开链接跳过这一步。
*/
export default function CollectMaterialLinkEntryPage() {
const { message } = App.useApp()
const navigate = useNavigate()
useCollectLinkPageMeta('订单资料补充')
function handleFinish(values: { orderNo?: string }) {
const orderNo = normalizeOrderNoInput(values.orderNo)
if (!orderNo) {
message.warning('请输入订单号')
return
}
void navigate(`/supplement/${encodeURIComponent(orderNo)}`)
}
return (
<main className="collect-link-page">
<Card className="collect-link-card" bordered={false}>
<Space direction="vertical" size={16} className="full-width">
<div>
<Title level={3} className="collect-link-title">
订单资料补充
</Title>
<Text type="secondary">请输入下单时使用的订单号,进入补资料页面。</Text>
</div>
<Form layout="vertical" onFinish={handleFinish}>
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
>
<Input autoComplete="off" allowClear placeholder="请输入订单号" size="large" />
</Form.Item>
<Button
type="primary"
size="large"
htmlType="submit"
block
icon={<ArrowRightOutlined />}
>
下一步
</Button>
</Form>
<Text type="secondary" className="collect-link-footnote">
如果客服已经发给你完整链接,直接打开链接即可。
</Text>
</Space>
</Card>
</main>
)
}
@@ -1,4 +1,4 @@
import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import { CheckCircleFilled, LoadingOutlined } from '@ant-design/icons'
import {
Alert,
App,
@@ -13,17 +13,23 @@ import {
Tag,
Typography,
} from 'antd'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router'
import ImageUpload from '@/components/files/ImageUpload'
import { uploadCollectMaterialLinkFile } from '@/services/files'
import { fetchCollectMaterialLink, submitCollectMaterialLink } from '@/services/worker'
import type { CollectMaterialLinkInfo, UploadedFile } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import type {
CollectMaterialLinkInfo,
CollectMaterialLinkOrder,
UploadedFile,
} from '@/types/worker-platform'
import { useCollectLinkPageMeta } from './use-collect-link-page-meta'
const { Text, Title } = Typography
const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确'
type LinkLoadError = {
code: string
message: string
@@ -38,253 +44,278 @@ function resolveErrorMessage(error: unknown): string {
}
function resolveErrorTitle(code: string): string {
if (code === 'material_link_expired') return '链接已过期'
if (code === 'material_link_revoked') return '链接已失效'
if (code === 'material_link_not_found') return '链接无效'
if (code === 'material_link_not_found' || code === 'material_link_work_order_invalid') {
return '订单号无效'
}
if (code === 'material_link_not_pending') return '该订单当前无需补充资料'
if (code === 'rate_limited') return '查询过于频繁'
if (code === 'material_link_work_order_required') return '请选择要补充的商品'
if (code === 'material_link_order_status_invalid') return '订单状态已变化'
return '无法打开链接'
return '暂时无法打开页面'
}
/** 公开页不展示「待分配」字样:已提交统一用「资料已提交」表达。 */
function resolveMaterialOrderMessage(order: CollectMaterialLinkOrder): string {
if (order.blockedReason === 'already_submitted') {
return '资料已提交;如需修改请联系客服。'
}
return order.message || '当前不能修改资料,如需修改请联系客服。'
}
/**
* 客户侧一次性补资料页。
* 客户侧补资料页。
*
* 只渲染 requirement 里的动态字段与买家主页截图,不展示发单价格、押金、
* 打手、后台备注等任何后台字段;提交成功后链接立即失效。
* 只查询「待完善」工单;其他状态(待分配、已验收等)统一提示无需补充资料,
* 不展示商品、订单号等信息。页面按 orders 渲染多张卡片,只有未指派、无份额的
* 待完善工单返回表单。提交成功后本地展示成功页;刷新后因工单已变为待分配,
* 会提示「该订单当前无需补充资料」。
*/
export default function CollectMaterialLinkPage() {
const { message } = App.useApp()
const { token: routeToken } = useParams<{ token?: string }>()
const token = String(routeToken || '').trim()
const { code: routeCode } = useParams<{ code?: string }>()
const code = String(routeCode || '').trim()
useCollectLinkPageMeta('订单资料补充')
const [form] = Form.useForm<Record<string, string>>()
const [loading, setLoading] = useState(true)
const [info, setInfo] = useState<CollectMaterialLinkInfo | null>(null)
const [loadError, setLoadError] = useState<LinkLoadError | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
const submittingRef = useRef(false)
const requestSeqRef = useRef(0)
/** React StrictMode 开发环境会重复执行 mount effect,这里按订单号去重,避免刷新时打两次查询接口。 */
const loadedCodeRef = useRef<string | null>(null)
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
const [submittedSuccess, setSubmittedSuccess] = useState(false)
const [submittedNames, setSubmittedNames] = useState<string[]>([])
useEffect(() => {
const previousTitle = document.title
document.title = '订单资料补充'
/**
* 读取订单资料状态。
*
* silent=true 用于提交后刷新:不切整页 loading,避免页面闪烁丢焦点;
* 同时用请求序号丢弃过期响应,防止快速重试时旧数据覆盖新数据。
*/
const loadInfo = useCallback(
async (options: { silent?: boolean } = {}) => {
const seq = requestSeqRef.current + 1
requestSeqRef.current = seq
function applyMeta(name: string, content: string) {
const existing = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
const previous = existing?.getAttribute('content') ?? null
let created: HTMLMetaElement | null = null
if (existing) {
existing.setAttribute('content', content)
} else {
created = document.createElement('meta')
created.setAttribute('name', name)
created.setAttribute('content', content)
document.head.appendChild(created)
if (!options.silent) {
// 新订单号/刷新页面时清空上一次提交成功状态;提交后不再回查
// (严格模式只允许查询待完善订单,回查会变成「无需补充资料」)。
setSubmittedSuccess(false)
setSubmittedNames([])
}
return () => {
if (created) {
created.remove()
} else if (existing) {
if (previous === null) {
existing.removeAttribute('content')
} else {
existing.setAttribute('content', previous)
}
}
}
}
const restoreRobots = applyMeta('robots', 'noindex,nofollow')
const restoreReferrer = applyMeta('referrer', 'no-referrer')
return () => {
document.title = previousTitle
restoreRobots()
restoreReferrer()
}
}, [])
useEffect(() => {
let cancelled = false
async function load() {
if (!token) {
if (!code) {
setInfo(null)
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
setLoading(false)
setLoadError({
code: 'material_link_not_found',
message: '链接无效,请确认链接是否完整',
})
return
}
setLoading(true)
setSubmitted(false)
if (!options.silent) setLoading(true)
try {
const response = await fetchCollectMaterialLink(token)
if (cancelled) return
const response = await fetchCollectMaterialLink(code)
if (seq !== requestSeqRef.current) return
const orders = response.data.orders || []
if (orders.length === 0) {
setInfo(null)
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
return
}
setInfo(response.data)
setLoadError(null)
setScreenshots(response.data.screenshots || [])
form.resetFields()
form.setFieldsValue(response.data.values || {})
setScreenshotsByOrder((previous) => {
// 只保留仍可提交商品的本地截图,避免刷新后覆盖用户刚上传的图片;
// 不可提交商品不再展示资料,直接丢弃。
const next: Record<number, UploadedFile[]> = {}
for (const order of orders) {
if (!order.canSubmit) continue
next[order.workOrderId] = previous[order.workOrderId] ?? order.screenshots ?? []
}
return next
})
} catch (error) {
if (cancelled) return
if (seq !== requestSeqRef.current) return
setInfo(null)
setLoadError({
code: resolveErrorCode(error),
message: resolveErrorMessage(error),
message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE,
})
} finally {
if (!cancelled) setLoading(false)
if (seq === requestSeqRef.current && !options.silent) setLoading(false)
}
}
},
[code],
)
void load()
return () => {
cancelled = true
}
}, [form, token])
useEffect(() => {
if (loadedCodeRef.current === code) return
loadedCodeRef.current = code
void loadInfo()
}, [code, loadInfo])
async function handleSubmit(values: Record<string, string>) {
if (!token) return
setSubmitting(true)
async function handleSubmit(order: CollectMaterialLinkOrder, values: Record<string, string>) {
if (!code) return
// 多工单时用户可能连点不同卡片;前端只允许一个提交在途,避免列表状态互相覆盖。
if (submittingRef.current) return
submittingRef.current = true
setSubmittingWorkOrderId(order.workOrderId)
try {
await submitCollectMaterialLink(token, { fields: values, screenshots })
setSubmitted(true)
} catch (error) {
const code = resolveErrorCode(error)
if (
code === 'material_link_used' ||
code === 'material_link_expired' ||
code === 'material_link_revoked' ||
code === 'material_link_not_found' ||
code === 'material_link_order_status_invalid'
) {
setInfo(null)
setLoadError({ code, message: resolveErrorMessage(error) })
return
await submitCollectMaterialLink(code, {
workOrderId: order.workOrderId,
fields: values,
screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [],
})
message.success('提交成功')
const submittedName = String(order.productName || '').trim() || '订单商品'
setSubmittedNames((previous) => [...previous, submittedName])
const remainingOrders = (info?.orders || []).filter(
(item) => item.workOrderId !== order.workOrderId,
)
if (remainingOrders.length > 0) {
await loadInfo({ silent: true })
} else {
// 严格模式只查询待完善订单:最后一个商品提交后不再回查,
// 直接本地展示成功页,避免刷新成「无需补充资料」。
setSubmittedSuccess(true)
}
} catch (error) {
const errorCode = resolveErrorCode(error)
message.error(resolveErrorMessage(error))
// 状态类错误说明工单已被并发提交、被客服重置或流转到其他状态:
// 以服务端状态为准静默刷新,页面切换成只读卡片而不是整页报错。
if (
errorCode === 'material_link_already_submitted' ||
errorCode === 'material_link_order_status_invalid' ||
errorCode === 'material_link_work_order_invalid' ||
errorCode === 'material_link_not_found'
) {
await loadInfo({ silent: true })
}
} finally {
setSubmitting(false)
submittingRef.current = false
setSubmittingWorkOrderId(0)
}
}
const emptyStateMessage = '该订单当前不可修改资料,如需修改请联系客服。'
const submittedProductNames = submittedNames.join('、')
return (
<main className="collect-link-page">
<Card className="collect-link-card" bordered={false}>
{loading ? (
<div className="collect-link-state">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<Text type="secondary">正在读取补资料链接...</Text>
<Text type="secondary">正在读取订单资料...</Text>
</div>
) : submitted ? (
<Result
status="success"
title="资料已提交"
subTitle="链接已失效,如需修改资料,请联系客服重新获取。"
/>
) : loadError ? (
loadError.code === 'material_link_used' ? (
<Result
icon={<CheckCircleOutlined style={{ color: '#16a34a' }} />}
title="资料已提交"
subTitle="该链接已经使用过;如需再次修改资料,请联系客服重新获取链接。"
/>
) : (
<Result
status="warning"
title={resolveErrorTitle(loadError.code)}
subTitle={loadError.message || '请联系客服重新获取链接'}
/>
)
<Result
status={loadError.code === 'material_link_not_pending' ? 'info' : 'warning'}
title={resolveErrorTitle(loadError.code)}
subTitle={
loadError.code === 'material_link_not_pending'
? undefined
: loadError.code === 'rate_limited'
? '请稍后再试'
: loadError.message || NOT_FOUND_MESSAGE
}
extra={
<Link to="/supplement">
<Button>重新输入订单号</Button>
</Link>
}
/>
) : submittedSuccess ? (
<div className="collect-link-success">
<CheckCircleFilled className="collect-link-success-icon" />
<Title level={4} className="collect-link-success-title">
提交成功
</Title>
<Text type="secondary">资料已提交;如需修改请联系客服。</Text>
<div className="collect-link-success-products">
<span>本次提交</span>
<strong>{submittedProductNames || '订单资料'}</strong>
</div>
<Text type="secondary" className="collect-link-footnote">
本页面仅用于补充订单资料,请妥善保管订单号。
</Text>
</div>
) : info ? (
<Space direction="vertical" size={16} className="full-width">
<div>
<Title level={3} className="collect-link-title">
订单资料补充
</Title>
{info.pendingCount > 0 ? (
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
</div>
) : null}
<div className="collect-link-meta">
<div>
<span>商品</span>
<strong>{info.order.productName || '-'}</strong>
</div>
<div>
<span>工单号</span>
<strong>{info.order.workOrderNo || '-'}</strong>
</div>
<div>
<span>订单号</span>
<strong>{info.order.platformOrderId || '-'}</strong>
</div>
<div>
<span>有效期至</span>
<strong>{formatDateTime(info.expiresAt)}</strong>
</div>
</div>
{info.pendingCount > 1 ? (
<Alert
type="info"
showIcon
message={`当前订单有 ${info.pendingCount} 个商品待补充资料,请逐个提交`}
description="每个商品的资料仅可提交一次,提交成功后不能再修改。"
/>
) : null}
<Alert
type="warning"
showIcon
message="链接仅可提交一次"
description="提交成功后链接立即失效;如需再次修改资料,请联系客服重新获取链接。"
/>
{info.pendingCount === 0 ? (
<Alert type="warning" showIcon message={emptyStateMessage} />
) : null}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{info.fields.map((field) => (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={6}>
<span>{field.label}</span>
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
</Space>
}
rules={
field.required
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
: undefined
}
>
{field.type === 'select' && (field.options || []).length > 0 ? (
<Radio.Group
options={(field.options || []).map((option) => ({
label: option,
value: option,
}))}
{info.orders.map((order) => (
<Card key={order.workOrderId} className="collect-link-order-card" size="small">
<Space direction="vertical" size={12} className="full-width">
<div className="collect-link-order-head">
<div className="collect-link-order-name">
<span>{order.productName || '订单商品'}</span>
</div>
<div className="collect-link-order-no">
<span>订单号</span>
<strong>{order.platformOrderId || '-'}</strong>
</div>
</div>
{order.canSubmit ? (
<>
{info.pendingCount === 1 ? (
<Alert
type="warning"
showIcon
message="该商品资料仅可提交一次"
description="提交成功后不能再修改;如需修改请联系客服。"
/>
) : null}
<MaterialLinkOrderForm
order={order}
code={code}
screenshots={
screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? []
}
submitting={submittingWorkOrderId === order.workOrderId}
disabled={submittingWorkOrderId !== 0}
onScreenshotsChange={(files) =>
setScreenshotsByOrder((previous) => ({
...previous,
[order.workOrderId]: files,
}))
}
onSubmit={(values) => handleSubmit(order, values)}
/>
</>
) : info.pendingCount > 0 ? (
<Alert
type={order.blockedReason === 'already_submitted' ? 'info' : 'warning'}
showIcon
message={resolveMaterialOrderMessage(order)}
/>
) : (
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
)}
</Form.Item>
))}
<Form.Item label="买家主页截图">
<ImageUpload
scene="collect-material"
scope="collect"
maxCount={5}
showPasteHint
captureGlobalPaste
value={screenshots}
onChange={setScreenshots}
uploader={(file, scene) => uploadCollectMaterialLinkFile(file, scene, token)}
/>
</Form.Item>
<div className="collect-link-actions">
<Button type="primary" size="large" htmlType="submit" loading={submitting}>
提交资料
</Button>
</div>
</Form>
) : null}
</Space>
</Card>
))}
<Text type="secondary" className="collect-link-footnote">
本页面仅用于补充订单资料,请勿将链接转发给他人。
本页面仅用于补充订单资料,请妥善保管订单号。
</Text>
</Space>
) : null}
@@ -292,3 +323,83 @@ export default function CollectMaterialLinkPage() {
</main>
)
}
type MaterialLinkOrderFormProps = {
order: CollectMaterialLinkOrder
code: string
screenshots: UploadedFile[]
submitting: boolean
disabled: boolean
onScreenshotsChange: (files: UploadedFile[]) => void
onSubmit: (values: Record<string, string>) => void
}
function MaterialLinkOrderForm({
order,
code,
screenshots,
submitting,
disabled,
onScreenshotsChange,
onSubmit,
}: MaterialLinkOrderFormProps) {
return (
<Form layout="vertical" initialValues={order.values || {}} onFinish={onSubmit}>
{(order.fields ?? []).map((field) => (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={6}>
<span>{field.label}</span>
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
</Space>
}
rules={
field.required
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
: undefined
}
>
{field.type === 'select' && (field.options || []).length > 0 ? (
<Radio.Group
options={(field.options || []).map((option) => ({
label: option,
value: option,
}))}
/>
) : (
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
)}
</Form.Item>
))}
<Form.Item label="买家主页截图">
<ImageUpload
scene="collect-material"
scope="collect"
maxCount={5}
showPasteHint
captureGlobalPaste
value={screenshots}
onChange={onScreenshotsChange}
uploader={(file, scene) =>
uploadCollectMaterialLinkFile(file, scene, code, order.workOrderId)
}
/>
</Form.Item>
<div className="collect-link-actions">
<Button
type="primary"
size="large"
htmlType="submit"
loading={submitting}
disabled={disabled}
>
提交资料
</Button>
</div>
</Form>
)
}
@@ -0,0 +1,49 @@
import { useEffect } from 'react'
/**
* 公开补资料页共用的 document.title / robots / referrer 设置。
*
* 入口页和补资料页都是可被直接访问的公开路径,必须禁止搜索引擎收录,
* 并避免平台订单号通过 referrer 泄漏给第三方。
*/
export function useCollectLinkPageMeta(title: string) {
useEffect(() => {
const previousTitle = document.title
document.title = title
function applyMeta(name: string, content: string) {
const existing = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
const previous = existing?.getAttribute('content') ?? null
let created: HTMLMetaElement | null = null
if (existing) {
existing.setAttribute('content', content)
} else {
created = document.createElement('meta')
created.setAttribute('name', name)
created.setAttribute('content', content)
document.head.appendChild(created)
}
return () => {
if (created) {
created.remove()
} else if (existing) {
if (previous === null) {
existing.removeAttribute('content')
} else {
existing.setAttribute('content', previous)
}
}
}
}
const restoreRobots = applyMeta('robots', 'noindex,nofollow')
const restoreReferrer = applyMeta('referrer', 'no-referrer')
return () => {
document.title = previousTitle
restoreRobots()
restoreReferrer()
}
}, [title])
}
@@ -24,7 +24,9 @@ import type { UploadedFile, WorkOrder, WorkerAcceptanceVideo } from '@/types/wor
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
import { formatDateTime } from '@/utils/date-time'
import { useIsMobile } from '@/utils/use-is-mobile'
import { shouldBlockAcceptanceForMissingShares } from '@/utils/worker-order-acceptance'
import { buildWorkerOrderQuery } from '@/utils/worker-order-query'
import { getWorkerId } from '@/utils/worker-auth'
import { WorkerOrderDesktopList } from './WorkerOrderDesktopList'
import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer'
import { WorkerOrderMobileList } from './WorkerOrderMobileList'
@@ -324,8 +326,16 @@ export default function WorkerOrdersPage() {
function openAcceptanceModal(order: WorkOrder) {
const shares = getOrderShares(order)
// 整单(非拼单)本来就没有份额,不能拦;只有拼单订单缺份额时才提示刷新。
if (shares.length === 0 && order.sharing?.enabled === true) {
// 整单(非拼单)本来就没有份额,不能拦;拼单订单被管理端整单指派给本人时,
// 后端也会按整单验收,同样不能拦。只有“拼单缺份额且不是指派给本人”才提示刷新。
if (
shouldBlockAcceptanceForMissingShares({
shareCount: shares.length,
sharingEnabled: order.sharing?.enabled === true,
assignedWorkerId: order.worker?.workerId,
currentWorkerId: getWorkerId(),
})
) {
message.warning('未找到可验收的拼单份额,请刷新后重试')
return
}