diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 0ebe9d0f..987c248a 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -35,6 +35,12 @@ export type ConsignmentInventoryRow = { source_submission_id: string | number | null /** 唯一锚点:来源提报单号(客服手工入库的现货没有) */ source_submission_no?: string | null + /** 提报凭证(卖家提报时上传的物品/主页截图) */ + submission_screenshots_json?: string | unknown[] | null + /** 赠送凭证:冷却结束后上传的赠送完成截图(存在绑定工单的验收资料里) */ + gift_evidence_json?: string | Record | null + gift_work_order_no?: string | null + gift_work_order_status?: string | null worker_id?: string | number | null worker_name?: string worker_phone?: string @@ -74,6 +80,10 @@ export type ConsignmentSubmissionRow = { reviewer_name: string reviewed_at: string | null result_inventory_id: string | number | null + /** 赠送凭证:履约方冷却结束后上传的赠送完成截图(存在绑定工单的验收资料里) */ + gift_evidence_json?: string | Record | null + gift_work_order_no?: string | null + gift_work_order_status?: string | null created_at: string updated_at: string category_name?: string @@ -180,10 +190,15 @@ export async function listConsignmentInventories(filters: { SELECT ci.*, wc.name AS category_name, - cs.submission_no AS source_submission_no + cs.submission_no AS source_submission_no, + cs.screenshots_json AS submission_screenshots_json, + wo.acceptance_json AS gift_evidence_json, + wo.work_order_no AS gift_work_order_no, + wo.status AS gift_work_order_status FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id ${whereClause} ORDER BY ci.id DESC LIMIT $${values.length - 1} OFFSET $${values.length} @@ -290,10 +305,15 @@ export async function getConsignmentInventoryById( SELECT ci.*, wc.name AS category_name, - cs.submission_no AS source_submission_no + cs.submission_no AS source_submission_no, + cs.screenshots_json AS submission_screenshots_json, + wo.acceptance_json AS gift_evidence_json, + wo.work_order_no AS gift_work_order_no, + wo.status AS gift_work_order_status FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id WHERE ci.id = $1 LIMIT 1 ` @@ -941,12 +961,18 @@ export async function listConsignmentSubmissions(filters: { const offset = (page - 1) * pageSize values.push(pageSize, offset) + // 赠送凭证存在绑定工单的验收资料里:提报行 → 结果库存 → 绑定工单 const listSql = ` SELECT cs.*, - wc.name AS category_name + wc.name AS category_name, + wo.acceptance_json AS gift_evidence_json, + wo.work_order_no AS gift_work_order_no, + wo.status AS gift_work_order_status FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN consignment_inventories rinv ON rinv.id = cs.result_inventory_id + LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id ${whereClause} ORDER BY cs.id DESC LIMIT $${values.length - 1} OFFSET $${values.length} diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index f8043509..89713abc 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -70,6 +70,70 @@ function pickScreenshotsJson(payload: JsonObject): unknown[] | undefined { return Array.isArray(value) ? value : undefined } +function toRecord(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + const parsed = safeParseJson(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null +} + +function toScreenshotList(value: unknown): string[] { + const list = Array.isArray(value) ? value : [] + return list.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) +} + +/** + * 一件寄卖的**凭证链**:提报凭证(卖家提报时上传的物品/主页截图)+ 赠送凭证 + * (履约方冷却结束后上传的赠送完成截图,落在绑定工单的验收资料里,客服凭它验收)。 + * + * 这两个凭证以前散在两处、只有提报凭证在寄卖审核界面可见,客服要证明"这件货真送出去了" + * 得自己去工单里翻,所以这里统一从客服的两个列表接口下发。 + */ +function mapConsignmentEvidence(source: { + submission_screenshots_json?: unknown + screenshots_json?: unknown + gift_evidence_json?: string | Record | null + gift_work_order_no?: string | null + gift_work_order_status?: string | null +}) { + const acceptance = toRecord(source.gift_evidence_json) + const files = Array.isArray(acceptance?.files) ? (acceptance.files as unknown[]) : [] + const fileUrls = files.map((file) => { + const record = toRecord(file) + return String(record?.url || record?.mediumUrl || record?.thumbnailUrl || '').trim() + }) + const giftImages = [ + ...new Set([...toScreenshotList(acceptance?.imageUrls), ...fileUrls.filter(Boolean)]), + ] + const workOrderStatus = String(source.gift_work_order_status || '') + const reviewStatus = + giftImages.length === 0 + ? 'none' + : workOrderStatus === 'accepted' + ? 'accepted' + : workOrderStatus === 'pending_acceptance' + ? 'pending' + : 'submitted' + + return { + /** 提报凭证(物品/主页截图) */ + submissionScreenshots: toScreenshotList( + source.submission_screenshots_json ?? source.screenshots_json, + ), + /** 赠送凭证(赠送完成截图) */ + giftEvidenceImages: giftImages, + giftEvidenceNote: String(acceptance?.note || ''), + giftEvidenceSubmittedAt: String(acceptance?.submittedAt || ''), + giftWorkOrderNo: source.gift_work_order_no || '', + giftWorkOrderStatus: workOrderStatus, + /** none=还没交 / pending=交了待客服验收 / accepted=客服已验收 / submitted=其他在途状态 */ + giftEvidenceReviewStatus: reviewStatus, + } +} + export function mapConsignmentInventory(row: ConsignmentInventoryRow) { return { id: Number(row.id), @@ -110,6 +174,7 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null, /** 唯一锚点:卖家端看到的「寄卖单号」就是它,客服端也用同一个号 */ sourceSubmissionNo: row.source_submission_no || '', + ...mapConsignmentEvidence(row), workerId: row.worker_id ? Number(row.worker_id) : null, workerName: row.worker_name || '', workerPhone: row.worker_phone || '', @@ -158,6 +223,7 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { reviewerName: row.reviewer_name || '', reviewedAt: row.reviewed_at, resultInventoryId: row.result_inventory_id ? Number(row.result_inventory_id) : null, + ...mapConsignmentEvidence(row), createdAt: row.created_at, updatedAt: row.updated_at, } diff --git a/apps/backend/src/services/worker-platform/consignment-evidence.test.ts b/apps/backend/src/services/worker-platform/consignment-evidence.test.ts new file mode 100644 index 00000000..18b7b24d --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-evidence.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { mapConsignmentInventory, mapConsignmentSubmission } from './consignment-admin-service.js' + +function inventoryRow(overrides: Record = {}) { + return { + id: 1, + inventory_no: 'INV1', + game_name: '和平精英', + category_id: null, + item_name: '苹果微信区血宴3钻2璀璨 苏尔南', + system: '苹果', + server_zone: '微信区', + series: '血宴', + diamond_level: 3, + brilliance_count: 2, + cost_price_fen: 11500, + status: 'consumed', + allocation_status: '已安排', + source_type: 'worker_portal', + source_submission_id: 9, + source_submission_no: 'SUB1', + screenshots_json: [], + submission_screenshots_json: ['https://cdn.example.com/publish-1.png'], + ...overrides, + } as never +} + +test('赠送凭证从工单验收资料的 imageUrls 与 files 两处取图并去重', () => { + const mapped = mapConsignmentInventory( + inventoryRow({ + gift_evidence_json: { + note: '已赠送', + imageUrls: ['https://cdn.example.com/a.png', 'https://cdn.example.com/a.png'], + files: [ + { url: 'https://cdn.example.com/b.png' }, + { mediumUrl: 'https://cdn.example.com/c.png' }, + { thumbnailUrl: '' }, + {}, + ], + submittedAt: '2026-09-17T10:00:00.000Z', + }, + gift_work_order_no: 'WO1', + gift_work_order_status: 'pending_acceptance', + }), + ) + + assert.deepEqual(mapped.giftEvidenceImages, [ + 'https://cdn.example.com/a.png', + 'https://cdn.example.com/b.png', + 'https://cdn.example.com/c.png', + ]) + assert.equal(mapped.giftEvidenceNote, '已赠送') + assert.equal(mapped.giftEvidenceSubmittedAt, '2026-09-17T10:00:00.000Z') + assert.equal(mapped.giftWorkOrderNo, 'WO1') +}) + +test('赠送凭证状态跟着绑定工单走:未交/待验收/已验收', () => { + assert.equal( + mapConsignmentInventory(inventoryRow({ gift_evidence_json: null })).giftEvidenceReviewStatus, + 'none', + ) + assert.equal( + mapConsignmentInventory( + inventoryRow({ + gift_evidence_json: { imageUrls: ['https://cdn.example.com/a.png'] }, + gift_work_order_status: 'pending_acceptance', + }), + ).giftEvidenceReviewStatus, + 'pending', + ) + assert.equal( + mapConsignmentInventory( + inventoryRow({ + gift_evidence_json: { imageUrls: ['https://cdn.example.com/a.png'] }, + gift_work_order_status: 'accepted', + }), + ).giftEvidenceReviewStatus, + 'accepted', + ) + // 交了图但工单还没走到待验收(例如超时挂起),也不能说成"已验收" + assert.equal( + mapConsignmentInventory( + inventoryRow({ + gift_evidence_json: { imageUrls: ['https://cdn.example.com/a.png'] }, + gift_work_order_status: 'in_progress', + }), + ).giftEvidenceReviewStatus, + 'submitted', + ) +}) + +test('提报凭证优先用来源提报单的截图,客服手工入库的现货回退自身截图', () => { + assert.deepEqual(mapConsignmentInventory(inventoryRow()).submissionScreenshots, [ + 'https://cdn.example.com/publish-1.png', + ]) + const manual = mapConsignmentInventory( + inventoryRow({ + source_submission_id: null, + submission_screenshots_json: null, + screenshots_json: ['https://cdn.example.com/manual.png'], + }), + ) + assert.deepEqual(manual.submissionScreenshots, ['https://cdn.example.com/manual.png']) +}) + +test('脏数据不炸:坏 JSON / 空串 / 非数组都退回空凭证', () => { + for (const bad of ['', '{坏json', null, 42, []]) { + const mapped = mapConsignmentInventory( + inventoryRow({ gift_evidence_json: bad, submission_screenshots_json: bad }), + ) + assert.deepEqual(mapped.giftEvidenceImages, []) + assert.deepEqual(mapped.submissionScreenshots, []) + assert.equal(mapped.giftEvidenceReviewStatus, 'none') + } +}) + +test('提报行同样带出赠送凭证(审核界面要看这件货有没有真的送出去)', () => { + const mapped = mapConsignmentSubmission({ + id: 9, + submission_no: 'SUB1', + game_name: '和平精英', + item_name: '苹果微信区血宴3钻2璀璨 苏尔南', + system: '苹果', + server_zone: '微信区', + series: '血宴', + diamond_level: 3, + brilliance_count: 2, + screenshots_json: ['https://cdn.example.com/publish-1.png'], + asking_price_fen: 13000, + worker_id: 3, + status: 'approved', + result_inventory_id: 1, + gift_evidence_json: { imageUrls: ['https://cdn.example.com/gift.png'] }, + gift_work_order_no: 'WO1', + gift_work_order_status: 'accepted', + created_at: '2026-09-17T10:00:00.000Z', + updated_at: '2026-09-17T10:00:00.000Z', + } as never) + + assert.deepEqual(mapped.screenshotsJson, ['https://cdn.example.com/publish-1.png']) + assert.deepEqual(mapped.giftEvidenceImages, ['https://cdn.example.com/gift.png']) + assert.equal(mapped.giftEvidenceReviewStatus, 'accepted') + assert.equal(mapped.giftWorkOrderNo, 'WO1') +}) diff --git a/apps/frontend/src/components/admin/ConsignmentEvidenceGallery.tsx b/apps/frontend/src/components/admin/ConsignmentEvidenceGallery.tsx new file mode 100644 index 00000000..db562654 --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentEvidenceGallery.tsx @@ -0,0 +1,92 @@ +import { Image, Typography } from 'antd' +import type { CSSProperties } from 'react' + +type ConsignmentEvidenceGalleryProps = { + /** 凭证图片地址列表;空数组 / 空值时渲染 emptyText */ + images?: string[] | null + /** 没有凭证时的占位文案(灰色小字) */ + emptyText?: string + /** 列表里最多铺几张缩略图,超出的用「+N」覆盖层提示 */ + max?: number + /** 缩略图边长(px) */ + size?: number +} + +const DEFAULT_MAX = 3 +const DEFAULT_SIZE = 52 + +/** + * 寄卖凭证画廊:把一组凭证图渲染成「缩略图 + 点击看大图」。 + * + * 客服原来只能看到一张 48x48 的小图,既看不清是什么、也看不到全部; + * 这里统一成缩略图 + `共 N 张` 提示,点击任意缩略图即可放大预览**全部**图片 + * (预览项来自 `items`,所以被 `max` 折叠的图在大图预览里依然可以左右切换)。 + */ +export default function ConsignmentEvidenceGallery({ + images, + emptyText = '无', + max = DEFAULT_MAX, + size = DEFAULT_SIZE, +}: ConsignmentEvidenceGalleryProps) { + const list = normalizeImages(images) + + if (list.length === 0) { + return ( + + {emptyText} + + ) + } + + const visibleCount = Math.max(1, Math.min(max, list.length)) + const visible = list.slice(0, visibleCount) + const hiddenCount = list.length - visible.length + + return ( + +
+ {visible.map((src, index) => ( +
+ + {hiddenCount > 0 && index === visible.length - 1 ? ( + + +{hiddenCount} + + ) : null} +
+ ))} + {hiddenCount > 0 ? ( + + 共 {list.length} 张 + + ) : null} +
+
+ ) +} + +/** 「+N」覆盖层:不吃鼠标事件,点击仍然穿透到缩略图打开大图预览 */ +const MORE_OVERLAY_STYLE: CSSProperties = { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + background: 'rgba(0, 0, 0, 0.45)', + color: '#fff', + fontSize: 13, + fontWeight: 600, + lineHeight: 1, + pointerEvents: 'none', +} + +function normalizeImages(images?: string[] | null) { + if (!Array.isArray(images)) return [] + return images.map((src) => String(src ?? '').trim()).filter(Boolean) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 4a45cb13..51bf7bee 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -1,8 +1,5 @@ import { - CheckCircleOutlined, - CopyOutlined, EyeOutlined, - FileImageOutlined, PlusOutlined, ReloadOutlined, RollbackOutlined, @@ -18,7 +15,6 @@ import { Col, Descriptions, Form, - Image, Input, InputNumber, Modal, @@ -36,6 +32,7 @@ import { import type { TableColumnsType } from 'antd' import { useState } from 'react' +import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' import { archiveAdminConsignmentInventory, createAdminConsignmentInventory, @@ -46,9 +43,11 @@ import { updateAdminConsignmentInventory, } from '@/services/admin/consignment' import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform' -import type { ConsignmentInventory } from '@/types/consignment' +import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment' import { fetchAdminSession } from '@/services/admin/auth' import { resolveAdminPermission } from '@/utils/admin-auth' +import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' +import { formatDateTime } from '@/utils/date-time' const { Text } = Typography @@ -92,6 +91,9 @@ const BUYER_COLORS: Record = { 客服B: 'magenta', } +/** 「凭证」列里两组的灰字小标题 */ +const EVIDENCE_CAPTION_STYLE = { fontSize: 12, color: '#8c8c8c' } as const + /** * 寄卖单号 = 来源提报单号(卖家提报时生成的提报单号就是唯一寄卖单号)。 * 客服手工入库的现货没有来源提报,回退显示内部仓库号,保证客服对单口径一致。 @@ -100,6 +102,33 @@ function resolveConsignmentNo(record: ConsignmentInventory) { return record.sourceSubmissionNo || record.inventoryNo } +/** 关联工单状态文案(后端 giftWorkOrderStatus:in_progress / pending_acceptance / accepted 等) */ +const GIFT_WORK_ORDER_STATUS_LABELS: Record = { + in_progress: '履约中', + pending_acceptance: '待客服验收', + accepted: '已验收', +} + +function formatGiftWorkOrderStatus(status?: string) { + const normalized = String(status || '').trim() + if (!normalized) return '状态未知' + return GIFT_WORK_ORDER_STATUS_LABELS[normalized] || normalized +} + +/** + * 赠送凭证为空时的占位文案:客服要能一眼看出「为什么还没有赠送凭证」, + * 所以按有没有匹配到工单讲清原因,不能只写一个「无」。 + */ +function resolveGiftEvidenceEmptyText(record: { + giftEvidenceReviewStatus?: GiftEvidenceReviewStatus + giftWorkOrderNo?: string +}) { + if (record.giftEvidenceReviewStatus === 'none') { + return record.giftWorkOrderNo ? '待上传赠送截图' : '未匹配工单,待赠送' + } + return '未上传赠送截图' +} + export default function ConsignmentInventoryPanel() { const { message } = App.useApp() @@ -471,6 +500,52 @@ export default function ConsignmentInventoryPanel() { ), }, + { + title: '凭证', + key: 'evidence', + width: 190, + render: (_, record) => { + const badge = resolveGiftEvidenceBadge(record.giftEvidenceReviewStatus ?? 'none') + return ( + +
+
提报凭证
+ +
+
+
赠送凭证
+ + + + {badge.text} + + {record.giftWorkOrderNo ? ( + + + 关联工单 + + + ) : null} + +
+
+ ) + }, + }, { title: '允许匹配', key: 'allowMatching', @@ -596,6 +671,9 @@ export default function ConsignmentInventoryPanel() { }, ] + // 详情弹窗里的赠送凭证标签(未选记录时给默认值,Modal 未打开也不会取到 undefined) + const viewingGiftBadge = resolveGiftEvidenceBadge(viewingItem?.giftEvidenceReviewStatus ?? 'none') + return (
{/* 统计概览卡片 */} @@ -783,6 +861,7 @@ export default function ConsignmentInventoryPanel() { columns={columns} dataSource={items} loading={inventoriesQuery.isLoading} + scroll={{ x: 1590 }} pagination={{ current: page, pageSize, @@ -1279,6 +1358,62 @@ export default function ConsignmentInventoryPanel() { {viewingItem.sellerPaymentInfo || '-'} + + + + 游戏主页截图(含物品/仓库详情) + + + + + + + + 赠送完成截图(客服凭此验收) + + + + {viewingGiftBadge.text} + + + {describeGiftEvidence({ + status: viewingItem.giftEvidenceReviewStatus ?? 'none', + images: viewingItem.giftEvidenceImages ?? [], + submittedAt: viewingItem.giftEvidenceSubmittedAt, + })} + + + + + {viewingItem.giftEvidenceNote || '-'} + + + {viewingItem.giftEvidenceSubmittedAt + ? formatDateTime(viewingItem.giftEvidenceSubmittedAt) + : '-'} + + + {viewingItem.giftWorkOrderNo ? ( + + {viewingItem.giftWorkOrderNo} + + {formatGiftWorkOrderStatus(viewingItem.giftWorkOrderStatus)} + + + ) : ( + 未匹配工单 + )} + {(viewingItem.credentialsJson as any)?.loginMethod || '-'} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index 88fdee23..8419b390 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -13,7 +13,6 @@ import { Col, Descriptions, Form, - Image, Input, InputNumber, Modal, @@ -23,12 +22,14 @@ import { Space, Table, Tag, + Tooltip, Typography, App, } from 'antd' import type { TableColumnsType } from 'antd' import { useState } from 'react' +import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' import { approveAdminConsignmentSubmission, fetchAdminConsignmentSubmissions, @@ -36,11 +37,40 @@ import { } from '@/services/admin/consignment' import { fetchAdminSession } from '@/services/admin/auth' import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform' -import type { ConsignmentSubmission } from '@/types/consignment' +import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment' import { resolveAdminPermission } from '@/utils/admin-auth' +import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' +import { formatDateTime } from '@/utils/date-time' const { Text } = Typography +/** 关联工单状态文案(后端 giftWorkOrderStatus:in_progress / pending_acceptance / accepted 等) */ +const GIFT_WORK_ORDER_STATUS_LABELS: Record = { + in_progress: '履约中', + pending_acceptance: '待客服验收', + accepted: '已验收', +} + +function formatGiftWorkOrderStatus(status?: string) { + const normalized = String(status || '').trim() + if (!normalized) return '状态未知' + return GIFT_WORK_ORDER_STATUS_LABELS[normalized] || normalized +} + +/** + * 赠送凭证为空时的占位文案:客服要能一眼看出「为什么还没有赠送凭证」, + * 所以按有没有匹配到工单讲清原因,不能只写一个「无」。 + */ +function resolveGiftEvidenceEmptyText(record: { + giftEvidenceReviewStatus?: GiftEvidenceReviewStatus + giftWorkOrderNo?: string +}) { + if (record.giftEvidenceReviewStatus === 'none') { + return record.giftWorkOrderNo ? '待上传赠送截图' : '未匹配工单,待赠送' + } + return '未上传赠送截图' +} + export default function ConsignmentSubmissionsPanel() { const { message } = App.useApp() @@ -240,21 +270,45 @@ export default function ConsignmentSubmissionsPanel() { ), }, { - title: '凭据截图', - key: 'screens', - width: 110, + title: '提报凭证(游戏主页截图)', + key: 'submissionScreenshots', + width: 170, + render: (_, record) => ( + + ), + }, + { + title: '赠送凭证', + key: 'giftEvidence', + width: 170, render: (_, record) => { - const screens = Array.isArray(record.screenshotsJson) ? record.screenshotsJson : [] - if (screens.length === 0) return 无截图 + const badge = resolveGiftEvidenceBadge(record.giftEvidenceReviewStatus ?? 'none') return ( - - + - + + + {badge.text} + + {record.giftWorkOrderNo ? ( + + + 关联工单 + + + ) : null} + + ) }, }, @@ -330,6 +384,11 @@ export default function ConsignmentSubmissionsPanel() { }, ] + // 详情弹窗里的赠送凭证标签(未选记录时给默认值,Modal 未打开也不会取到 undefined) + const viewingGiftBadge = resolveGiftEvidenceBadge( + viewingSubmission?.giftEvidenceReviewStatus ?? 'none', + ) + return (
@@ -392,6 +451,7 @@ export default function ConsignmentSubmissionsPanel() { columns={submissionColumns} dataSource={submissions} loading={submissionsQuery.isLoading} + scroll={{ x: 1490 }} pagination={{ current: submissionPage, pageSize: submissionPageSize, @@ -651,24 +711,60 @@ export default function ConsignmentSubmissionsPanel() { {viewingSubmission.sellerPaymentInfo || '-'} - - {Array.isArray(viewingSubmission.screenshotsJson) && - viewingSubmission.screenshotsJson.length > 0 ? ( - - - {viewingSubmission.screenshotsJson.map((src, i) => ( - - ))} - - + + + + 游戏主页截图(含物品/仓库详情) + + + + + + + + 赠送完成截图(客服凭此验收) + + + + {viewingGiftBadge.text} + + + {describeGiftEvidence({ + status: viewingSubmission.giftEvidenceReviewStatus ?? 'none', + images: viewingSubmission.giftEvidenceImages ?? [], + submittedAt: viewingSubmission.giftEvidenceSubmittedAt, + })} + + + + + {viewingSubmission.giftEvidenceNote || '-'} + + + {viewingSubmission.giftEvidenceSubmittedAt + ? formatDateTime(viewingSubmission.giftEvidenceSubmittedAt) + : '-'} + + + {viewingSubmission.giftWorkOrderNo ? ( + + {viewingSubmission.giftWorkOrderNo} + + {formatGiftWorkOrderStatus(viewingSubmission.giftWorkOrderStatus)} + + ) : ( - 无截图 + 未匹配工单 )} diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 61bb548b..9b94a977 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -1,4 +1,36 @@ -export type ConsignmentInventory = { +/** + * 赠送凭证(履约方冷却结束后上传的「赠送完成截图」)的客服验收状态。 + * - none:还没交(未匹配工单 / 待赠送) + * - pending:已交,待客服验收 + * - accepted:客服已验收 + * - submitted:其他在途状态 + */ +export type GiftEvidenceReviewStatus = 'none' | 'pending' | 'accepted' | 'submitted' + +/** + * 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。 + * + * 展示提报凭证一律用 `submissionScreenshots`:有来源提报单就是提报单的截图, + * 客服手工入库的现货由后端回退成库存自己的截图(库存的 `screenshotsJson` 是库存自己的)。 + */ +export type ConsignmentEvidenceFields = { + /** 提报凭证:卖家提报时上传的「游戏主页截图」(含物品/仓库详情) */ + submissionScreenshots?: string[] + /** 赠送凭证:赠送完成截图 */ + giftEvidenceImages?: string[] + /** 赠送备注 */ + giftEvidenceNote?: string + /** 赠送凭证提交时间(ISO 字符串,可能为空) */ + giftEvidenceSubmittedAt?: string + /** 关联工单号(可能为空) */ + giftWorkOrderNo?: string + /** 关联工单状态:in_progress / pending_acceptance / accepted 等 */ + giftWorkOrderStatus?: string + /** 赠送凭证的客服验收状态 */ + giftEvidenceReviewStatus?: GiftEvidenceReviewStatus +} + +export type ConsignmentInventory = ConsignmentEvidenceFields & { id: number inventoryNo: string gameName: string @@ -58,7 +90,7 @@ export type ConsignmentFulfillmentInfo = { [key: string]: unknown } -export type ConsignmentSubmission = { +export type ConsignmentSubmission = ConsignmentEvidenceFields & { id: number submissionNo: string gameName: string diff --git a/apps/frontend/src/utils/consignment-evidence.ts b/apps/frontend/src/utils/consignment-evidence.ts new file mode 100644 index 00000000..bdab87b1 --- /dev/null +++ b/apps/frontend/src/utils/consignment-evidence.ts @@ -0,0 +1,97 @@ +/** + * 寄卖凭证(提报凭证 / 赠送凭证)的纯展示逻辑。 + * + * 客服要在列表和详情里一句话看清「赠送凭证交没交、交了几张、验没验」, + * 所以把文案规则集中在这里。刻意写成纯函数:不依赖 React / antd, + * 也不使用 `@/` 别名,方便 `node --experimental-strip-types --test` 直接跑单测。 + */ +import type { GiftEvidenceReviewStatus } from '../types/consignment' + +export type { GiftEvidenceReviewStatus } + +export type GiftEvidenceBadge = { + /** 标签文案 */ + text: string + /** antd Tag 的 color 取值 */ + color: string +} + +export type GiftEvidenceSummaryInput = { + status: GiftEvidenceReviewStatus + /** 赠送凭证图片(赠送完成截图) */ + images: string[] + /** 凭证提交时间(ISO 字符串,可能为空) */ + submittedAt?: string +} + +/** + * 赠送凭证状态 → 客服标签。 + * 后端以后新增状态时不能崩:未知状态一律回退成「未提交」。 + */ +export function resolveGiftEvidenceBadge(status: GiftEvidenceReviewStatus): GiftEvidenceBadge { + switch (status) { + case 'pending': + return { text: '待客服验收', color: 'gold' } + case 'accepted': + return { text: '已验收', color: 'green' } + case 'submitted': + return { text: '已提交', color: 'blue' } + default: + return { text: '未提交', color: 'default' } + } +} + +/** + * 给客服看的一句话赠送凭证说明: + * - `未提交` + * - `已提交 3 张,待客服验收` + * - `已验收(3 张)· 2026-09-17 10:00` + * - 有图但没时间 / 时间非法:省略 ` · 时间` 部分 + * - 已提交但 0 张图:`已提交,无截图`(带状态后缀时如 `已提交,无截图,待客服验收`) + * - `none`(以及未来新增状态)只看成「还没交」:即使残留了时间也不拼进文案,避免自相矛盾 + */ +export function describeGiftEvidence(input: GiftEvidenceSummaryInput): string { + const count = countEvidenceImages(input?.images) + const time = formatEvidenceTime(input?.submittedAt) + + switch (input?.status) { + case 'pending': + return appendEvidenceTime( + count > 0 ? `已提交 ${count} 张,待客服验收` : '已提交,无截图,待客服验收', + time, + ) + case 'accepted': + return appendEvidenceTime(count > 0 ? `已验收(${count} 张)` : '已验收,无截图', time) + case 'submitted': + return appendEvidenceTime(count > 0 ? `已提交 ${count} 张` : '已提交,无截图', time) + default: + return '未提交' + } +} + +/** 追加提交时间;没有有效时间就原样返回(不留下孤零零的分隔符) */ +function appendEvidenceTime(text: string, time: string): string { + if (!time) return text + // 以全角右括号收尾的短句(如「已验收(3 张)」)直接紧凑接 ·,其余用空格分隔更易读 + return text.endsWith(')') ? `${text}· ${time}` : `${text} · ${time}` +} + +/** 统计有效图片张数:空字符串 / 空白串不算凭证 */ +function countEvidenceImages(images?: string[] | null): number { + if (!Array.isArray(images)) return 0 + return images.filter((src) => String(src ?? '').trim()).length +} + +/** 凭证提交时间 → `YYYY-MM-DD HH:mm`;空值或非法时间返回空串(调用方直接省略这段) */ +function formatEvidenceTime(submittedAt?: string | null): string { + const normalized = String(submittedAt ?? '').trim() + if (!normalized) return '' + + const date = new Date(normalized) + if (Number.isNaN(date.getTime())) return '' + + const pad = (value: number) => String(value).padStart(2, '0') + const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}` + return `${datePart} ${timePart}` +} diff --git a/apps/frontend/tests/consignment-evidence.test.ts b/apps/frontend/tests/consignment-evidence.test.ts new file mode 100644 index 00000000..f5966187 --- /dev/null +++ b/apps/frontend/tests/consignment-evidence.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + describeGiftEvidence, + resolveGiftEvidenceBadge, +} from '../src/utils/consignment-evidence.ts' +import type { GiftEvidenceReviewStatus } from '../src/types/consignment.ts' + +/** 故意构造后端未来可能新增的状态,验证未知状态不会崩 */ +const FUTURE_STATUS = 'reviewing' as unknown as GiftEvidenceReviewStatus + +test('赠送凭证标签:四种状态各有文案与配色', () => { + assert.deepEqual(resolveGiftEvidenceBadge('none'), { text: '未提交', color: 'default' }) + assert.deepEqual(resolveGiftEvidenceBadge('pending'), { text: '待客服验收', color: 'gold' }) + assert.deepEqual(resolveGiftEvidenceBadge('accepted'), { text: '已验收', color: 'green' }) + assert.deepEqual(resolveGiftEvidenceBadge('submitted'), { text: '已提交', color: 'blue' }) +}) + +test('赠送凭证标签:未知状态回退为未提交 / default', () => { + assert.deepEqual(resolveGiftEvidenceBadge(FUTURE_STATUS), { text: '未提交', color: 'default' }) + assert.equal(describeGiftEvidence({ status: FUTURE_STATUS, images: ['a.png'] }), '未提交') +}) + +test('未提交(none)只说一句「未提交」', () => { + assert.equal(describeGiftEvidence({ status: 'none', images: [] }), '未提交') +}) + +test('未提交但有残留图片与时间时,仍然只说「未提交」', () => { + assert.equal( + describeGiftEvidence({ + status: 'none', + images: ['a.png', 'b.png'], + submittedAt: '2026-09-17T10:00:00', + }), + '未提交', + ) +}) + +test('待客服验收:按「已提交 N 张,待客服验收」,没有提交时间就不拼时间', () => { + assert.equal( + describeGiftEvidence({ status: 'pending', images: ['a.png', 'b.png', 'c.png'] }), + '已提交 3 张,待客服验收', + ) + assert.equal( + describeGiftEvidence({ + status: 'pending', + images: ['a.png'], + submittedAt: '2026-09-17T10:00:00', + }), + '已提交 1 张,待客服验收 · 2026-09-17 10:00', + ) +}) + +test('已验收:按「已验收(N 张)· 提交时间」', () => { + assert.equal( + describeGiftEvidence({ + status: 'accepted', + images: ['a.png', 'b.png', 'c.png'], + submittedAt: '2026-09-17T10:00:30', + }), + '已验收(3 张)· 2026-09-17 10:00', + ) +}) + +test('已提交:有图按张数,0 张 / 空数组写「已提交,无截图」', () => { + assert.equal(describeGiftEvidence({ status: 'submitted', images: ['a.png'] }), '已提交 1 张') + assert.equal( + describeGiftEvidence({ status: 'submitted', images: ['a.png', '', ' '] }), + '已提交 1 张', + ) + assert.equal(describeGiftEvidence({ status: 'submitted', images: [] }), '已提交,无截图') +}) + +test('已验收但没截图写「已验收,无截图」;时间非法或空白时省略时间部分', () => { + assert.equal(describeGiftEvidence({ status: 'accepted', images: [] }), '已验收,无截图') + assert.equal( + describeGiftEvidence({ status: 'accepted', images: [], submittedAt: '' }), + '已验收,无截图', + ) + assert.equal( + describeGiftEvidence({ status: 'accepted', images: [], submittedAt: ' ' }), + '已验收,无截图', + ) + assert.equal( + describeGiftEvidence({ status: 'accepted', images: [], submittedAt: '不是时间' }), + '已验收,无截图', + ) +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index c0267904..5e91b791 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -21,6 +21,7 @@ | `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 | | `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` | | `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` | +| 本批提交(批次 J,同一提交内含本文档,故不写自身 hash) | 批次 J:凭证链可视化(提报凭证 + 赠送凭证),客服审核/仓库面板统一展示 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentSubmissionsPanel.tsx`、`ConsignmentInventoryPanel.tsx` | | 本批提交(批次 I,同一提交内含本文档,故不写自身 hash) | 批次 I:提报单号为唯一寄卖单号(一件货一行)+ 统一状态机 + 卖家确认加好友/提交赠送截图 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`ConsignmentInventoryPanel.tsx`、`WorkerConsignmentPage.tsx` | | 本批提交(批次 H,同一提交内含本文档,故不写自身 hash) | 批次 H:修掉发布时乱弹内部库存提醒(部分匹配兜底过宽)+ 弹窗赏金分/元换算 | `consignment-matching-service.ts`、履约复检、`WorkOrdersPanel.tsx` | | 本批提交(批次 G,同一提交内含本文档,故不写自身 hash) | 批次 G:确认加好友后现货作废不回池 + 撤销寄售履约押金可选(含修打手押金永久冻结、真实库 42804 500) | `consignment-repo.ts`、`consignment-deposit-policy.ts`、`work-order-deposit-repo.ts`、撤销履约服务与路由、`WorkOrdersPanel.tsx` | @@ -279,6 +280,34 @@ 前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。 3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。 +### 批次 J · 凭证链可视化:提报凭证 + 赠送凭证(本轮) + +**问题**:一件寄卖有两类凭证,但客服界面只看得见第一类,而且是 48×48 一张缩略图,看不出是物品还是主页: + +1. **提报凭证** = 卖家提报时的「游戏主页截图」(含物品/仓库详情),存在提报表 `screenshots_json`。 +2. **赠送凭证** = 履约方冷却结束后上传的**赠送完成截图**(证明"这件货真送出去了"), + 存在**绑定工单的验收资料** `work_orders.acceptance_json` 里。 + +第一类只在寄卖审核界面可见;第二类只有在工单验收界面才看得到。客服要证明一件寄卖的赠送 +是否完成,得自己在两个模块之间翻,且没有任何地方把两类凭证并排呈现。 + +**改法**: + +1. ✅ 后端把**凭证链**从客服两个列表/详情接口一起下发(提报表 → 结果库存 → 绑定工单): + `submissionScreenshots`(提报凭证)、`giftEvidenceImages`(赠送凭证)、`giftEvidenceNote`、 + `giftEvidenceSubmittedAt`、`giftWorkOrderNo`、`giftWorkOrderStatus`、 + `giftEvidenceReviewStatus`(`none` 未提交 / `pending` 待客服验收 / `accepted` 已验收 / `submitted` 其他在途)。 + 赠送凭证的图片从工单验收资料的 `imageUrls` 与 `files[].url|mediumUrl|thumbnailUrl` 两处取并去重。 +2. ✅ 提报凭证口径统一:有来源提报单就用提报单的主页截图,客服手工入库的现货回退用库存自己的截图, + 前端只需要看 `submissionScreenshots` 一个字段,不会再拿错。 +3. ✅ 前端新增共享组件 `ConsignmentEvidenceGallery`(缩略图 + 大图预览 + 共 N 张), + 审核面板与仓库面板复用;审核面板原来那个「凭据截图」列拆成 + 「提报凭证(游戏主页截图)」与「赠送凭证(赠送完成截图,带待验收/已验收 Tag 与关联工单)」两列, + 详情抽屉同样分组,并给出 `describeGiftEvidence()` 的一句话说明。 + +**真实库验证**:赠送凭证解析在人工构造的工单验收资料上正确取到 3 张图(`imageUrls` 1 张 + `files` 2 张) +并能识别 `pending_acceptance → 待客服验收`;提报凭证在真实数据上取到 1 张。临时数据已清理。 + ### 批次 I · 一个单号锚定 + 统一状态机(本轮) **问题**:一件货在系统里有三层互不相通的状态——提报单(`SUB…`,审核态)、库存(`INV…`,仓库态)、