From a2cf48f5fc6263ea979f6f283a054d2e7177d0fd Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 18:12:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(collect):=20=E8=A1=A5=E8=B5=84=E6=96=99?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=AE=A2=E5=8D=95=E5=8F=B7=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E7=8A=B6=E6=80=81=E4=B8=80=E6=AC=A1=E6=80=A7?= =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公开补资料页 /supplement/:订单号 直接按平台订单号查询,不再依赖链接记录;旧 token 链接一律 404 - 仅「待完善 + 未指派 + 无拼单份额」可提交,提交后状态变为待分配,重复提交返回 409 - 非可编辑工单只返回商品、工单号、状态与客服提示,不返回已填资料和截图 - 管理端新增「重置补资料」:待分配且未指派、无份额、未发布时一键重置回待完善 - 新增 canResetMaterial 能力与 material_reset 流转事件,工单状态文案统一为「待分配」 --- .../src/repositories/worker-platform/types.ts | 2 + .../work-order-material-link-repo.ts | 108 ++-- .../routes/admin/worker-platform/orders.ts | 24 + apps/backend/src/routes/collect.ts | 47 +- ...admin-work-order-material-reset-service.ts | 74 +++ .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 6 + .../work-order-material-link-service.test.ts | 188 +++++-- .../work-order-material-link-service.ts | 399 ++++++++++----- apps/frontend/src/App.tsx | 18 +- .../src/components/WorkOrderEventTimeline.tsx | 10 +- .../src/components/files/ImageUpload.tsx | 2 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 63 ++- .../src/pages/admin/panels/shared.tsx | 2 +- .../collect/CollectMaterialLinkEntryPage.tsx | 67 +++ .../pages/collect/CollectMaterialLinkPage.tsx | 468 ++++++++++-------- .../collect/use-collect-link-page-meta.ts | 49 ++ .../src/services/admin/worker-platform.ts | 15 +- apps/frontend/src/services/files.ts | 14 +- apps/frontend/src/services/worker.ts | 9 +- apps/frontend/src/styles/client.css | 5 + apps/frontend/src/types/worker-platform.ts | 39 +- apps/frontend/src/utils/collect-link.ts | 47 +- apps/frontend/tests/collect-link.test.ts | 60 ++- 24 files changed, 1223 insertions(+), 494 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts create mode 100644 apps/frontend/src/pages/collect/CollectMaterialLinkEntryPage.tsx create mode 100644 apps/frontend/src/pages/collect/use-collect-link-page-meta.ts diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 82d44b51..9511a1b0 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -537,6 +537,8 @@ export type WorkOrderCapabilities = { canPin: boolean canUnpublish: boolean canUpdateMaterial: boolean + /** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */ + canResetMaterial: boolean canAccept: boolean canMarkProblem: boolean canDelete: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts index 79ef6438..0fdcc06b 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-material-link-repo.ts @@ -1,6 +1,5 @@ import type { PoolClient } from 'pg' -import { query } from '../../db/client.js' import type { WorkOrderRow } from './types.js' export type WorkOrderMaterialLinkStatus = 'active' | 'used' | 'expired' | 'revoked' @@ -17,52 +16,31 @@ export type WorkOrderMaterialLinkRow = { updated_at: string } -export async function findWorkOrderMaterialLinkByToken( - token: string, -): Promise { - const result = await query( - 'SELECT * FROM work_order_material_links WHERE token = $1 LIMIT 1', - [String(token || '').trim()], - ) - return result.rows[0] || null -} - -export async function findActiveWorkOrderMaterialLink( - workOrderId: number | string, - now: string, -): Promise { - const result = await query( - `SELECT * FROM work_order_material_links - WHERE work_order_id = $1 AND status = 'active' AND expires_at > $2 - ORDER BY id DESC - LIMIT 1`, - [Number(workOrderId), now], - ) - return result.rows[0] || null -} - +/** + * 锁工单并回读「有效拼单份额」。 + * + * `SELECT * FROM work_orders` 本身没有聚合字段,直接看 `active_share_quantity` + * 会永远是 0,导致补资料绕过「已有拼单份额不允许改资料」的守卫。 + */ export async function getWorkOrderForUpdateWithClient( client: PoolClient, workOrderId: number | string, ): Promise { const result = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + `SELECT wo.*, + COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id AND wos.status != 'cancelled' + ), 0)::int AS active_share_quantity + FROM work_orders wo + WHERE wo.id = $1 + FOR UPDATE`, [Number(workOrderId)], ) return result.rows[0] || null } -export async function getWorkOrderMaterialLinkForUpdateWithClient( - client: PoolClient, - linkId: number | string, -): Promise { - const result = await client.query( - 'SELECT * FROM work_order_material_links WHERE id = $1 FOR UPDATE', - [Number(linkId)], - ) - return result.rows[0] || null -} - export async function findActiveWorkOrderMaterialLinkWithClient( client: PoolClient, workOrderId: number | string, @@ -120,33 +98,6 @@ export async function createWorkOrderMaterialLinkWithClient( return result.rows[0]! } -export async function markWorkOrderMaterialLinkUsedWithClient( - client: PoolClient, - linkId: number | string, - now: string, -): Promise { - const result = await client.query( - `UPDATE work_order_material_links - SET status = 'used', used_at = $2, updated_at = $2 - WHERE id = $1 AND status = 'active' AND expires_at > $2 - RETURNING *`, - [Number(linkId), now], - ) - return result.rows[0] || null -} - -export async function markWorkOrderMaterialLinkExpired( - linkId: number | string, - now: string, -): Promise { - await query( - `UPDATE work_order_material_links - SET status = 'expired', updated_at = $2 - WHERE id = $1 AND status = 'active'`, - [Number(linkId), now], - ) -} - export async function updateWorkOrderMaterialWithClient( client: PoolClient, input: { @@ -165,3 +116,32 @@ export async function updateWorkOrderMaterialWithClient( ) return result.rows[0] || null } + +/** + * 客服重置补资料状态:只允许「待分配、未指派、未发布」的工单回到待完善。 + * + * 服务层已经在事务里锁工单并校验过拼单份额;这里的 WHERE 条件再兜一层, + * 防止并发或调用方漏校验。 + */ +export async function resetWorkOrderMaterialStatusWithClient( + client: PoolClient, + input: { + workOrderId: number | string + fromStatus: string + toStatus: string + now: string + }, +): Promise { + const result = await client.query( + `UPDATE work_orders + SET status = $2, updated_at = $3 + WHERE id = $1 + AND status = $4 + AND assigned_worker_id IS NULL + AND published_at IS NULL + AND hall_queued_at IS NULL + RETURNING *`, + [Number(input.workOrderId), input.toStatus, input.now, input.fromStatus], + ) + return result.rows[0] || null +} diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 81da73c4..3e08cbf2 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -23,6 +23,7 @@ import { reopenAdminCancelledWorkOrder, reprocessAdminKuaishouSendCodeWorkOrders, resetAdminWorkOrderGiftMaterial, + resetAdminWorkOrderMaterialStatus, submitAdminWorkOrderMaterial, unassignAdminWorkOrder, unpublishAdminWorkOrder, @@ -216,6 +217,29 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/reset-material', + requireAdminPermission('worker_order.update_material'), + createJsonHandler( + (req) => + resetAdminWorkOrderMaterialStatus( + String(req.params.workOrderId || ''), + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '补资料状态已重置为待完善', + errorMessage: '重置补资料状态失败', + scope: '[admin/worker-platform/orders/:workOrderId/reset-material]', + audit: (req) => ({ + action: 'work_order_material_reset', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: {}, + }), + }, + ), +) + router.put( '/worker-platform/orders/:workOrderId', requireAdminPermission('worker_order.edit'), diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts index b13354b9..21ed5db7 100644 --- a/apps/backend/src/routes/collect.ts +++ b/apps/backend/src/routes/collect.ts @@ -29,21 +29,40 @@ const materialLinkReadRateLimit = createRateLimitMiddleware({ scope: 'collect:link:read', windowMs: 60_000, max: 120, - key: getParamRateLimitKey('token'), + key: getParamRateLimitKey('code'), +}) + +/** 平台订单号可被枚举,额外加一层只按 IP 的总限流,避免换 code 绕过单链接限流。 */ +const materialLinkReadIpRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:read:ip', + windowMs: 60_000, + max: 300, }) const materialLinkWriteRateLimit = createRateLimitMiddleware({ scope: 'collect:link:write', windowMs: 60_000, max: 20, - key: getParamRateLimitKey('token'), + key: getParamRateLimitKey('code'), +}) + +const materialLinkWriteIpRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:write:ip', + windowMs: 60_000, + max: 40, }) const materialLinkUploadRateLimit = createRateLimitMiddleware({ scope: 'collect:link:upload', windowMs: 60_000, max: 30, - key: getParamRateLimitKey('token'), + key: getParamRateLimitKey('code'), +}) + +const materialLinkUploadIpRateLimit = createRateLimitMiddleware({ + scope: 'collect:link:upload:ip', + windowMs: 60_000, + max: 60, }) router.post( @@ -88,35 +107,39 @@ router.post( ) router.get( - '/link/:token', + '/link/:code', + materialLinkReadIpRateLimit, materialLinkReadRateLimit, - createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.token || '')), { + createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), { errorMessage: '读取补资料链接失败', - scope: '[collect/link/:token]', + scope: '[collect/link/:code]', }), ) router.post( - '/link/:token/submit', + '/link/:code/submit', + materialLinkWriteIpRateLimit, materialLinkWriteRateLimit, createRouteHandler( - (req) => submitPublicWorkOrderMaterialLink(String(req.params.token || ''), req.body || {}), + (req) => submitPublicWorkOrderMaterialLink(String(req.params.code || ''), req.body || {}), { successMessage: '资料已提交', errorMessage: '提交补资料失败', - scope: '[collect/link/:token/submit]', + scope: '[collect/link/:code/submit]', }, ), ) router.post( - '/link/:token/upload', + '/link/:code/upload', + materialLinkUploadIpRateLimit, materialLinkUploadRateLimit, uploadSingleFile, createRouteHandler( async (req) => { const { order } = await getPublicWorkOrderMaterialLinkUploadContext( - String(req.params.token || ''), + String(req.params.code || ''), + req.query.workOrderId, ) return uploadFileAsset({ file: req.file, @@ -128,7 +151,7 @@ router.post( { successMessage: '文件已上传', errorMessage: '文件上传失败', - scope: '[collect/link/:token/upload]', + scope: '[collect/link/:code/upload]', }, ), ) diff --git a/apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts new file mode 100644 index 00000000..bdc54054 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-work-order-material-reset-service.ts @@ -0,0 +1,74 @@ +import { withTransaction } from '../../db/client.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + createWorkOrderEventWithClient, + getWorkOrderForUpdateWithClient, + resetWorkOrderMaterialStatusWithClient, +} from '../../repositories/worker-platform/index.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { mapWorkOrderAdmin } from './mappers.js' +import { + resolveMaterialResetBlockMessage, + resolveMaterialResetBlockReason, +} from './work-order-material-link-service.js' + +/** + * 客服重置补资料状态:把「待分配」工单改回「待完善」,让客户可以凭订单号重新提交。 + * + * 只允许未指派、无拼单份额、未发布的工单;原资料保留,客户刷新补资料页后 + * 会看到之前填过的内容,可以在此基础上修改。 + */ +export async function resetAdminWorkOrderMaterialStatus( + workOrderId: number | string, + actorName = '', +) { + const result = await withTransaction(async (client) => { + const order = await getWorkOrderForUpdateWithClient(client, workOrderId) + if (!order) { + throw createHttpError('接单订单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + + const blockReason = resolveMaterialResetBlockReason(order) + if (blockReason) { + throw createHttpError(resolveMaterialResetBlockMessage(blockReason), { + statusCode: 409, + errorCode: 'work_order_material_reset_invalid', + }) + } + + const now = nowIso() + const updated = await resetWorkOrderMaterialStatusWithClient(client, { + workOrderId: order.id, + fromStatus: WORK_ORDER_STATUS.UNASSIGNED, + toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL, + now, + }) + if (!updated) { + throw createHttpError('重置补资料状态失败,订单可能已变化', { + statusCode: 409, + errorCode: 'work_order_material_reset_failed', + }) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: order.id, + actorType: 'admin', + actorId: String(actorName || '').trim(), + eventType: 'material_reset', + fromStatus: WORK_ORDER_STATUS.UNASSIGNED, + toStatus: WORK_ORDER_STATUS.PENDING_MATERIAL, + payloadJson: JSON.stringify({ source: 'admin_reset' }), + now, + }) + + return { order: updated } + }) + + publishWorkOrderRealtimeChange({ workOrderId: Number(result.order.id) }) + return { order: mapWorkOrderAdmin(result.order) } +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 5374b17a..9b800c83 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -37,6 +37,7 @@ export * from './admin-worker-cancel-service.js' export * from './admin-work-order-sharing-service.js' export * from './admin-work-order-realtime-service.js' export * from './admin-work-order-material-service.js' +export * from './admin-work-order-material-reset-service.js' export * from './admin-work-order-gift-service.js' export * from './admin-mock-work-order-service.js' export * from './admin-mock-custom-shop-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index c0f18d70..26e3fbec 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -290,6 +290,12 @@ export function mapWorkOrderAdmin( workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || workOrder.status === WORK_ORDER_STATUS.OPEN || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS, + canResetMaterial: + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && + !workOrder.assigned_worker_id && + !hasActiveShares && + !workOrder.published_at && + !workOrder.hall_queued_at, canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE || (isFriendGift && diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts index 2d819c1f..88ca0fad 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -2,63 +2,173 @@ import assert from 'node:assert/strict' import test from 'node:test' import { - resolveWorkOrderMaterialLinkUnavailableReason, + formatPublicMaterialStatus, pickSubmittedMaterialFields, + resolveMaterialResetBlockReason, + resolvePublicMaterialOrderState, + resolveWorkOrderMaterialLinkOrderBlockReason, } from './work-order-material-link-service.js' -const NOW = '2026-10-31T12:00:00.000Z' +test('公开补资料:只有待完善且未指派/无份额可提交', () => { + const editable = resolvePublicMaterialOrderState({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 0, + }) + assert.equal(editable.canSubmit, true) + assert.equal(editable.blockedReason, '') + assert.equal(editable.statusLabel, '待完善') -test('补资料链接:active 且未过期时可用', () => { + const alreadySubmitted = resolvePublicMaterialOrderState({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + }) + assert.equal(alreadySubmitted.canSubmit, false) + assert.equal(alreadySubmitted.blockedReason, 'already_submitted') + assert.equal(alreadySubmitted.statusLabel, '待分配') + assert.match(alreadySubmitted.message, /联系客服/) + + const assigned = resolvePublicMaterialOrderState({ + status: 'pending_material', + assigned_worker_id: 9, + active_share_quantity: 0, + }) + assert.equal(assigned.canSubmit, false) + assert.equal(assigned.blockedReason, 'assigned') + + const shares = resolvePublicMaterialOrderState({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 2, + }) + assert.equal(shares.canSubmit, false) + assert.equal(shares.blockedReason, 'shares') + + const open = resolvePublicMaterialOrderState({ + status: 'open', + assigned_worker_id: null, + active_share_quantity: 0, + }) + assert.equal(open.canSubmit, false) + assert.equal(open.blockedReason, 'status') + assert.equal(open.statusLabel, '待抢单') + assert.match(open.message, /联系客服/) +}) + +test('公开补资料:状态文案覆盖待分配与后续流程', () => { + assert.equal(formatPublicMaterialStatus('pending_material'), '待完善') + assert.equal(formatPublicMaterialStatus('unassigned'), '待分配') + assert.equal(formatPublicMaterialStatus('in_progress'), '代练中') + assert.equal(formatPublicMaterialStatus('pending_acceptance'), '待验收') + assert.equal(formatPublicMaterialStatus('accepted'), '已验收') + assert.equal(formatPublicMaterialStatus('cancelled'), '已取消') + assert.equal(formatPublicMaterialStatus('unknown_status'), 'unknown_status') +}) + +test('客服重置补资料:只允许待分配、未指派、无份额、未发布', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'active', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: null, + hall_queued_at: null, + }), '', ) -}) - -test('补资料链接:active 但已过期按过期处理', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason({ status: 'active', expires_at: NOW }, NOW), - 'expired', + resolveMaterialResetBlockReason({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: null, + hall_queued_at: null, + }), + 'status', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: 9, + active_share_quantity: 0, + published_at: null, + hall_queued_at: null, + }), + 'assigned', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 2, + published_at: null, + hall_queued_at: null, + }), + 'shares', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: '2026-11-01T00:00:00.000Z', + hall_queued_at: null, + }), + 'published', + ) + assert.equal( + resolveMaterialResetBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + published_at: null, + hall_queued_at: '2026-11-01T00:00:00.000Z', + }), + 'published', ) }) -test('补资料链接:used / expired / revoked 分别返回对应原因', () => { +test('管理端链接准入:待完善/待分配可用,已指派/有份额/其他状态拒绝', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'used', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), - 'used', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 0, + }), + '', ) assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'expired', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), - 'expired', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'unassigned', + assigned_worker_id: null, + active_share_quantity: 0, + }), + '', ) assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { status: 'revoked', expires_at: '2026-10-31T13:00:00.000Z' }, - NOW, - ), - 'revoked', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'open', + assigned_worker_id: null, + active_share_quantity: 0, + }), + 'status', ) -}) - -test('补资料链接:未知状态一律视为失效,避免误放行', () => { assert.equal( - resolveWorkOrderMaterialLinkUnavailableReason( - { - status: 'unknown' as never, - expires_at: '2026-10-31T13:00:00.000Z', - }, - NOW, - ), - 'revoked', + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'pending_material', + assigned_worker_id: 9, + active_share_quantity: 0, + }), + 'assigned', + ) + assert.equal( + resolveWorkOrderMaterialLinkOrderBlockReason({ + status: 'pending_material', + assigned_worker_id: null, + active_share_quantity: 2, + }), + 'shares', ) }) diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index 4c9decf2..c449c3a1 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -5,15 +5,11 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { createWorkOrderEventWithClient, createWorkOrderMaterialLinkWithClient, - findWorkOrderMaterialLinkByToken, findActiveWorkOrderMaterialLinkWithClient, getWorkOrderForUpdateWithClient, - getWorkOrderMaterialLinkForUpdateWithClient, - markWorkOrderMaterialLinkExpired, - markWorkOrderMaterialLinkUsedWithClient, + listWorkOrdersByPlatformOrderId, revokeActiveWorkOrderMaterialLinksWithClient, updateWorkOrderMaterialWithClient, - type WorkOrderMaterialLinkRow, } from '../../repositories/worker-platform/index.js' import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' import type { JsonObject } from '../../types/json.js' @@ -26,6 +22,7 @@ import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-servi import { mapCollectLookupOrder, mapWorkOrderAdmin, + normalizeOptionalId, normalizeSubmittedFields, normalizeUploadedFiles, resolveRequirementFields, @@ -40,29 +37,142 @@ const ALLOWED_MATERIAL_LINK_ORDER_STATUSES = new Set([ const MAX_MATERIAL_SCREENSHOTS = 5 -export type WorkOrderMaterialLinkUnavailableReason = 'used' | 'expired' | 'revoked' | '' +const PUBLIC_MATERIAL_STATUS_LABELS: Record = { + [WORK_ORDER_STATUS.PENDING_MATERIAL]: '待完善', + [WORK_ORDER_STATUS.UNASSIGNED]: '待分配', + [WORK_ORDER_STATUS.OPEN]: '待抢单', + [WORK_ORDER_STATUS.IN_PROGRESS]: '代练中', + [WORK_ORDER_STATUS.PENDING_ACCEPTANCE]: '待验收', + [WORK_ORDER_STATUS.PROBLEM]: '问题单', + [WORK_ORDER_STATUS.ACCEPTED]: '已验收', + [WORK_ORDER_STATUS.CANCELLED]: '已取消', + vip_evidence_pending: '待补图', +} + +export type WorkOrderMaterialLinkOrderBlockReason = 'status' | 'assigned' | 'shares' | '' + +export type PublicMaterialOrderBlockReason = + | 'already_submitted' + | 'status' + | 'assigned' + | 'shares' + | '' + +export type PublicMaterialOrderState = { + canSubmit: boolean + blockedReason: PublicMaterialOrderBlockReason + statusLabel: string + message: string +} + +export type MaterialResetBlockReason = 'status' | 'assigned' | 'shares' | 'published' | '' + +/** 公开页状态文案;后台状态列/时间线也统一用「待分配」。 */ +export function formatPublicMaterialStatus(status: string): string { + const normalized = String(status || '').trim() + return PUBLIC_MATERIAL_STATUS_LABELS[normalized] || normalized || '未知状态' +} /** - * 判断链接是否还能用。 + * 公开补资料页的准入判断。 * - * 只做纯函数判断,方便单测覆盖;数据库状态与过期时间都由调用方传入。 + * 只有「待完善 + 未指派 + 无拼单份额」可以提交;待分配视为已提交, + * 其他状态只展示状态与客服提示,不再展示表单。 */ -export function resolveWorkOrderMaterialLinkUnavailableReason( - link: Pick, - now: string, -): WorkOrderMaterialLinkUnavailableReason { - if (link.status === 'used') return 'used' - if (link.status === 'revoked') return 'revoked' - if (link.status === 'expired') return 'expired' - if (link.status !== 'active') return 'revoked' - if (new Date(link.expires_at).getTime() <= new Date(now).getTime()) return 'expired' +export function resolvePublicMaterialOrderState( + order: Pick, +): PublicMaterialOrderState { + const statusLabel = formatPublicMaterialStatus(order.status) + + if (order.status === WORK_ORDER_STATUS.UNASSIGNED) { + return { + canSubmit: false, + blockedReason: 'already_submitted', + statusLabel, + message: '资料已提交,订单已进入待分配;如需修改请联系客服。', + } + } + + if (order.status !== WORK_ORDER_STATUS.PENDING_MATERIAL) { + return { + canSubmit: false, + blockedReason: 'status', + statusLabel, + message: `当前状态为「${statusLabel}」,不能修改资料;如需修改请联系客服。`, + } + } + + if (order.assigned_worker_id) { + return { + canSubmit: false, + blockedReason: 'assigned', + statusLabel, + message: '订单已指派打手,不能修改资料;如需修改请联系客服。', + } + } + + if (Number(order.active_share_quantity || 0) > 0) { + return { + canSubmit: false, + blockedReason: 'shares', + statusLabel, + message: '订单已有拼单份额,不能修改资料;如需修改请联系客服。', + } + } + + return { + canSubmit: true, + blockedReason: '', + statusLabel, + message: '', + } +} + +/** + * 客服重置补资料状态的前置条件。 + * + * 只允许「待分配 + 未指派 + 无份额 + 未发布」的工单回到待完善; + * 其他状态一律拒绝,避免把已进入接单流程的工单改回可编辑。 + */ +export function resolveMaterialResetBlockReason( + order: Pick< + WorkOrderRow, + 'status' | 'assigned_worker_id' | 'active_share_quantity' | 'published_at' | 'hall_queued_at' + >, +): MaterialResetBlockReason { + if (order.status !== WORK_ORDER_STATUS.UNASSIGNED) return 'status' + if (order.assigned_worker_id) return 'assigned' + if (Number(order.active_share_quantity || 0) > 0) return 'shares' + if (order.published_at || order.hall_queued_at) return 'published' + return '' +} + +export function resolveMaterialResetBlockMessage(reason: MaterialResetBlockReason): string { + if (reason === 'assigned') return '订单已指派打手,不能重置补资料状态' + if (reason === 'shares') return '订单已有拼单份额,不能重置补资料状态' + if (reason === 'published') return '订单已发布到接单大厅,不能重置补资料状态' + return '只有待分配(未指派、无份额、未发布)的工单可以重置补资料状态' +} + +/** + * 管理端生成/复用一次性链接的准入判断。 + * + * 用户要求保留现有“复制补资料链接”按钮,所以这里仍按原口径允许 + * 待完善 / 待分配,并拒绝已指派/有份额的工单。 + */ +export function resolveWorkOrderMaterialLinkOrderBlockReason( + order: Pick, +): WorkOrderMaterialLinkOrderBlockReason { + if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) return 'status' + if (order.assigned_worker_id) return 'assigned' + if (Number(order.active_share_quantity || 0) > 0) return 'shares' return '' } /** * 只保留工单 requirement 里定义过的字段。 * - * 公开链接的提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的 + * 公开提交体是客户可构造的,不能让 `rewardAmount`、`status` 之类的 * 额外 key 混进 material.collect.fields;这里按白名单裁剪一次。 */ export function pickSubmittedMaterialFields( @@ -73,57 +183,42 @@ export function pickSubmittedMaterialFields( return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key))) } -function throwMaterialLinkUnavailable( - reason: Exclude, -): never { - if (reason === 'used') { - throw createHttpError('链接已使用,如需修改资料请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_used', +function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never { + if (state.blockedReason === 'already_submitted') { + throw createHttpError(state.message, { + statusCode: 409, + errorCode: 'material_link_already_submitted', }) } - if (reason === 'expired') { - throw createHttpError('链接已过期,请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_expired', - }) - } - throw createHttpError('链接已失效,请联系客服重新获取', { - statusCode: 410, - errorCode: 'material_link_revoked', + throw createHttpError(state.message, { + statusCode: 409, + errorCode: 'material_link_order_status_invalid', }) } -/** - * 公开链接只对「待完善 / 未分配」开放,且不能已经指派打手或有进行中的拼单份额。 - * 这三个条件同时满足,提交后才可以安全地把状态改成「未分配」。 - */ +/** 管理端生成补资料链接时仍沿用旧的准入文案。 */ function assertWorkOrderAllowedForMaterialLink(order: WorkOrderRow): void { - if (!ALLOWED_MATERIAL_LINK_ORDER_STATUSES.has(order.status)) { - throw createHttpError('订单当前状态不允许补资料,请联系客服重新获取链接', { + const reason = resolveWorkOrderMaterialLinkOrderBlockReason(order) + if (reason === 'status') { + throw createHttpError('当前状态不能生成补资料链接', { statusCode: 409, - errorCode: 'material_link_order_status_invalid', + errorCode: 'material_link_status_invalid', }) } - if (order.assigned_worker_id) { - throw createHttpError('订单已指派打手,不能通过公开链接修改资料', { + if (reason === 'assigned') { + throw createHttpError('订单已指派打手,不能生成补资料链接', { statusCode: 409, - errorCode: 'material_link_order_status_invalid', + errorCode: 'material_link_status_invalid', }) } - if (Number(order.active_share_quantity || 0) > 0) { - throw createHttpError('订单已有拼单份额,不能通过公开链接修改资料', { + if (reason === 'shares') { + throw createHttpError('订单已有拼单份额,不能生成补资料链接', { statusCode: 409, - errorCode: 'material_link_order_status_invalid', + errorCode: 'material_link_status_invalid', }) } } -function assertMaterialLinkRecordUsable(link: WorkOrderMaterialLinkRow, now: string): void { - const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now) - if (reason) throwMaterialLinkUnavailable(reason) -} - function mapPublicOrder(order: WorkOrderRow) { return { workOrderNo: order.work_order_no, @@ -139,11 +234,67 @@ function toIsoTimestamp(value: string): string { return Number.isNaN(date.getTime()) ? String(value || '') : date.toISOString() } +function mapPublicMaterialOrder(order: WorkOrderRow) { + const state = resolvePublicMaterialOrderState(order) + const base = { + workOrderId: Number(order.id), + workOrderNo: order.work_order_no, + platformOrderId: order.platform_order_id, + productName: order.product_name, + status: order.status, + statusLabel: state.statusLabel, + canSubmit: state.canSubmit, + blockedReason: state.blockedReason, + message: state.message, + } + if (!state.canSubmit) return base + + const mapped = mapCollectLookupOrder(order) + return { + ...base, + fields: mapped.fields, + values: mapped.values, + screenshots: mapped.screenshots, + } +} + +async function resolvePublicMaterialTarget( + platformOrderId: string, + workOrderIdInput: unknown, +): Promise { + const workOrderId = normalizeOptionalId(workOrderIdInput) + if (workOrderId) { + const order = await getRequiredWorkOrder(workOrderId) + if (String(order.platform_order_id || '').trim() !== platformOrderId) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'material_link_work_order_invalid', + }) + } + return order + } + + const orders = await listWorkOrdersByPlatformOrderId(platformOrderId) + if (orders.length === 0) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) + } + if (orders.length > 1) { + throw createHttpError('该订单号下有多个商品,请先选择商品', { + statusCode: 409, + errorCode: 'material_link_work_order_required', + }) + } + return orders[0]! +} + /** * 管理端生成/复用一次性链接。 * - * 幂等策略:同一工单已有未过期 active 链接时直接返回,避免客服连续点两次 - * 把刚发出去的链接顶掉;只有已使用/已过期/已撤销时才生成新 token。 + * 用户要求保留现有“复制补资料链接”按钮,因此链接记录仍会生成; + * 但公开页已经不再依赖它,复制出的 `/supplement/<平台订单号>` 始终可用。 */ export async function createAdminWorkOrderMaterialLink( workOrderId: number | string, @@ -166,6 +317,15 @@ export async function createAdminWorkOrderMaterialLink( errorCode: 'material_link_status_invalid', }) } + + const platformOrderId = String(order.platform_order_id || '').trim() + if (!platformOrderId) { + throw createHttpError('该工单没有平台订单号,不能生成补资料链接', { + statusCode: 409, + errorCode: 'material_link_platform_order_missing', + }) + } + assertWorkOrderAllowedForMaterialLink(order) // 锁到手后再取时间,避免等锁期间旧链接过期却仍按旧时间复用。 @@ -174,6 +334,7 @@ export async function createAdminWorkOrderMaterialLink( if (existing) { return { token: existing.token, + platformOrderId, expiresAt: toIsoTimestamp(existing.expires_at), reused: true, } @@ -192,6 +353,7 @@ export async function createAdminWorkOrderMaterialLink( return { token: link.token, + platformOrderId, expiresAt: toIsoTimestamp(link.expires_at), reused: false, } @@ -199,83 +361,76 @@ export async function createAdminWorkOrderMaterialLink( } /** - * 公开页读取链接内容。 + * 公开页按平台订单号读取补资料内容。 * - * 只返回客户补资料需要的最少信息:商品/单号、动态字段、已填值、截图、过期时间。 - * 不返回发单价格、押金、成本、打手、后台备注等任何后台字段。 + * 不再要求客服先生成链接;只要订单号存在,就返回该订单号下所有工单的 + * 状态与准入信息。只有可提交的待完善工单返回字段/已填值/截图,其他状态 + * 只返回商品、工单号、状态和客服提示。 */ -export async function getPublicWorkOrderMaterialLink(token: string) { - const link = await findWorkOrderMaterialLinkByToken(token) - if (!link) { - throw createHttpError('链接无效,请确认链接是否完整', { +export async function getPublicWorkOrderMaterialLink(platformOrderId: string) { + const code = String(platformOrderId || '').trim() + if (!code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, errorCode: 'material_link_not_found', }) } - const now = nowIso() - const reason = resolveWorkOrderMaterialLinkUnavailableReason(link, now) - if (reason) { - if (reason === 'expired') { - await markWorkOrderMaterialLinkExpired(link.id, now) - } - throwMaterialLinkUnavailable(reason) + const orders = await listWorkOrdersByPlatformOrderId(code) + if (orders.length === 0) { + throw createHttpError('订单号无效,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'material_link_not_found', + }) } - const order = await getRequiredWorkOrder(link.work_order_id) - assertWorkOrderAllowedForMaterialLink(order) - - const mapped = mapCollectLookupOrder(order) + const publicOrders = orders.map(mapPublicMaterialOrder) return { - order: { - workOrderNo: mapped.workOrderNo, - platformOrderId: mapped.platformOrderId, - productName: mapped.productName, - status: mapped.status, - }, - fields: mapped.fields, - values: mapped.values, - screenshots: mapped.screenshots, - expiresAt: toIsoTimestamp(link.expires_at), + platformOrderId: code, + orders: publicOrders, + pendingCount: publicOrders.filter((order) => order.canSubmit).length, } } /** * 公开页提交资料。 * - * 一次性语义:只有「完整提交」成功时才把 token 标为 used; - * 少填必填项、订单状态变化、网络失败等都会在事务里回滚,链接继续可用。 + * 一次性语义完全由工单状态保证:只有 `pending_material` 能成功提交, + * 提交后改成 `unassigned`;并发的第二个提交会锁到 `unassigned` 并返回 409。 */ -export async function submitPublicWorkOrderMaterialLink(token: string, payload: JsonObject = {}) { - const preliminary = await findWorkOrderMaterialLinkByToken(token) - if (!preliminary) { - throw createHttpError('链接无效,请确认链接是否完整', { +export async function submitPublicWorkOrderMaterialLink( + platformOrderId: string, + payload: JsonObject = {}, +) { + const code = String(platformOrderId || '').trim() + if (!code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, errorCode: 'material_link_not_found', }) } + const target = await resolvePublicMaterialTarget(code, payload.workOrderId) + const targetState = resolvePublicMaterialOrderState(target) + if (!targetState.canSubmit) throwPublicMaterialBlocked(targetState) + const result = await withTransaction(async (client) => { - // 先锁工单再锁链接,和后台生成链接保持同样的加锁顺序,避免死锁。 - const order = await getWorkOrderForUpdateWithClient(client, preliminary.work_order_id) + // 锁工单:并发提交/客服重置都串行化,状态是唯一的一次性凭据。 + const order = await getWorkOrderForUpdateWithClient(client, target.id) if (!order) { throw createHttpError('接单订单不存在', { statusCode: 404, errorCode: 'work_order_not_found', }) } - const link = await getWorkOrderMaterialLinkForUpdateWithClient(client, preliminary.id) - if (!link) { - throw createHttpError('链接无效,请确认链接是否完整', { + if (String(order.platform_order_id || '').trim() !== code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, - errorCode: 'material_link_not_found', + errorCode: 'material_link_work_order_invalid', }) } - - // 锁到手后再取时间:等锁期间链接可能已经过期,不能沿用进入事务前的时间。 - const now = nowIso() - assertMaterialLinkRecordUsable(link, now) - assertWorkOrderAllowedForMaterialLink(order) + const orderState = resolvePublicMaterialOrderState(order) + if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState) const fields = resolveRequirementFields(order) const submittedFields = pickSubmittedMaterialFields( @@ -304,6 +459,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: }) } + const now = nowIso() const material = { ...safeParseJson(order.material_json), collect: { @@ -311,7 +467,7 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: screenshots, submittedAt: now, complete: true, - source: 'customer_link', + source: 'customer_order_no', }, } @@ -328,33 +484,22 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: }) } - const used = await markWorkOrderMaterialLinkUsedWithClient(client, link.id, now) - if (!used) { - // 并发提交,或刚好在写入瞬间过期:按当前状态给出准确原因,事务整体回滚。 - const latest = await getWorkOrderMaterialLinkForUpdateWithClient(client, link.id) - if (latest) { - const reason = resolveWorkOrderMaterialLinkUnavailableReason(latest, now) - if (reason) throwMaterialLinkUnavailable(reason) - } - throwMaterialLinkUnavailable('used') - } - await createWorkOrderEventWithClient(client, { workOrderId: order.id, actorType: 'collect', - actorId: '客户补资料链接', + actorId: '客户按订单号补资料', eventType: 'material_completed', fromStatus: order.status, toStatus: WORK_ORDER_STATUS.UNASSIGNED, payloadJson: JSON.stringify({ fields: submittedFields, complete: true, - source: 'customer_link', + source: 'customer_order_no', }), now, }) - return { order: updated, link: used } + return { order: updated } }) // 事务已提交,后续通知/实时广播失败不能让客户误以为提交失败。 @@ -374,28 +519,30 @@ export async function submitPublicWorkOrderMaterialLink(token: string, payload: return { complete: true, + workOrderId: Number(result.order.id), order: mapPublicOrder(result.order), } } /** - * 上传前校验 token 与工单状态;返回工单 id 供文件归属使用。 - * 注意:这里不消耗 token,只有最终提交才消耗。 + * 上传前校验平台订单号 / workOrderId 与工单状态;返回工单供文件归属使用。 + * 注意:这里不消耗提交机会,只有最终提交才把状态改成待分配。 */ -export async function getPublicWorkOrderMaterialLinkUploadContext(token: string) { - const link = await findWorkOrderMaterialLinkByToken(token) - if (!link) { - throw createHttpError('链接无效,请确认链接是否完整', { +export async function getPublicWorkOrderMaterialLinkUploadContext( + platformOrderId: string, + workOrderId?: unknown, +) { + const code = String(platformOrderId || '').trim() + if (!code) { + throw createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, errorCode: 'material_link_not_found', }) } - const now = nowIso() - assertMaterialLinkRecordUsable(link, now) + const order = await resolvePublicMaterialTarget(code, workOrderId) + const orderState = resolvePublicMaterialOrderState(order) + if (!orderState.canSubmit) throwPublicMaterialBlocked(orderState) - const order = await getRequiredWorkOrder(link.work_order_id) - assertWorkOrderAllowedForMaterialLink(order) - - return { link, order, now } + return { order, now: nowIso() } } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index d7c4cf57..14b4f951 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -46,6 +46,9 @@ 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 CollectMaterialLinkEntryPage = lazy( + () => import('@/pages/collect/CollectMaterialLinkEntryPage'), +) const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage')) @@ -124,8 +127,8 @@ export default function App() { return } - // 一次性补资料链接(`/supplement/`):路径式公开页,先于站点判断, - // 保证在后台域名和打手端域名下都能打开。 + // 一次性补资料链接(`/supplement`):路径式公开页,先于站点判断, + // 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。 if (isCollectMaterialLinkPath(window.location.pathname)) { return } @@ -235,9 +238,13 @@ function ClaimApplication() { } /** - * 一次性补资料链接(`/supplement/`)的独立路由树。 + * 一次性补资料链接(`/supplement`)的独立路由树。 * - * 路径式公开页,不带 hash;页面只读取 token,不依赖后台会话。 + * - `/supplement`:输入平台订单号; + * - `/supplement/<订单号>`:直接打开对应订单的补资料页; + * - 其余子路径:交给补资料页展示「链接无效」。 + * + * 路径式公开页,不带 hash;不依赖后台会话。 */ function CollectMaterialLinkApplication() { useClearStrayHash() @@ -246,7 +253,8 @@ function CollectMaterialLinkApplication() { }> - } /> + } /> + } /> } /> diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index c1341ce8..aa6f3ca1 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -21,7 +21,7 @@ type EventNodeStyle = { /** 工单状态中文名 */ const STATUS_LABELS: Record = { pending_material: '待完善资料', - unassigned: '未分配', + unassigned: '待分配', open: '待抢单', in_progress: '代练中', pending_acceptance: '待验收', @@ -176,6 +176,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { return { title: '完善了订单资料', color: 'cyan' } case 'material_updated': return { title: '更新了订单资料', color: 'cyan' } + case 'material_reset': + return { + title: note ? `客服重置补资料状态:${note}` : '客服重置补资料状态', + description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel( + event.toStatus, + )}`, + color: 'orange', + } // —— 好友赠送节点 —— case 'gift_material_submitted': diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 895dfa71..7eec1328 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -28,7 +28,7 @@ type ImageUploadProps = { captureGlobalPaste?: boolean /** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */ allowCamera?: boolean - /** 自定义上传函数;公开一次性链接用它把文件传到 token 校验过的上传接口。 */ + /** 自定义上传函数;公开一次性链接用它把文件传到带订单号 / workOrderId 校验的上传接口。 */ uploader?: ImageUploadUploader } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index eae5b3b6..39d0be61 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -78,6 +78,7 @@ import { reduceAdminWorkOrderShareQuantity, reopenAdminCancelledWorkOrder, resetAdminWorkOrderGiftMaterial, + resetAdminWorkOrderMaterialStatus, resolveAdminProblemWorkOrder, submitAdminWorkOrderMaterial, unpublishAdminWorkOrder, @@ -106,6 +107,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 +205,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 兜底。 */ @@ -1491,7 +1494,7 @@ export default function WorkOrdersPanel() { modal.confirm({ title: `确认删除订单 ${getDisplayOrderNo(row)}?`, content: - '删除后工单将从列表中移除,且不可恢复。仅未分配、待完善或已取消且无拼单参与的工单可删除。', + '删除后工单将从列表中移除,且不可恢复。仅待分配、待完善或已取消且无拼单参与的工单可删除。', okText: '确认删除', okButtonProps: { danger: true }, cancelText: '取消', @@ -1577,6 +1580,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) { @@ -1638,12 +1657,24 @@ export default function WorkOrdersPanel() { setMaterialLinkLoadingId(order.workOrderId) try { const response = await createAdminWorkOrderMaterialLink(order.workOrderId) - const url = `${window.location.origin}/supplement/${encodeURIComponent(response.data.token)}` + // 新后端返回 platformOrderId;旧后端缺失时回退 token,仅用于前后端滚动发布。 + const platformOrderId = String( + response.data.platformOrderId || response.data.token || '', + ).trim() + if (!platformOrderId) { + message.error('生成补资料链接失败:缺少平台订单号') + return + } + const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId) + if (!url) { + message.error('生成补资料链接失败:链接地址为空') + return + } const copied = await copyTextToClipboard(url) const expiresText = formatAdminDateTime(response.data.expiresAt) if (copied) { message.success( - `${response.data.reused ? '已复制现有链接' : '已生成并复制新链接'},${expiresText} 前有效,仅可提交一次`, + `${response.data.reused ? '已复制现有链接' : '已生成并复制链接'}(订单号 ${platformOrderId}),${expiresText} 前有效;该订单号下已生成链接的待补商品会一起显示`, ) } else { setMaterialLinkFallback({ url, expiresAt: response.data.expiresAt }) @@ -1914,13 +1945,13 @@ export default function WorkOrdersPanel() { onClick={() => { modal.confirm({ title: '确认重新启用该订单?', - content: '订单将恢复为未分配状态。请确认资料与金额后,再发布到抢单大厅。', + content: '订单将恢复为待分配状态。请确认资料与金额后,再发布到抢单大厅。', okText: '重新启用', cancelText: '取消', onOk: () => runAction( () => reopenAdminCancelledWorkOrder(row.workOrderId), - '订单已恢复为未分配状态,可编辑后发布', + '订单已恢复为待分配状态,可编辑后发布', ), }) }} @@ -1969,11 +2000,20 @@ export default function WorkOrdersPanel() { icon={} loading={materialLinkLoadingId === row.workOrderId} onClick={() => void copyMaterialLink(row)} - title="生成一次性补资料链接(有效期内仅可提交一次)" + title="生成一次性补资料链接(按平台订单号打开;有效期内每个工单仅可提交一次)" > 复制补资料链接 ) : null} + {row.capabilities?.canResetMaterial && canUpdateMaterial ? ( + + ) : null} {row.capabilities?.canPublish && canPublishOrder ? ( + + + + 如果客服已经发给你完整链接,直接打开链接即可。 + + + + + ) +} diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx index cf89a755..b83a462d 100644 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx +++ b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx @@ -1,4 +1,4 @@ -import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons' +import { 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,135 +44,140 @@ 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_work_order_required') return '请选择要补充的商品' if (code === 'material_link_order_status_invalid') return '订单状态已变化' - return '无法打开链接' + return '暂时无法打开页面' +} + +const STATUS_TAG_COLORS: Record = { + pending_material: 'orange', + unassigned: 'blue', + open: 'cyan', + in_progress: 'processing', + pending_acceptance: 'purple', + problem: 'red', + accepted: 'green', + cancelled: 'default', +} + +function resolveStatusColor(status: string): string { + return STATUS_TAG_COLORS[status] || 'default' } /** - * 客户侧一次性补资料页。 + * 客户侧补资料页。 * - * 只渲染 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>() const [loading, setLoading] = useState(true) const [info, setInfo] = useState(null) const [loadError, setLoadError] = useState(null) - const [submitting, setSubmitting] = useState(false) - const [submitted, setSubmitted] = useState(false) - const [screenshots, setScreenshots] = useState([]) + const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0) + const submittingRef = useRef(false) + const requestSeqRef = useRef(0) + const [screenshotsByOrder, setScreenshotsByOrder] = useState>({}) - 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(`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) { + 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 = {} + 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(() => { + void loadInfo() + }, [loadInfo]) - async function handleSubmit(values: Record) { - if (!token) return - setSubmitting(true) + async function handleSubmit(order: CollectMaterialLinkOrder, values: Record) { + if (!code) return + // 多工单时用户可能连点不同卡片;前端只允许一个提交在途,避免列表状态互相覆盖。 + if (submittingRef.current) return + submittingRef.current = true + setSubmittingWorkOrderId(order.workOrderId) try { - await submitCollectMaterialLink(token, { fields: values, screenshots }) - setSubmitted(true) + await submitCollectMaterialLink(code, { + workOrderId: order.workOrderId, + fields: values, + screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [], + }) + message.success('资料已提交,订单已进入待分配') + await loadInfo({ silent: 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 - } + 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) } } @@ -176,115 +187,106 @@ export default function CollectMaterialLinkPage() { {loading ? (
} size="large" /> - 正在读取补资料链接... + 正在读取订单资料...
- ) : submitted ? ( - ) : loadError ? ( - loadError.code === 'material_link_used' ? ( - } - title="资料已提交" - subTitle="该链接已经使用过;如需再次修改资料,请联系客服重新获取链接。" - /> - ) : ( - - ) + + + + } + /> ) : info ? (
订单资料补充 - 请补充以下资料,提交后客服会尽快处理。 + + {info.pendingCount > 0 + ? '请补充以下资料,提交后客服会尽快处理。' + : '以下为该订单当前的资料状态,如需修改请联系客服。'} +
-
-
- 商品 - {info.order.productName || '-'} -
-
- 工单号 - {info.order.workOrderNo || '-'} -
-
- 订单号 - {info.order.platformOrderId || '-'} -
-
- 有效期至 - {formatDateTime(info.expiresAt)} -
-
+ {info.pendingCount > 1 ? ( + + ) : null} - + {info.pendingCount === 0 ? ( + + ) : null} -
- {info.fields.map((field) => ( - - {field.label} - {field.required ? 必填 : 选填} - - } - rules={ - field.required - ? [{ required: true, whitespace: true, message: `请填写${field.label}` }] - : undefined - } - > - {field.type === 'select' && (field.options || []).length > 0 ? ( - ({ - label: option, - value: option, - }))} - /> + {info.orders.map((order) => ( + {order.statusLabel}} + > + +
+
+ 工单号 + {order.workOrderNo || '-'} +
+
+ 订单号 + {order.platformOrderId || '-'} +
+
+ + {order.canSubmit ? ( + <> + {info.pendingCount === 1 ? ( + + ) : null} + + + setScreenshotsByOrder((previous) => ({ + ...previous, + [order.workOrderId]: files, + })) + } + onSubmit={(values) => handleSubmit(order, values)} + /> + ) : ( - + )} -
- ))} - - - uploadCollectMaterialLinkFile(file, scene, token)} - /> - - -
- -
-
+
+ + ))} - 本页面仅用于补充订单资料,请勿将链接转发给他人。 + 本页面仅用于补充订单资料,请妥善保管订单号。 ) : null} @@ -292,3 +294,83 @@ export default function CollectMaterialLinkPage() { ) } + +type MaterialLinkOrderFormProps = { + order: CollectMaterialLinkOrder + code: string + screenshots: UploadedFile[] + submitting: boolean + disabled: boolean + onScreenshotsChange: (files: UploadedFile[]) => void + onSubmit: (values: Record) => void +} + +function MaterialLinkOrderForm({ + order, + code, + screenshots, + submitting, + disabled, + onScreenshotsChange, + onSubmit, +}: MaterialLinkOrderFormProps) { + return ( +
+ {(order.fields ?? []).map((field) => ( + + {field.label} + {field.required ? 必填 : 选填} + + } + rules={ + field.required + ? [{ required: true, whitespace: true, message: `请填写${field.label}` }] + : undefined + } + > + {field.type === 'select' && (field.options || []).length > 0 ? ( + ({ + label: option, + value: option, + }))} + /> + ) : ( + + )} + + ))} + + + + uploadCollectMaterialLinkFile(file, scene, code, order.workOrderId) + } + /> + + +
+ +
+
+ ) +} diff --git a/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts b/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts new file mode 100644 index 00000000..6547c6cd --- /dev/null +++ b/apps/frontend/src/pages/collect/use-collect-link-page-meta.ts @@ -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(`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]) +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index df50a26c..cb1b0494 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -770,8 +770,19 @@ 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`, + return apiPost<{ + token: string + /** 公开 URL 使用的平台订单号;旧后端可能没有该字段,前端仅用于滚动发布兜底。 */ + platformOrderId?: string + expiresAt: string + reused: boolean + }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/material-link`) +} + +/** 客服重置补资料:仅「待分配 + 未指派 + 无份额 + 未发布」可重置回待完善。 */ +export function resetAdminWorkOrderMaterialStatus(workOrderId: number) { + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/reset-material`, ) } diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index 5c32d562..ddc7a8b1 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -22,16 +22,22 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope) /** * 一次性补资料链接的截图上传。 * - * 与公开 collect/upload 的区别:先把 token 放在路径里,后端会校验链接 - * 是否仍可用;链接已使用/过期时不会落文件。 + * 与公开 collect/upload 的区别:路径里带平台订单号,多工单时再带 workOrderId; + * 后端会校验对应链接是否仍可用,链接已使用/过期时不会落文件。 */ -export async function uploadCollectMaterialLinkFile(file: File, scene: string, token: string) { +export async function uploadCollectMaterialLinkFile( + file: File, + scene: string, + code: string, + workOrderId?: number, +) { const uploadTarget = await optimizeImageForUpload(file, scene) const form = new FormData() form.append('file', uploadTarget) form.append('scene', scene) + const query = workOrderId ? `?workOrderId=${encodeURIComponent(String(workOrderId))}` : '' const response = await apiPostForm<{ file: UploadedFile }>( - `/api/v1/collect/link/${encodeURIComponent(token)}/upload`, + `/api/v1/collect/link/${encodeURIComponent(code)}/upload${query}`, form, ) return response.data.file diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 70f282a0..c6130c8f 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -381,19 +381,20 @@ export function submitCollectOrder(payload: { ) } -export function fetchCollectMaterialLink(token: string) { - return apiGet(`/api/v1/collect/link/${encodeURIComponent(token)}`) +export function fetchCollectMaterialLink(code: string) { + return apiGet(`/api/v1/collect/link/${encodeURIComponent(code)}`) } export function submitCollectMaterialLink( - token: string, + code: string, payload: { + workOrderId?: number fields: Record screenshots?: UploadedFile[] }, ) { return apiPost( - `/api/v1/collect/link/${encodeURIComponent(token)}/submit`, + `/api/v1/collect/link/${encodeURIComponent(code)}/submit`, payload, ) } diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index cc11c0b8..373a1d6c 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -499,6 +499,11 @@ padding: 28px; } +/* 一单多工单时,内层商品卡片比外层容器更紧凑 */ +.collect-link-order-card .ant-card-body { + padding: 16px; +} + .collect-link-title { margin: 0 0 4px !important; } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 6a34deeb..1fa4c993 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -647,6 +647,8 @@ export type WorkOrder = { canPin: boolean canUnpublish: boolean canUpdateMaterial: boolean + /** 待分配且未指派、无份额、未发布时,客服可以把补资料状态重置回待完善。 */ + canResetMaterial?: boolean canAccept: boolean canMarkProblem: boolean canDelete: boolean @@ -867,22 +869,39 @@ export type CollectLookupResponse = { pendingCount: number } -/** 一次性补资料链接的公开只读信息(不含价格/押金/打手等后台字段)。 */ -export type CollectMaterialLinkInfo = { - order: { - workOrderNo: string - platformOrderId: string - productName: string - status: string - } - fields: CollectField[] +export type CollectMaterialLinkBlockedReason = + | 'already_submitted' + | 'status' + | 'assigned' + | 'shares' + | '' + +/** 公开补资料页的单个工单;只有 canSubmit=true 时才有字段/已填值/截图。 */ +export type CollectMaterialLinkOrder = { + workOrderId: number + workOrderNo: string + platformOrderId: string + productName: string + status: string + statusLabel: string + canSubmit: boolean + blockedReason: CollectMaterialLinkBlockedReason + message: string + fields?: CollectField[] values?: Record screenshots?: UploadedFile[] - expiresAt: string +} + +/** 按平台订单号读取到的补资料信息;一单多工单时 orders 会有多条。 */ +export type CollectMaterialLinkInfo = { + platformOrderId: string + orders: CollectMaterialLinkOrder[] + pendingCount: number } export type CollectMaterialLinkSubmitResult = { complete: boolean + workOrderId: number order: { workOrderNo: string platformOrderId: string diff --git a/apps/frontend/src/utils/collect-link.ts b/apps/frontend/src/utils/collect-link.ts index 9ca1c4f6..a80145b0 100644 --- a/apps/frontend/src/utils/collect-link.ts +++ b/apps/frontend/src/utils/collect-link.ts @@ -1,9 +1,10 @@ /** * 一次性补资料链接的路径解析。 * - * 公开链接形如:`https://<域名>/supplement/`,与后台 HashRouter 完全隔离。 - * 命名空间判定用于路由分发:`/supplement` 及子路径整段交给公开页, - * 避免落到后台路由或打手端 SPA。 + * 公开链接有两种形态: + * - `https://<域名>/supplement/`:输入平台订单号; + * - `https://<域名>/supplement/<平台订单号>`:直接打开对应订单的补资料页。 + * 与后台 HashRouter 完全隔离。 */ export function isCollectMaterialLinkPath(pathname: unknown): boolean { @@ -11,14 +12,44 @@ export function isCollectMaterialLinkPath(pathname: unknown): boolean { } /** - * 只认 `/supplement/<一段 token>`: + * 只认 `/supplement/<一段 code>`: * - `/supplement/abc` 命中,结尾多余斜杠容忍; - * - `/supplement`、`/supplement/a/b` 不命中(页面展示链接无效)。 + * - `/supplement`、`/supplement/a/b` 不命中(由入口页/页面自己展示无效)。 */ -export function resolveCollectMaterialLinkToken(pathname: unknown): string { +export function resolveCollectMaterialLinkCode(pathname: unknown): string { const segments = splitPathSegments(pathname) if (segments.length !== 2 || segments[0] !== 'supplement') return '' - return decodeTokenSegment(segments[1]) + return decodePathSegment(segments[1]) +} + +/** 统一拼补资料链接;origin 结尾多余斜杠会被去掉。 */ +export function buildCollectMaterialLinkUrl(origin: unknown, code: unknown): string { + const base = String(origin ?? '') + .trim() + .replace(/\/+$/, '') + const normalized = String(code ?? '').trim() + if (!base || !normalized) return '' + return `${base}/supplement/${encodeURIComponent(normalized)}` +} + +/** + * 入口页输入归一化:去空格;如果用户粘贴了完整 `/supplement/<订单号>` 链接, + * 自动提取最后一段,避免把整条 URL 当成订单号。 + */ +export function normalizeOrderNoInput(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + + const marker = '/supplement/' + const index = raw.indexOf(marker) + if (index < 0) return raw + + const segment = raw + .slice(index + marker.length) + .split(/[?#]/)[0] + .replace(/\/+$/, '') + .trim() + return decodePathSegment(segment) } function splitPathSegments(pathname: unknown): string[] { @@ -28,7 +59,7 @@ function splitPathSegments(pathname: unknown): string[] { return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean) } -function decodeTokenSegment(segment: string): string { +function decodePathSegment(segment: string): string { try { return decodeURIComponent(segment) } catch { diff --git a/apps/frontend/tests/collect-link.test.ts b/apps/frontend/tests/collect-link.test.ts index 9bf34d9e..b1377cbc 100644 --- a/apps/frontend/tests/collect-link.test.ts +++ b/apps/frontend/tests/collect-link.test.ts @@ -2,31 +2,65 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + buildCollectMaterialLinkUrl, isCollectMaterialLinkPath, - resolveCollectMaterialLinkToken, + normalizeOrderNoInput, + resolveCollectMaterialLinkCode, } from '../src/utils/collect-link.ts' -test('补资料链接:/supplement/ 命中并解析 token', () => { - assert.equal(isCollectMaterialLinkPath('/supplement/abc123'), true) - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123'), 'abc123') +test('补资料链接:/supplement/<订单号> 命中并解析 code', () => { + assert.equal(isCollectMaterialLinkPath('/supplement/2630501851236676'), true) + assert.equal(resolveCollectMaterialLinkCode('/supplement/2630501851236676'), '2630501851236676') +}) + +test('补资料链接:入口页 /supplement 命中命名空间但没有 code', () => { + assert.equal(isCollectMaterialLinkPath('/supplement'), true) + assert.equal(isCollectMaterialLinkPath('/supplement/'), true) + assert.equal(resolveCollectMaterialLinkCode('/supplement'), '') + assert.equal(resolveCollectMaterialLinkCode('/supplement/'), '') }) test('补资料链接:容忍结尾斜杠、查询串与 hash', () => { - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123/'), 'abc123') - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123?from=chat'), 'abc123') - assert.equal(resolveCollectMaterialLinkToken('/supplement/abc123#top'), 'abc123') + assert.equal(resolveCollectMaterialLinkCode('/supplement/2630501851236676/'), '2630501851236676') + assert.equal( + resolveCollectMaterialLinkCode('/supplement/2630501851236676?from=chat'), + '2630501851236676', + ) + assert.equal( + resolveCollectMaterialLinkCode('/supplement/2630501851236676#top'), + '2630501851236676', + ) }) -test('补资料链接:命名空间命中但缺少 token 时返回空串', () => { - assert.equal(isCollectMaterialLinkPath('/supplement'), true) - assert.equal(isCollectMaterialLinkPath('/supplement/'), true) - assert.equal(resolveCollectMaterialLinkToken('/supplement'), '') - assert.equal(resolveCollectMaterialLinkToken('/supplement/a/b'), '') +test('补资料链接:多段路径不解析 code', () => { + assert.equal(resolveCollectMaterialLinkCode('/supplement/a/b'), '') }) test('补资料链接:其它路径不命中', () => { for (const pathname of ['/', '/claim/abc', '/collect', '/supplementx/abc', '']) { assert.equal(isCollectMaterialLinkPath(pathname), false, pathname) - assert.equal(resolveCollectMaterialLinkToken(pathname), '', pathname) + assert.equal(resolveCollectMaterialLinkCode(pathname), '', pathname) } }) + +test('补资料链接:URL 构建去掉 origin 结尾斜杠并编码', () => { + assert.equal( + buildCollectMaterialLinkUrl('https://ks.khhao.com/', '2630501851236676'), + 'https://ks.khhao.com/supplement/2630501851236676', + ) + assert.equal( + buildCollectMaterialLinkUrl('https://ks.khhao.com', 'abc/def'), + 'https://ks.khhao.com/supplement/abc%2Fdef', + ) + assert.equal(buildCollectMaterialLinkUrl('', 'abc'), '') + assert.equal(buildCollectMaterialLinkUrl('https://ks.khhao.com', ''), '') +}) + +test('补资料入口:输入归一化支持订单号与完整链接', () => { + assert.equal(normalizeOrderNoInput(' 2630501851236676 '), '2630501851236676') + assert.equal( + normalizeOrderNoInput('https://ks.khhao.com/supplement/2630501851236676?from=chat#top'), + '2630501851236676', + ) + assert.equal(normalizeOrderNoInput(''), '') +})