feat(consignment): 寄售匹配成功后向卖家端与客服后台透出买家加好友资料

This commit is contained in:
yml2213
2026-09-17 20:46:45 +08:00
parent eff45bb9a8
commit c5d1e2c375
9 changed files with 327 additions and 2 deletions
@@ -41,6 +41,8 @@ export type ConsignmentInventoryRow = {
gift_evidence_json?: string | Record<string, unknown> | null
gift_work_order_no?: string | null
gift_work_order_status?: string | null
bound_work_order_material_json?: string | Record<string, unknown> | null
bound_work_order_requirement_json?: string | Record<string, unknown> | null
worker_id?: string | number | null
worker_name?: string
worker_phone?: string
@@ -194,7 +196,9 @@ export async function listConsignmentInventories(filters: {
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
wo.status AS gift_work_order_status,
wo.material_json AS bound_work_order_material_json,
wo.requirement_json AS bound_work_order_requirement_json
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
@@ -309,7 +313,9 @@ export async function getConsignmentInventoryById(
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
wo.status AS gift_work_order_status,
wo.material_json AS bound_work_order_material_json,
wo.requirement_json AS bound_work_order_requirement_json
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
@@ -29,6 +29,7 @@ import {
releaseConsignmentPublishDepositBySubmissionWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js'
import { extractConsignmentBuyerInfo } from './work-order-material-mappers.js'
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
const code = getConsignmentDepositErrorCode(error)
@@ -192,6 +193,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
/** 唯一锚点:卖家端看到的「寄卖单号」就是它,客服端也用同一个号 */
sourceSubmissionNo: row.source_submission_no || '',
...mapConsignmentEvidence(row),
buyerInfo: extractConsignmentBuyerInfo({
materialJson: row.bound_work_order_material_json,
requirementJson: row.bound_work_order_requirement_json,
}),
workerId: row.worker_id ? Number(row.worker_id) : null,
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
@@ -23,6 +23,7 @@ import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepos
import { confirmAdminWorkOrderFriendAdded } from './admin-work-order-gift-service.js'
import { submitWorkerOrderAcceptance } from './worker-acceptance-service.js'
import type { WorkerSession } from './worker-session-context-service.js'
import { extractConsignmentBuyerInfoFromWorkOrder } from './work-order-material-mappers.js'
export function getConsignmentPublishDepositFen() {
return getConfiguredConsignmentPublishDepositFen()
@@ -276,6 +277,9 @@ export async function listWorkerConsignments(workerId: number) {
const items = [
...submissionsRes.items.map((sub) => {
const inv = inventoryBySubmission.get(Number(sub.id))
const boundWo = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage(String(sub.status || ''), inv)
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
@@ -305,10 +309,14 @@ export async function listWorkerConsignments(workerId: number) {
friendAddedAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}),
...orphanInventories.map((inv) => {
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
const boundWo = inv.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage('approved', inv)
return {
consignmentNo: '客服手工入库',
@@ -336,6 +344,7 @@ export async function listWorkerConsignments(workerId: number) {
friendAddedAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}),
].sort((a, b) => Date.parse(String(b.createdAt || '')) - Date.parse(String(a.createdAt || '')))
@@ -9,6 +9,87 @@ import {
normalizeSubmittedFields,
normalizeUploadedFiles,
} from './worker-input-normalizers.js'
import { normalizeRequirementFields } from './work-order-requirement-fields.js'
export interface ConsignmentBuyerInfo {
gameNickname: string
gameId: string
serverZone?: string
system?: string
fieldItems: Array<{ key: string; label: string; value: string }>
screenshots: string[]
}
export function extractConsignmentBuyerInfo(input: {
materialJson: unknown
requirementJson?: unknown
}): ConsignmentBuyerInfo | null {
const material = safeParseJson(input.materialJson)
const collect = safeParseJson(material.collect)
const fields = normalizeSubmittedFields(collect.fields || material.fields)
const requirementFields = normalizeRequirementFields(safeParseJson(input.requirementJson).fields)
const gameNickname = String(
fields.gameNickname || fields.roleName || fields.nickname || fields.game_nickname || '',
).trim()
const gameId = String(
fields.gameId || fields.characterId || fields.uid || fields.game_id || '',
).trim()
const serverZone = String(fields.serverZone || fields.zone || fields.server_zone || '').trim()
const system = String(fields.system || fields.os || '').trim()
const fieldItems: Array<{ key: string; label: string; value: string }> = []
const usedKeys = new Set<string>()
for (const req of requirementFields) {
usedKeys.add(req.key)
const val = String(fields[req.key] || '').trim()
if (val) {
fieldItems.push({ key: req.key, label: req.label, value: val })
}
}
for (const [k, v] of Object.entries(fields)) {
if (!usedKeys.has(k) && String(v || '').trim()) {
fieldItems.push({ key: k, label: k, value: String(v).trim() })
}
}
const rawScreenshots = Array.isArray(collect.screenshots) ? collect.screenshots : []
const screenshots = rawScreenshots
.map((item) => {
if (typeof item === 'string' && item.trim()) {
return refreshStoredFileUrl(item.trim())
}
if (item && typeof item === 'object') {
const norm = normalizeUploadedFiles([item])[0]
return norm ? refreshUploadedFileUrls(norm)?.url : (item as { url?: string }).url || null
}
return null
})
.filter((url): url is string => typeof url === 'string' && url.length > 0)
if (!gameNickname && !gameId && fieldItems.length === 0 && screenshots.length === 0) {
return null
}
return {
gameNickname,
gameId,
serverZone,
system,
fieldItems,
screenshots,
}
}
export function extractConsignmentBuyerInfoFromWorkOrder(
workOrder: WorkOrderRow | undefined | null,
): ConsignmentBuyerInfo | null {
if (!workOrder) return null
return extractConsignmentBuyerInfo({
materialJson: workOrder.material_json,
requirementJson: workOrder.requirement_json,
})
}
export function mapAcceptanceForResponse(value: unknown) {
const acceptance = safeParseJson(value)
@@ -29,3 +110,4 @@ export function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) {
: {}
return normalizeSubmittedFields(collect.fields)
}
@@ -1107,6 +1107,52 @@ export default function ConsignmentInventoryPanel() {
<Text type="secondary">未匹配工单</Text>
)}
</Descriptions.Item>
{viewingItem.buyerInfo ? (
<Descriptions.Item label="匹配买家资料" span={2}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space size={16} wrap>
{viewingItem.buyerInfo.gameNickname ? (
<span>
买家角色名:
<Text strong copyable>
{viewingItem.buyerInfo.gameNickname}
</Text>
</span>
) : null}
{viewingItem.buyerInfo.gameId ? (
<span>
角色数字ID:
<Text strong copyable>
{viewingItem.buyerInfo.gameId}
</Text>
</span>
) : null}
{viewingItem.buyerInfo.serverZone ? (
<Tag color="blue">{viewingItem.buyerInfo.serverZone}</Tag>
) : null}
{viewingItem.buyerInfo.system ? (
<Tag color="cyan">{viewingItem.buyerInfo.system}</Tag>
) : null}
</Space>
{viewingItem.buyerInfo.screenshots &&
viewingItem.buyerInfo.screenshots.length > 0 ? (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
买家主页截图(加好友核对用):
</Text>
<div style={{ marginTop: 4 }}>
<ConsignmentEvidenceGallery
images={viewingItem.buyerInfo.screenshots}
emptyText="无买家截图"
max={6}
size={70}
/>
</div>
</div>
) : null}
</Space>
</Descriptions.Item>
) : null}
<Descriptions.Item label="登录方式">
{(viewingItem.credentialsJson as any)?.loginMethod || '-'}
</Descriptions.Item>
@@ -2,12 +2,15 @@ import {
CheckCircleOutlined,
ClockCircleOutlined,
CloudUploadOutlined,
CopyOutlined,
DollarCircleOutlined,
HistoryOutlined,
InboxOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
@@ -912,6 +915,115 @@ function SpecTags({
)
}
function ConsignmentBuyerInfoBlock({
buyerInfo,
}: {
buyerInfo: NonNullable<WorkerConsignmentRecord['buyerInfo']>
}) {
const { message } = App.useApp()
const copyText = (text: string, label: string) => {
if (!text) return
navigator.clipboard
.writeText(text)
.then(() => message.success(`已复制${label}:${text}`))
.catch(() => message.error('复制失败,请手动长按复制'))
}
const copyAll = () => {
const parts: string[] = []
if (buyerInfo.gameNickname) parts.push(`角色名: ${buyerInfo.gameNickname}`)
if (buyerInfo.gameId) parts.push(`游戏ID: ${buyerInfo.gameId}`)
if (buyerInfo.serverZone) parts.push(`区服: ${buyerInfo.serverZone}`)
if (buyerInfo.system) parts.push(`系统: ${buyerInfo.system}`)
copyText(parts.join(' | '), '买家加好友资料')
}
return (
<div className="worker-consignment-buyer-box">
<div className="worker-consignment-buyer-header">
<Space size={6}>
<UserOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong style={{ color: '#1677ff', fontSize: 13 }}>
买家加好友资料
</Typography.Text>
</Space>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
onClick={copyAll}
>
一键复制资料
</Button>
</div>
<div className="worker-consignment-buyer-grid">
{buyerInfo.gameNickname ? (
<div className="buyer-field-cell">
<span className="buyer-field-label">买家角色名</span>
<Typography.Text
strong
copyable={{ text: buyerInfo.gameNickname }}
className="buyer-field-highlight"
>
{buyerInfo.gameNickname}
</Typography.Text>
</div>
) : null}
{buyerInfo.gameId ? (
<div className="buyer-field-cell">
<span className="buyer-field-label">角色数字ID</span>
<Typography.Text
strong
copyable={{ text: buyerInfo.gameId }}
className="buyer-field-highlight"
>
{buyerInfo.gameId}
</Typography.Text>
</div>
) : null}
{buyerInfo.serverZone || buyerInfo.system ? (
<div className="buyer-field-cell">
<span className="buyer-field-label">区服与系统</span>
<Space size={4} style={{ marginTop: 2 }}>
{buyerInfo.system ? <Tag color="blue">{buyerInfo.system}</Tag> : null}
{buyerInfo.serverZone ? <Tag color="cyan">{buyerInfo.serverZone}</Tag> : null}
</Space>
</div>
) : null}
</div>
{buyerInfo.screenshots && buyerInfo.screenshots.length > 0 ? (
<div className="worker-consignment-buyer-shots-wrap">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<PictureOutlined style={{ marginRight: 4 }} />
买家主页截图(在游戏内添加好友时核对头像/段位/等级,防加错人):
</Typography.Text>
<div style={{ marginTop: 6 }}>
<Image.PreviewGroup>
<Space size={8} wrap>
{buyerInfo.screenshots.map((src: string, i: number) => (
<Image
key={`buyer-shot-${i}`}
src={src}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #bfdbfe' }}
/>
))}
</Space>
</Image.PreviewGroup>
</div>
</div>
) : null}
</div>
)
}
/**
* 「我的寄卖」记录卡片:一件货一张卡。
*
@@ -968,6 +1080,7 @@ function ConsignmentRecordCard({
{record.reviewNote ? (
<Typography.Text type="danger">审核说明:{record.reviewNote}</Typography.Text>
) : null}
{record.buyerInfo ? <ConsignmentBuyerInfoBlock buyerInfo={record.buyerInfo} /> : null}
{record.hint ? (
<Typography.Text type="secondary" className="worker-consignment-record-hint">
{record.hint}
+6
View File
@@ -23,6 +23,8 @@ import type {
WorkerUser,
WorkerWalletLedger,
} from '@/types/worker-platform'
import type { ConsignmentBuyerInfo } from '@/types/consignment'
import { getWorkerDeviceId } from '@/utils/worker-auth'
export function sendWorkerSmsCode(payload: { phone: string }) {
@@ -445,8 +447,12 @@ export type WorkerConsignmentRecord = {
/** 赠送冷却截止时间,前端拿它做倒计时 */
giftAvailableAt: string | null
friendAddedAt: string | null
/** 匹配工单的买家资料(用于加好友) */
buyerInfo?: ConsignmentBuyerInfo | null
}
export type { ConsignmentBuyerInfo } from '@/types/consignment'
export type WorkerConsignmentListResponse = {
publishDepositFen: number
items: WorkerConsignmentRecord[]
+46
View File
@@ -4466,3 +4466,49 @@ body {
width: 100%;
}
}
.worker-consignment-buyer-box {
margin-top: 4px;
margin-bottom: 4px;
padding: 10px 12px;
border-radius: 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
}
.worker-consignment-buyer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.worker-consignment-buyer-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
margin-bottom: 6px;
}
.buyer-field-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.buyer-field-label {
font-size: 11px;
color: #64748b;
}
.buyer-field-highlight {
font-size: 14px;
color: #1e293b;
}
.worker-consignment-buyer-shots-wrap {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #cbd5e1;
}
+12
View File
@@ -72,8 +72,20 @@ export type ConsignmentInventory = ConsignmentEvidenceFields & {
consumedAt: string | null
createdAt: string
updatedAt: string
/** 关联履约工单的买家资料(加好友及主页截图) */
buyerInfo?: ConsignmentBuyerInfo | null
}
export type ConsignmentBuyerInfo = {
gameNickname: string
gameId: string
serverZone?: string
system?: string
fieldItems: Array<{ key: string; label: string; value: string }>
screenshots: string[]
}
/**
* 工单 `material.consignment` 里的寄售履约信息(履约/指派时后端写入)。
* 历史老单没有 sourceSubmissionNo,展示时必须回退到 inventoryNo。