feat(consignment): 客服端凭证链可视化(提报凭证 + 赠送凭证)
问题:一件寄卖有两类凭证,客服界面只看得见第一类,还是 48x48 一张缩略图, 看不出是主页还是物品;第二类(证明"这件货真送出去了"的赠送完成截图)只在工单验收界面, 客服要证明赠送是否完成得自己跨模块翻,没有任何地方把两类凭证并排呈现。 - 两类凭证分别在哪里: 提报凭证 = 提报表 screenshots_json(卖家提报时的游戏主页截图,含物品/仓库详情) 赠送凭证 = 绑定工单的验收资料 work_orders.acceptance_json(冷却结束后上传、客服凭此验收) - 后端把凭证链从客服两个列表/详情接口一起下发:提报表/库存 → 结果库存 → 绑定工单, 新增 submissionScreenshots / giftEvidenceImages / giftEvidenceNote / giftEvidenceSubmittedAt / giftWorkOrderNo / giftWorkOrderStatus / giftEvidenceReviewStatus(none|pending|accepted|submitted); 赠送凭证图从 imageUrls 与 files[].url|mediumUrl|thumbnailUrl 两处取并去重 - 提报凭证口径统一:有来源提报单用提报单主页截图,客服手工入库现货回退库存自身截图, 前端只看 submissionScreenshots 一个字段,不会拿错 - 前端新增共享组件 ConsignmentEvidenceGallery(缩略图 + 大图预览 + 共 N 张, 预览用 Image.PreviewGroup items,被折叠的图在大图里仍能翻看);审核面板把「凭据截图」 拆成「提报凭证(游戏主页截图)」与「赠送凭证」(带待验收/已验收 Tag + 关联工单 Tooltip), 仓库面板新增「凭证」列并分组展示,两个详情弹窗同口径(含备注/提交时间/工单号/一句话说明) 真实库验证:赠送凭证解析在构造的工单验收资料上取到 3 张图(imageUrls 1 + files 2)并识别 pending_acceptance → 待客服验收;提报凭证在真实数据上取到 1 张。临时数据已清理。
This commit is contained in:
@@ -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<string, unknown> | 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<string, unknown> | 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}
|
||||
|
||||
@@ -70,6 +70,70 @@ function pickScreenshotsJson(payload: JsonObject): unknown[] | undefined {
|
||||
return Array.isArray(value) ? value : undefined
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
const parsed = safeParseJson(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: 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<string, unknown> | 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,
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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')
|
||||
})
|
||||
@@ -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 (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{emptyText}
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleCount = Math.max(1, Math.min(max, list.length))
|
||||
const visible = list.slice(0, visibleCount)
|
||||
const hiddenCount = list.length - visible.length
|
||||
|
||||
return (
|
||||
<Image.PreviewGroup items={list}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6 }}>
|
||||
{visible.map((src, index) => (
|
||||
<div key={`${index}-${src}`} style={{ position: 'relative', lineHeight: 0 }}>
|
||||
<Image
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #e5e7eb' }}
|
||||
/>
|
||||
{hiddenCount > 0 && index === visible.length - 1 ? (
|
||||
<span style={MORE_OVERLAY_STYLE} title={`还有 ${hiddenCount} 张,点击看全部`}>
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {list.length} 张
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/** 「+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)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
客服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<string, string> = {
|
||||
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() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
key: 'evidence',
|
||||
width: 190,
|
||||
render: (_, record) => {
|
||||
const badge = resolveGiftEvidenceBadge(record.giftEvidenceReviewStatus ?? 'none')
|
||||
return (
|
||||
<Space direction="vertical" size={6}>
|
||||
<div>
|
||||
<div style={EVIDENCE_CAPTION_STYLE}>提报凭证</div>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={record.submissionScreenshots}
|
||||
emptyText="无提报凭证"
|
||||
max={2}
|
||||
size={48}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={EVIDENCE_CAPTION_STYLE}>赠送凭证</div>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={record.giftEvidenceImages}
|
||||
emptyText={resolveGiftEvidenceEmptyText(record)}
|
||||
max={2}
|
||||
size={48}
|
||||
/>
|
||||
<Space size={4} wrap style={{ marginTop: 2 }}>
|
||||
<Tag color={badge.color} style={{ marginInlineEnd: 0 }}>
|
||||
{badge.text}
|
||||
</Tag>
|
||||
{record.giftWorkOrderNo ? (
|
||||
<Tooltip
|
||||
title={`关联工单 ${record.giftWorkOrderNo}(工单状态:${formatGiftWorkOrderStatus(
|
||||
record.giftWorkOrderStatus,
|
||||
)})`}
|
||||
>
|
||||
<Tag color="default" style={{ marginInlineEnd: 0, cursor: 'help' }}>
|
||||
关联工单
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '允许匹配',
|
||||
key: 'allowMatching',
|
||||
@@ -596,6 +671,9 @@ export default function ConsignmentInventoryPanel() {
|
||||
},
|
||||
]
|
||||
|
||||
// 详情弹窗里的赠送凭证标签(未选记录时给默认值,Modal 未打开也不会取到 undefined)
|
||||
const viewingGiftBadge = resolveGiftEvidenceBadge(viewingItem?.giftEvidenceReviewStatus ?? 'none')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 统计概览卡片 */}
|
||||
@@ -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() {
|
||||
<Descriptions.Item label="卖家收款信息" span={2}>
|
||||
<Text copyable>{viewingItem.sellerPaymentInfo || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提报凭证" span={2}>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
游戏主页截图(含物品/仓库详情)
|
||||
</Text>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={viewingItem.submissionScreenshots}
|
||||
emptyText="无提报凭证"
|
||||
max={8}
|
||||
size={80}
|
||||
/>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="赠送凭证" span={2}>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
赠送完成截图(客服凭此验收)
|
||||
</Text>
|
||||
<Space size={8} wrap align="center">
|
||||
<ConsignmentEvidenceGallery
|
||||
images={viewingItem.giftEvidenceImages}
|
||||
emptyText={resolveGiftEvidenceEmptyText(viewingItem)}
|
||||
max={8}
|
||||
size={80}
|
||||
/>
|
||||
<Tag color={viewingGiftBadge.color}>{viewingGiftBadge.text}</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{describeGiftEvidence({
|
||||
status: viewingItem.giftEvidenceReviewStatus ?? 'none',
|
||||
images: viewingItem.giftEvidenceImages ?? [],
|
||||
submittedAt: viewingItem.giftEvidenceSubmittedAt,
|
||||
})}
|
||||
</Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="赠送备注" span={2}>
|
||||
{viewingItem.giftEvidenceNote || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="凭证提交时间">
|
||||
{viewingItem.giftEvidenceSubmittedAt
|
||||
? formatDateTime(viewingItem.giftEvidenceSubmittedAt)
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联工单">
|
||||
{viewingItem.giftWorkOrderNo ? (
|
||||
<Space size={4} wrap>
|
||||
<Text copyable>{viewingItem.giftWorkOrderNo}</Text>
|
||||
<Tag color="default">
|
||||
{formatGiftWorkOrderStatus(viewingItem.giftWorkOrderStatus)}
|
||||
</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">未匹配工单</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="登录方式">
|
||||
{(viewingItem.credentialsJson as any)?.loginMethod || '-'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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) => (
|
||||
<ConsignmentEvidenceGallery
|
||||
images={record.submissionScreenshots}
|
||||
emptyText="提报时未上传主页截图"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '赠送凭证',
|
||||
key: 'giftEvidence',
|
||||
width: 170,
|
||||
render: (_, record) => {
|
||||
const screens = Array.isArray(record.screenshotsJson) ? record.screenshotsJson : []
|
||||
if (screens.length === 0) return <Text type="secondary">无截图</Text>
|
||||
const badge = resolveGiftEvidenceBadge(record.giftEvidenceReviewStatus ?? 'none')
|
||||
return (
|
||||
<Image.PreviewGroup>
|
||||
<Image
|
||||
src={screens[0]}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
<Space direction="vertical" size={2}>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={record.giftEvidenceImages}
|
||||
emptyText={resolveGiftEvidenceEmptyText(record)}
|
||||
/>
|
||||
</Image.PreviewGroup>
|
||||
<Space size={4} wrap>
|
||||
<Tag color={badge.color} style={{ marginInlineEnd: 0 }}>
|
||||
{badge.text}
|
||||
</Tag>
|
||||
{record.giftWorkOrderNo ? (
|
||||
<Tooltip
|
||||
title={`关联工单 ${record.giftWorkOrderNo}(工单状态:${formatGiftWorkOrderStatus(
|
||||
record.giftWorkOrderStatus,
|
||||
)})`}
|
||||
>
|
||||
<Tag color="default" style={{ marginInlineEnd: 0, cursor: 'help' }}>
|
||||
关联工单
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
@@ -330,6 +384,11 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
},
|
||||
]
|
||||
|
||||
// 详情弹窗里的赠送凭证标签(未选记录时给默认值,Modal 未打开也不会取到 undefined)
|
||||
const viewingGiftBadge = resolveGiftEvidenceBadge(
|
||||
viewingSubmission?.giftEvidenceReviewStatus ?? 'none',
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Card size="small">
|
||||
@@ -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() {
|
||||
<Descriptions.Item label="卖家收款方式" span={2}>
|
||||
<Text copyable>{viewingSubmission.sellerPaymentInfo || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="凭据截图" span={2}>
|
||||
{Array.isArray(viewingSubmission.screenshotsJson) &&
|
||||
viewingSubmission.screenshotsJson.length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap>
|
||||
{viewingSubmission.screenshotsJson.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={src}
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
<Descriptions.Item label="提报凭证" span={2}>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
游戏主页截图(含物品/仓库详情)
|
||||
</Text>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={viewingSubmission.submissionScreenshots}
|
||||
emptyText="提报时未上传游戏主页截图"
|
||||
max={8}
|
||||
size={80}
|
||||
/>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="赠送凭证" span={2}>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
赠送完成截图(客服凭此验收)
|
||||
</Text>
|
||||
<Space size={8} wrap align="center">
|
||||
<ConsignmentEvidenceGallery
|
||||
images={viewingSubmission.giftEvidenceImages}
|
||||
emptyText={resolveGiftEvidenceEmptyText(viewingSubmission)}
|
||||
max={8}
|
||||
size={80}
|
||||
/>
|
||||
<Tag color={viewingGiftBadge.color}>{viewingGiftBadge.text}</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{describeGiftEvidence({
|
||||
status: viewingSubmission.giftEvidenceReviewStatus ?? 'none',
|
||||
images: viewingSubmission.giftEvidenceImages ?? [],
|
||||
submittedAt: viewingSubmission.giftEvidenceSubmittedAt,
|
||||
})}
|
||||
</Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="赠送备注" span={2}>
|
||||
{viewingSubmission.giftEvidenceNote || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="凭证提交时间">
|
||||
{viewingSubmission.giftEvidenceSubmittedAt
|
||||
? formatDateTime(viewingSubmission.giftEvidenceSubmittedAt)
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联工单">
|
||||
{viewingSubmission.giftWorkOrderNo ? (
|
||||
<Space size={4} wrap>
|
||||
<Text copyable>{viewingSubmission.giftWorkOrderNo}</Text>
|
||||
<Tag color="default">
|
||||
{formatGiftWorkOrderStatus(viewingSubmission.giftWorkOrderStatus)}
|
||||
</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">无截图</Text>
|
||||
<Text type="secondary">未匹配工单</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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: '不是时间' }),
|
||||
'已验收,无截图',
|
||||
)
|
||||
})
|
||||
@@ -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…`,仓库态)、
|
||||
|
||||
Reference in New Issue
Block a user