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:
yml2213
2026-09-17 11:22:59 +08:00
parent 44de11a3a7
commit ca04e09deb
10 changed files with 849 additions and 41 deletions
@@ -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="审核状态">
+34 -2
View File
@@ -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}`
}