From c5d1e2c375503ad8bbf8399a92816076a26f6c61 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 17 Sep 2026 20:46:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=AF=84=E5=94=AE?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E6=88=90=E5=8A=9F=E5=90=8E=E5=90=91=E5=8D=96?= =?UTF-8?q?=E5=AE=B6=E7=AB=AF=E4=B8=8E=E5=AE=A2=E6=9C=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=80=8F=E5=87=BA=E4=B9=B0=E5=AE=B6=E5=8A=A0=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/consignment-repo.ts | 10 +- .../consignment-admin-service.ts | 5 + .../consignment-worker-service.ts | 9 ++ .../work-order-material-mappers.ts | 82 +++++++++++++ .../panels/ConsignmentInventoryPanel.tsx | 46 +++++++ .../pages/worker/WorkerConsignmentPage.tsx | 113 ++++++++++++++++++ apps/frontend/src/services/worker.ts | 6 + apps/frontend/src/styles/worker.css | 46 +++++++ apps/frontend/src/types/consignment.ts | 12 ++ 9 files changed, 327 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 39ca9a4a..a840a6ed 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -41,6 +41,8 @@ export type ConsignmentInventoryRow = { gift_evidence_json?: string | Record | null gift_work_order_no?: string | null gift_work_order_status?: string | null + bound_work_order_material_json?: string | Record | null + bound_work_order_requirement_json?: string | Record | 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 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 07861185..e4c4b8d7 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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 || '', diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 52533dec..c66a8c67 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -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 || ''))) diff --git a/apps/backend/src/services/worker-platform/work-order-material-mappers.ts b/apps/backend/src/services/worker-platform/work-order-material-mappers.ts index dfa9fbe9..2ced6785 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-mappers.ts @@ -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() + 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) } + diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index b7e4bc22..4eff7482 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -1107,6 +1107,52 @@ export default function ConsignmentInventoryPanel() { 未匹配工单 )} + {viewingItem.buyerInfo ? ( + + + + {viewingItem.buyerInfo.gameNickname ? ( + + 买家角色名: + + {viewingItem.buyerInfo.gameNickname} + + + ) : null} + {viewingItem.buyerInfo.gameId ? ( + + 角色数字ID: + + {viewingItem.buyerInfo.gameId} + + + ) : null} + {viewingItem.buyerInfo.serverZone ? ( + {viewingItem.buyerInfo.serverZone} + ) : null} + {viewingItem.buyerInfo.system ? ( + {viewingItem.buyerInfo.system} + ) : null} + + {viewingItem.buyerInfo.screenshots && + viewingItem.buyerInfo.screenshots.length > 0 ? ( +
+ + 买家主页截图(加好友核对用): + +
+ +
+
+ ) : null} +
+
+ ) : null} {(viewingItem.credentialsJson as any)?.loginMethod || '-'} diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 809e1d01..860e3157 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -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 +}) { + 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 ( +
+
+ + + + 买家加好友资料 + + + +
+ +
+ {buyerInfo.gameNickname ? ( +
+ 买家角色名 + + {buyerInfo.gameNickname} + +
+ ) : null} + + {buyerInfo.gameId ? ( +
+ 角色数字ID + + {buyerInfo.gameId} + +
+ ) : null} + + {buyerInfo.serverZone || buyerInfo.system ? ( +
+ 区服与系统 + + {buyerInfo.system ? {buyerInfo.system} : null} + {buyerInfo.serverZone ? {buyerInfo.serverZone} : null} + +
+ ) : null} +
+ + {buyerInfo.screenshots && buyerInfo.screenshots.length > 0 ? ( +
+ + + 买家主页截图(在游戏内添加好友时核对头像/段位/等级,防加错人): + +
+ + + {buyerInfo.screenshots.map((src: string, i: number) => ( + + ))} + + +
+
+ ) : null} +
+ ) +} + /** * 「我的寄卖」记录卡片:一件货一张卡。 * @@ -968,6 +1080,7 @@ function ConsignmentRecordCard({ {record.reviewNote ? ( 审核说明:{record.reviewNote} ) : null} + {record.buyerInfo ? : null} {record.hint ? ( {record.hint} diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index ce462ba9..405c14ae 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -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[] diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 55d1207f..2f39b933 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -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; +} + diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 30972251..ebfc7726 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -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。