feat(work-order): 新增一次性补资料链接
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
|
||||
import { isCollectMaterialLinkPath } from '@/utils/collect-link'
|
||||
import { isRedeemPath } from '@/utils/redeem'
|
||||
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
|
||||
import { hasWorkerSession } from '@/utils/worker-auth'
|
||||
@@ -45,6 +46,7 @@ const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlat
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
|
||||
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
|
||||
const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
|
||||
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
|
||||
@@ -122,6 +124,12 @@ export default function App() {
|
||||
return <RedeemApplication />
|
||||
}
|
||||
|
||||
// 一次性补资料链接(`/supplement/<token>`):路径式公开页,先于站点判断,
|
||||
// 保证在后台域名和打手端域名下都能打开。
|
||||
if (isCollectMaterialLinkPath(window.location.pathname)) {
|
||||
return <CollectMaterialLinkApplication />
|
||||
}
|
||||
|
||||
if (isWorkerSite()) {
|
||||
return <WorkerApplication />
|
||||
}
|
||||
@@ -226,6 +234,26 @@ function ClaimApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性补资料链接(`/supplement/<token>`)的独立路由树。
|
||||
*
|
||||
* 路径式公开页,不带 hash;页面只读取 token,不依赖后台会话。
|
||||
*/
|
||||
function CollectMaterialLinkApplication() {
|
||||
useClearStrayHash()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/supplement/:token" element={<CollectMaterialLinkPage />} />
|
||||
<Route path="*" element={<CollectMaterialLinkPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 领取入口(`/h5` 命名空间)的独立路由树。
|
||||
*
|
||||
|
||||
@@ -9,17 +9,27 @@ import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
|
||||
type ImageUploadScope = 'admin' | 'worker' | 'collect'
|
||||
|
||||
type ImageUploadUploader = (
|
||||
file: File,
|
||||
scene: string,
|
||||
scope: ImageUploadScope,
|
||||
) => Promise<UploadedFile>
|
||||
|
||||
type ImageUploadProps = {
|
||||
value?: UploadedFile[]
|
||||
onChange?: (files: UploadedFile[]) => void
|
||||
scene: string
|
||||
scope: 'admin' | 'worker' | 'collect'
|
||||
scope: ImageUploadScope
|
||||
maxCount?: number
|
||||
showPasteHint?: boolean
|
||||
/** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */
|
||||
captureGlobalPaste?: boolean
|
||||
/** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */
|
||||
allowCamera?: boolean
|
||||
/** 自定义上传函数;公开一次性链接用它把文件传到 token 校验过的上传接口。 */
|
||||
uploader?: ImageUploadUploader
|
||||
}
|
||||
|
||||
type GlobalPasteTarget = {
|
||||
@@ -67,6 +77,7 @@ export default function ImageUpload({
|
||||
showPasteHint = false,
|
||||
captureGlobalPaste = false,
|
||||
allowCamera,
|
||||
uploader,
|
||||
}: ImageUploadProps) {
|
||||
const { message } = App.useApp()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -75,6 +86,7 @@ export default function ImageUpload({
|
||||
const targetIdRef = useRef(0)
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
const uploadFileFn = uploader || uploadFile
|
||||
|
||||
const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera })
|
||||
const cameraInputProps = cameraCaptureInputProps('image')
|
||||
@@ -96,7 +108,7 @@ export default function ImageUpload({
|
||||
const next = [...valueRef.current]
|
||||
for (const file of images.slice(0, remaining)) {
|
||||
try {
|
||||
const uploaded = await uploadFile(file, scene, scope)
|
||||
const uploaded = await uploadFileFn(file, scene, scope)
|
||||
next.push(uploaded)
|
||||
} catch (error) {
|
||||
message.error(resolveUploadErrorMessage(error, source))
|
||||
@@ -199,7 +211,7 @@ export default function ImageUpload({
|
||||
options: Parameters<NonNullable<UploadProps['customRequest']>>[0],
|
||||
) {
|
||||
try {
|
||||
const uploaded = await uploadFile(options.file as File, scene, scope)
|
||||
const uploaded = await uploadFileFn(options.file as File, scene, scope)
|
||||
// 多文件选择会并发执行 customRequest,必须以 ref 中的最新列表累加,避免后完成的上传覆盖前面的图片。
|
||||
const next = [...valueRef.current, uploaded].slice(0, maxCount)
|
||||
valueRef.current = next
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Result,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { 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'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
type LinkLoadError = {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function resolveErrorCode(error: unknown): string {
|
||||
return String((error as { errorCode?: string })?.errorCode || '').trim()
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
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_order_status_invalid') return '订单状态已变化'
|
||||
return '无法打开链接'
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户侧一次性补资料页。
|
||||
*
|
||||
* 只渲染 requirement 里的动态字段与买家主页截图,不展示发单价格、押金、
|
||||
* 打手、后台备注等任何后台字段;提交成功后链接立即失效。
|
||||
*/
|
||||
export default function CollectMaterialLinkPage() {
|
||||
const { message } = App.useApp()
|
||||
const { token: routeToken } = useParams<{ token?: string }>()
|
||||
const token = String(routeToken || '').trim()
|
||||
|
||||
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[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title
|
||||
document.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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
if (!token) {
|
||||
setLoading(false)
|
||||
setLoadError({
|
||||
code: 'material_link_not_found',
|
||||
message: '链接无效,请确认链接是否完整',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setSubmitted(false)
|
||||
try {
|
||||
const response = await fetchCollectMaterialLink(token)
|
||||
if (cancelled) return
|
||||
setInfo(response.data)
|
||||
setLoadError(null)
|
||||
setScreenshots(response.data.screenshots || [])
|
||||
form.resetFields()
|
||||
form.setFieldsValue(response.data.values || {})
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
setInfo(null)
|
||||
setLoadError({
|
||||
code: resolveErrorCode(error),
|
||||
message: resolveErrorMessage(error),
|
||||
})
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [form, token])
|
||||
|
||||
async function handleSubmit(values: Record<string, string>) {
|
||||
if (!token) return
|
||||
setSubmitting(true)
|
||||
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
|
||||
}
|
||||
message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</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 || '请联系客服重新获取链接'}
|
||||
/>
|
||||
)
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3} className="collect-link-title">
|
||||
订单资料补充
|
||||
</Title>
|
||||
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="链接仅可提交一次"
|
||||
description="提交成功后链接立即失效,订单会转为未分配;如需再次修改资料,请联系客服重新获取链接。"
|
||||
/>
|
||||
|
||||
<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,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请勿将链接转发给他人。
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
WorkerDepositUnfreeze,
|
||||
WorkerFinanceConfig,
|
||||
WorkerHallConfig,
|
||||
WorkerMaterialLinkConfig,
|
||||
WorkerRiskConfig,
|
||||
WorkerRiskOverview,
|
||||
WorkerRiskEventList,
|
||||
@@ -98,6 +99,17 @@ export function saveAdminWorkerHallConfig(
|
||||
return apiPost<WorkerHallConfig>('/api/v1/admin/worker-platform/hall-config', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerMaterialLinkConfig() {
|
||||
return apiGet<WorkerMaterialLinkConfig>('/api/v1/admin/worker-platform/material-link-config')
|
||||
}
|
||||
|
||||
export function saveAdminWorkerMaterialLinkConfig(payload: WorkerMaterialLinkConfig) {
|
||||
return apiPost<WorkerMaterialLinkConfig>(
|
||||
'/api/v1/admin/worker-platform/material-link-config',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerAnnouncement() {
|
||||
return apiGet<WorkerAnnouncementConfig>('/api/v1/admin/worker-platform/announcement')
|
||||
}
|
||||
@@ -754,6 +766,12 @@ export function submitAdminWorkOrderMaterial(
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminWorkOrderMaterialLink(workOrderId: number) {
|
||||
return apiPost<{ token: string; expiresAt: string; reused: boolean }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`,
|
||||
)
|
||||
}
|
||||
|
||||
export function publishAdminWorkOrder(workOrderId: number) {
|
||||
return apiPost<{
|
||||
order: WorkOrder
|
||||
|
||||
@@ -19,6 +19,24 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope)
|
||||
return response.data.file
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性补资料链接的截图上传。
|
||||
*
|
||||
* 与公开 collect/upload 的区别:先把 token 放在路径里,后端会校验链接
|
||||
* 是否仍可用;链接已使用/过期时不会落文件。
|
||||
*/
|
||||
export async function uploadCollectMaterialLinkFile(file: File, scene: string, token: string) {
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(
|
||||
`/api/v1/collect/link/${encodeURIComponent(token)}/upload`,
|
||||
form,
|
||||
)
|
||||
return response.data.file
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。
|
||||
* 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/http'
|
||||
import type {
|
||||
CollectLookupResponse,
|
||||
CollectMaterialLinkInfo,
|
||||
CollectMaterialLinkSubmitResult,
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkOrderEvent,
|
||||
@@ -379,6 +381,23 @@ export function submitCollectOrder(payload: {
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCollectMaterialLink(token: string) {
|
||||
return apiGet<CollectMaterialLinkInfo>(`/api/v1/collect/link/${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
export function submitCollectMaterialLink(
|
||||
token: string,
|
||||
payload: {
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<CollectMaterialLinkSubmitResult>(
|
||||
`/api/v1/collect/link/${encodeURIComponent(token)}/submit`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------- 打手端:我要寄卖与手枪库存 ----------------
|
||||
|
||||
export type WorkerConsignmentPublishPayload = {
|
||||
|
||||
@@ -480,6 +480,114 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== 一次性补资料链接页 (Collect Material Link) ===== */
|
||||
|
||||
.collect-link-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 16px;
|
||||
background: #f5f7fb;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.collect-link-card {
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
|
||||
.collect-link-card .ant-card-body {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.collect-link-title {
|
||||
margin: 0 0 4px !important;
|
||||
}
|
||||
|
||||
.collect-link-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 20px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.collect-link-meta > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.collect-link-meta span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collect-link-meta strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.collect-link-state {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collect-link-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.collect-link-actions .ant-btn {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.collect-link-footnote {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.collect-link-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.collect-link-card .ant-card-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.collect-link-meta {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collect-link-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.collect-link-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.collect-link-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.collect-link-card .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 兑换中心 (Redeem Page) ===== */
|
||||
|
||||
.redeem-page {
|
||||
|
||||
@@ -333,6 +333,11 @@ export type WorkerHallConfig = {
|
||||
queuedOrderCount: number
|
||||
}
|
||||
|
||||
/** 一次性补资料链接配置:有效期只影响新生成的链接。 */
|
||||
export type WorkerMaterialLinkConfig = {
|
||||
ttlMinutes: number
|
||||
}
|
||||
|
||||
export type WorkerWithdrawalAccount = {
|
||||
accountChannel: string
|
||||
accountName: string
|
||||
@@ -861,6 +866,30 @@ export type CollectLookupResponse = {
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
/** 一次性补资料链接的公开只读信息(不含价格/押金/打手等后台字段)。 */
|
||||
export type CollectMaterialLinkInfo = {
|
||||
order: {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
}
|
||||
fields: CollectField[]
|
||||
values?: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export type CollectMaterialLinkSubmitResult = {
|
||||
complete: boolean
|
||||
order: {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 大厅风控阈值(后台可调,保存即热生效) */
|
||||
export type WorkerRiskConfig = {
|
||||
enabled: boolean
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 一次性补资料链接的路径解析。
|
||||
*
|
||||
* 公开链接形如:`https://<域名>/supplement/<token>`,与后台 HashRouter 完全隔离。
|
||||
* 命名空间判定用于路由分发:`/supplement` 及子路径整段交给公开页,
|
||||
* 避免落到后台路由或打手端 SPA。
|
||||
*/
|
||||
|
||||
export function isCollectMaterialLinkPath(pathname: unknown): boolean {
|
||||
return splitPathSegments(pathname)[0] === 'supplement'
|
||||
}
|
||||
|
||||
/**
|
||||
* 只认 `/supplement/<一段 token>`:
|
||||
* - `/supplement/abc` 命中,结尾多余斜杠容忍;
|
||||
* - `/supplement`、`/supplement/a/b` 不命中(页面展示链接无效)。
|
||||
*/
|
||||
export function resolveCollectMaterialLinkToken(pathname: unknown): string {
|
||||
const segments = splitPathSegments(pathname)
|
||||
if (segments.length !== 2 || segments[0] !== 'supplement') return ''
|
||||
return decodeTokenSegment(segments[1])
|
||||
}
|
||||
|
||||
function splitPathSegments(pathname: unknown): string[] {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized.startsWith('/')) return []
|
||||
|
||||
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function decodeTokenSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user