From 4a6215e816818e9f3319c61670303e78ddf64d28 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 15 Sep 2026 16:00:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E8=8B=8F=E5=B0=94?= =?UTF-8?q?=E5=8D=97=E5=AF=84=E5=8D=96=E6=8E=A5=E5=85=A5=E6=89=93=E6=89=8B?= =?UTF-8?q?=E7=AB=AF=E6=8A=BC=E9=87=91=E5=B1=A5=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打手可直接提报寄卖,关联 worker 与履约押金后指派交枪;后台去掉旧收集链接管理,提报审核与库存/工单流程对齐接单平台。 Co-authored-by: Cursor --- .../078_consignment_worker_deposit.sql | 25 + .../worker-platform/consignment-repo.ts | 102 ++- .../admin/worker-platform/consignment.ts | 20 + apps/backend/src/routes/worker.ts | 3 + apps/backend/src/routes/worker/consignment.ts | 40 + .../consignment-admin-service.ts | 72 +- .../consignment-fulfillment-service.ts | 119 +++ .../consignment-matching-service.test.ts | 6 + .../consignment-matching-service.ts | 8 +- .../consignment-public-service.ts | 105 ++- .../consignment-worker-service.ts | 166 ++++ apps/frontend/src/App.tsx | 2 + apps/frontend/src/layouts/WorkerLayout.tsx | 5 + .../pages/admin/AdminWorkerPlatformPage.tsx | 4 +- .../panels/ConsignmentInventoryPanel.tsx | 171 +++- .../panels/ConsignmentSubmissionsPanel.tsx | 656 ++++------------ .../pages/admin/panels/WorkOrdersPanel.tsx | 55 +- .../pages/collect/ConsignmentCollectPage.tsx | 396 ++++++---- .../pages/worker/WorkerConsignmentPage.tsx | 739 ++++++++++++++++++ .../src/services/admin/consignment.ts | 11 + apps/frontend/src/services/worker.ts | 64 ++ apps/frontend/src/styles/worker.css | 118 +++ apps/frontend/src/types/consignment.ts | 8 + 23 files changed, 2218 insertions(+), 677 deletions(-) create mode 100644 apps/backend/src/db/migrations/078_consignment_worker_deposit.sql create mode 100644 apps/backend/src/routes/worker/consignment.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-worker-service.ts create mode 100644 apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx diff --git a/apps/backend/src/db/migrations/078_consignment_worker_deposit.sql b/apps/backend/src/db/migrations/078_consignment_worker_deposit.sql new file mode 100644 index 00000000..823ae7fc --- /dev/null +++ b/apps/backend/src/db/migrations/078_consignment_worker_deposit.sql @@ -0,0 +1,25 @@ +-- 078_consignment_worker_deposit.sql —— 寄卖库存支持关联接单平台打手账号与押金履约模式 + +ALTER TABLE consignment_inventories + ADD COLUMN IF NOT EXISTS worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS worker_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS worker_phone TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS deposit_amount_fen INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE consignment_submissions + ADD COLUMN IF NOT EXISTS worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS worker_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS worker_phone TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS deposit_amount_fen INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_worker_id + ON consignment_inventories(worker_id) + WHERE worker_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_consignment_submissions_worker_id + ON consignment_submissions(worker_id) + WHERE worker_id IS NOT NULL; + +COMMENT ON COLUMN consignment_inventories.worker_id IS '关联接单平台的打手ID(用于押金冻结与履约指派)'; +COMMENT ON COLUMN consignment_inventories.deposit_amount_fen IS '该寄卖手枪建议/约定的履约押金金额(分)'; +COMMENT ON COLUMN consignment_submissions.worker_id IS '提交人或关联的接单平台打手ID'; diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index c9e5b193..cbcd13cc 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -32,6 +32,10 @@ export type ConsignmentInventoryRow = { shop_name: string source_type: 'manual' | 'collection_link' | 'recycle' source_submission_id: string | number | null + worker_id?: string | number | null + worker_name?: string + worker_phone?: string + deposit_amount_fen?: number inbound_at: string consumed_at: string | null created_at: string @@ -83,6 +87,10 @@ export type ConsignmentSubmissionRow = { content_json: string | Record screenshots_json: string | unknown[] asking_price_fen: number + worker_id?: string | number | null + worker_name?: string + worker_phone?: string + deposit_amount_fen?: number status: 'pending_review' | 'approved' | 'rejected' review_note: string reviewer_name: string @@ -121,6 +129,7 @@ export async function listConsignmentInventories(filters: { series?: string | undefined diamondLevel?: number | undefined allowMatching?: boolean | undefined + workerId?: number | undefined keyword?: string | undefined page?: number | undefined pageSize?: number | undefined @@ -132,6 +141,10 @@ export async function listConsignmentInventories(filters: { values.push(filters.status) conditions.push(`ci.status = $${values.length}`) } + if (filters.workerId) { + values.push(filters.workerId) + conditions.push(`ci.worker_id = $${values.length}`) + } if (filters.buyerName) { values.push(filters.buyerName) conditions.push(`ci.buyer_name = $${values.length}`) @@ -329,6 +342,10 @@ export async function createConsignmentInventory(input: { allowMatching?: boolean sourceType?: 'manual' | 'collection_link' | 'recycle' sourceSubmissionId?: number | null + workerId?: number | null + workerName?: string + workerPhone?: string + depositAmountFen?: number now: string }): Promise { const sql = ` @@ -338,14 +355,16 @@ export async function createConsignmentInventory(input: { inbound_batch, seller_contact, seller_game_id, seller_payment_info, seller_phone, credentials_json, screenshots_json, remark, allow_matching, status, allocation_status, source_type, - source_submission_id, inbound_at, created_at, updated_at + source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen, + inbound_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb, $18::jsonb, $19, $20, 'in_stock', '待安排', $21, - $22, $23, $23, $23 + $22, $23, $24, $25, $26, + $27, $27, $27 ) RETURNING * ` @@ -372,6 +391,10 @@ export async function createConsignmentInventory(input: { input.allowMatching !== false, input.sourceType || 'manual', input.sourceSubmissionId || null, + input.workerId || null, + input.workerName || '', + input.workerPhone || '', + input.depositAmountFen || 0, input.now, ] const res = await query(sql, values) @@ -400,6 +423,10 @@ export async function updateConsignmentInventory( allowMatching?: boolean | undefined status?: 'in_stock' | 'consumed' | 'archived' | undefined allocationStatus?: string | undefined + workerId?: number | null | undefined + workerName?: string | undefined + workerPhone?: string | undefined + depositAmountFen?: number | undefined now: string }, ): Promise { @@ -482,6 +509,22 @@ export async function updateConsignmentInventory( values.push(input.allocationStatus) updates.push(`allocation_status = $${values.length}`) } + if (input.workerId !== undefined) { + values.push(input.workerId) + updates.push(`worker_id = $${values.length}`) + } + if (input.workerName !== undefined) { + values.push(input.workerName) + updates.push(`worker_name = $${values.length}`) + } + if (input.workerPhone !== undefined) { + values.push(input.workerPhone) + updates.push(`worker_phone = $${values.length}`) + } + if (input.depositAmountFen !== undefined) { + values.push(input.depositAmountFen) + updates.push(`deposit_amount_fen = $${values.length}`) + } if (updates.length === 0) return getConsignmentInventoryById(id) @@ -681,6 +724,7 @@ export async function updateConsignmentCollectLink( export async function listConsignmentSubmissions(filters: { status?: string | undefined linkId?: number | undefined + workerId?: number | undefined keyword?: string | undefined page?: number | undefined pageSize?: number | undefined @@ -696,6 +740,10 @@ export async function listConsignmentSubmissions(filters: { values.push(filters.linkId) conditions.push(`cs.link_id = $${values.length}`) } + if (filters.workerId) { + values.push(filters.workerId) + conditions.push(`cs.worker_id = $${values.length}`) + } if (filters.keyword) { values.push(`%${filters.keyword.trim()}%`) conditions.push( @@ -765,6 +813,10 @@ export async function createConsignmentSubmission(input: { contentJson?: Record screenshotsJson?: unknown[] askingPriceFen?: number + workerId?: number | null + workerName?: string + workerPhone?: string + depositAmountFen?: number now: string }): Promise { const sql = ` @@ -773,13 +825,15 @@ export async function createConsignmentSubmission(input: { system, server_zone, series, diamond_level, brilliance_count, seller_game_id, seller_payment_info, seller_phone, contact_info, content_json, screenshots_json, asking_price_fen, + worker_id, worker_name, worker_phone, deposit_amount_fen, status, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, $16::jsonb, $17, - 'pending_review', $18, $18 + $18, $19, $20, $21, + 'pending_review', $22, $22 ) RETURNING * ` @@ -801,6 +855,10 @@ export async function createConsignmentSubmission(input: { toJsonString(input.contentJson || {}), toJsonString(input.screenshotsJson || []), input.askingPriceFen || 0, + input.workerId || null, + input.workerName || '', + input.workerPhone || '', + input.depositAmountFen || 0, input.now, ]) return res.rows[0]! @@ -852,6 +910,10 @@ export async function approveConsignmentSubmissionWithInbound(input: { credentialsJson?: Record | undefined screenshotsJson?: unknown[] | undefined reviewNote?: string | undefined + workerId?: number | null | undefined + workerName?: string | undefined + workerPhone?: string | undefined + depositAmountFen?: number | undefined now: string }): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> { return withTransaction(async (client: PoolClient) => { @@ -863,6 +925,16 @@ export async function approveConsignmentSubmissionWithInbound(input: { if (!submission) throw new Error('提报单不存在') if (submission.status !== 'pending_review') throw new Error('提报单已被处理') + const workerId = input.workerId !== undefined ? input.workerId : submission.worker_id || null + const workerName = + input.workerName !== undefined ? input.workerName : submission.worker_name || '' + const workerPhone = + input.workerPhone !== undefined ? input.workerPhone : submission.worker_phone || '' + const depositAmountFen = + input.depositAmountFen !== undefined + ? input.depositAmountFen + : submission.deposit_amount_fen || 0 + const invSql = ` INSERT INTO consignment_inventories ( inventory_no, game_name, category_id, item_name, system, server_zone, @@ -870,14 +942,16 @@ export async function approveConsignmentSubmissionWithInbound(input: { inbound_batch, seller_contact, seller_game_id, seller_payment_info, seller_phone, credentials_json, screenshots_json, remark, allow_matching, status, allocation_status, source_type, - source_submission_id, inbound_at, created_at, updated_at + source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen, + inbound_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb, $18::jsonb, $19, TRUE, 'in_stock', '待安排', 'collection_link', - $20, $21, $21, $21 + $20, $21, $22, $23, $24, + $25, $25, $25 ) RETURNING * ` @@ -902,6 +976,10 @@ export async function approveConsignmentSubmissionWithInbound(input: { toJsonString(input.screenshotsJson || submission.screenshots_json || []), input.reviewNote || '', submission.id, + workerId, + workerName, + workerPhone, + depositAmountFen, input.now, ] const invRes = await client.query(invSql, invValues) @@ -914,15 +992,23 @@ export async function approveConsignmentSubmissionWithInbound(input: { result_inventory_id = $1, reviewer_name = $2, review_note = $3, - reviewed_at = $4, - updated_at = $4 - WHERE id = $5 + worker_id = COALESCE($4, worker_id), + worker_name = COALESCE($5, worker_name), + worker_phone = COALESCE($6, worker_phone), + deposit_amount_fen = COALESCE($7, deposit_amount_fen), + reviewed_at = $8, + updated_at = $8 + WHERE id = $9 RETURNING * ` const updateSubRes = await client.query(updateSubSql, [ inventory.id, input.buyerName, input.reviewNote || '', + workerId, + workerName, + workerPhone, + depositAmountFen, input.now, submission.id, ]) diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 5eec7be8..ebd2706a 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -17,6 +17,7 @@ import { updateAdminConsignmentLink, } from '../../../services/worker-platform/consignment-admin-service.js' import { + assignWorkOrderWithConsignmentWorker, checkWorkOrderInventoryMatches, fulfillWorkOrderWithConsignment, revokeWorkOrderConsignmentFulfillment, @@ -253,6 +254,25 @@ router.post( ), ) +router.post( + '/worker-platform/consignment/assign-worker', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + assignWorkOrderWithConsignmentWorker({ + workOrderId: String(req.body?.workOrderId || ''), + inventoryId: String(req.body?.inventoryId || ''), + workerId: req.body?.workerId ? Number(req.body.workerId) : null, + actorName: req.adminSession?.username || 'admin', + }), + { + successMessage: '已指派打手,押金已冻结,工单流转进行中!', + errorMessage: '指派打手履约失败', + scope: '[admin/consignment/assign-worker]', + }, + ), +) + router.post( '/worker-platform/consignment/revoke', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index e49c0ac8..538a612d 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -5,6 +5,7 @@ import workerAuthSessionRoutes from './worker/auth-session.js' import workerProfileRoutes from './worker/profile.js' import workerHallRoutes from './worker/hall.js' import workerOrdersRoutes from './worker/orders.js' +import workerConsignmentRoutes from './worker/consignment.js' import { requireWorkerSession } from './worker/session.js' import { buildNotFoundPayload } from '../utils/http.js' @@ -22,6 +23,8 @@ router.use(workerHallRoutes) router.use(workerOrdersRoutes) +router.use(workerConsignmentRoutes) + router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) }) diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts new file mode 100644 index 00000000..a2292af4 --- /dev/null +++ b/apps/backend/src/routes/worker/consignment.ts @@ -0,0 +1,40 @@ +import { Router } from 'express' + +import { + listWorkerConsignments, + publishWorkerConsignment, +} from '../../services/worker-platform/consignment-worker-service.js' +import { sendRouteError } from '../../utils/http.js' +import { getRequiredWorkerSession } from './session.js' + +const router = Router() + +/** + * POST /api/v1/worker/consignment/publish + * 打手在接单平台直接提报苏尔南手枪寄卖 + */ +router.post('/consignment/publish', async (req, res) => { + try { + const session = getRequiredWorkerSession(req) + const result = await publishWorkerConsignment(session.workerId, req.body || {}) + res.json({ ok: true, data: result }) + } catch (error) { + sendRouteError(res, error, '提交寄卖失败', '[worker/consignment/publish]') + } +}) + +/** + * GET /api/v1/worker/consignment/my-items + * 打手查看自己在接单平台寄卖的手枪列表与状态 + */ +router.get('/consignment/my-items', async (req, res) => { + try { + const session = getRequiredWorkerSession(req) + const result = await listWorkerConsignments(session.workerId) + res.json({ ok: true, data: result }) + } catch (error) { + sendRouteError(res, error, '获取我的寄卖列表失败', '[worker/consignment/my-items]') + } +}) + +export default router 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 c8682bd1..1bd49269 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -16,6 +16,10 @@ import { updateConsignmentCollectLink, updateConsignmentInventory, } from '../../repositories/worker-platform/consignment-repo.js' +import { + getWorkerUserById, + getWorkerUserByPhone, +} from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js' @@ -60,6 +64,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { boundPlatformOrderId: row.bound_platform_order_id || '', sourceType: row.source_type, sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null, + workerId: row.worker_id ? Number(row.worker_id) : null, + workerName: row.worker_name || '', + workerPhone: row.worker_phone || '', + depositAmountFen: Number(row.deposit_amount_fen || 0), inboundAt: row.inbound_at || row.created_at, consumedAt: row.consumed_at, createdAt: row.created_at, @@ -126,6 +134,10 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { ? (safeParseJson(row.screenshots_json) as unknown[]) : [], askingPriceFen: Number(row.asking_price_fen || 0), + workerId: row.worker_id ? Number(row.worker_id) : null, + workerName: row.worker_name || '', + workerPhone: row.worker_phone || '', + depositAmountFen: Number(row.deposit_amount_fen || 0), status: row.status, reviewNote: row.review_note || '', reviewerName: row.reviewer_name || '', @@ -204,8 +216,8 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor system: String(payload.system || '').trim(), serverZone: String(payload.serverZone || '').trim(), series: String(payload.series || '').trim(), - diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))), - brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))), + diamondLevel: Math.max(1, Math.min(7, Number(payload.diamondLevel || 1))), + brillianceCount: Math.max(0, Math.min(7, Number(payload.brillianceCount || 0))), costPriceFen, buyerName: String(payload.buyerName || actorName || '').trim(), inboundBatch: String(payload.inboundBatch || '').trim(), @@ -221,6 +233,10 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor remark: String(payload.remark || '').trim(), allowMatching: payload.allowMatching !== false, sourceType: 'manual', + workerId: payload.workerId ? Number(payload.workerId) : null, + workerName: String(payload.workerName || '').trim(), + workerPhone: String(payload.workerPhone || '').trim(), + depositAmountFen: payload.depositAmountFen ? Number(payload.depositAmountFen) : 0, now, }) @@ -238,9 +254,14 @@ export async function updateAdminConsignmentInventory( system: payload.system !== undefined ? String(payload.system).trim() : undefined, serverZone: payload.serverZone !== undefined ? String(payload.serverZone).trim() : undefined, series: payload.series !== undefined ? String(payload.series).trim() : undefined, - diamondLevel: payload.diamondLevel !== undefined ? Number(payload.diamondLevel) : undefined, + diamondLevel: + payload.diamondLevel !== undefined + ? Math.max(1, Math.min(7, Number(payload.diamondLevel))) + : undefined, brillianceCount: - payload.brillianceCount !== undefined ? Number(payload.brillianceCount) : undefined, + payload.brillianceCount !== undefined + ? Math.max(0, Math.min(7, Number(payload.brillianceCount))) + : undefined, costPriceFen: payload.costPriceFen !== undefined ? Math.max(0, Math.round(Number(payload.costPriceFen))) @@ -265,6 +286,16 @@ export async function updateAdminConsignmentInventory( remark: payload.remark !== undefined ? String(payload.remark).trim() : undefined, allowMatching: payload.allowMatching !== undefined ? Boolean(payload.allowMatching) : undefined, status: payload.status !== undefined ? (payload.status as any) : undefined, + workerId: + payload.workerId !== undefined + ? payload.workerId + ? Number(payload.workerId) + : null + : undefined, + workerName: payload.workerName !== undefined ? String(payload.workerName).trim() : undefined, + workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined, + depositAmountFen: + payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined, now, }) @@ -428,6 +459,29 @@ export async function approveAdminConsignmentSubmission( Math.round(Number(payload.costPriceFen ?? submission.asking_price_fen ?? 0)), ) + let workerId = payload.workerId + ? Number(payload.workerId) + : submission.worker_id + ? Number(submission.worker_id) + : null + let workerName = String(payload.workerName || submission.worker_name || '').trim() + let workerPhone = String(payload.workerPhone || submission.worker_phone || '').trim() + + const phoneToLookup = String(payload.sellerPhone || submission.seller_phone || '').trim() + if (!workerId && phoneToLookup) { + const matchedWorker = await getWorkerUserByPhone(phoneToLookup) + if (matchedWorker) { + workerId = Number(matchedWorker.id) + workerName = matchedWorker.display_name || matchedWorker.username || '' + workerPhone = matchedWorker.phone || phoneToLookup + } + } + + const depositAmountFen = + payload.depositAmountFen !== undefined + ? Number(payload.depositAmountFen) + : Number(submission.deposit_amount_fen || 0) + const res = await approveConsignmentSubmissionWithInbound({ submissionId: id, inventoryNo, @@ -439,23 +493,27 @@ export async function approveAdminConsignmentSubmission( series: String(payload.series || submission.series).trim(), diamondLevel: Math.max( 1, - Math.min(6, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)), + Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)), ), brillianceCount: Math.max( 0, - Math.min(6, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)), + Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)), ), costPriceFen, sellerContact: String(payload.sellerContact || submission.contact_info).trim(), sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(), sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(), - sellerPhone: String(payload.sellerPhone || submission.seller_phone).trim(), + sellerPhone: phoneToLookup, credentialsJson: payload.credentials && typeof payload.credentials === 'object' ? (payload.credentials as Record) : undefined, screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined, reviewNote: String(payload.reviewNote || '').trim(), + workerId, + workerName, + workerPhone, + depositAmountFen, now, }) diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 1b41df37..bf61ebb6 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -5,16 +5,19 @@ import { type ConsignmentInventoryRow, getConsignmentInventoryById, listActiveConsignmentInventoriesForMatching, + updateConsignmentInventory, } from '../../repositories/worker-platform/consignment-repo.js' import { createWorkOrderEvent, getWorkOrderById, + updateWorkOrder, type WorkOrderRow, } from '../../repositories/worker-platform/index.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-service.js' import { mapWorkOrderAdmin } from './mappers.js' import { consumeIndustryVouchersBeforeWorkOrderPublish, @@ -246,6 +249,122 @@ export async function fulfillWorkOrderWithConsignment(input: { } } +/** + * 选用内部寄售库存并走接单平台指派打手(冻结押金担保履约) + */ +export async function assignWorkOrderWithConsignmentWorker(input: { + workOrderId: number | string + inventoryId: number | string + workerId?: number | string | null + actorName: string +}): Promise<{ + order: ReturnType + inventory: ReturnType + voucherConsume: WorkOrderVoucherConsumeResult +}> { + const workOrder = await getWorkOrderById(input.workOrderId) + if (!workOrder) { + throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) + } + + const inventory = await getConsignmentInventoryById(input.inventoryId) + if (!inventory) { + throw createHttpError('寄卖物品不存在', { + statusCode: 404, + errorCode: 'consignment_inventory_not_found', + }) + } + + if (inventory.status !== 'in_stock' || !inventory.allow_matching) { + throw createHttpError('该寄卖物品不可用或已被暂停匹配', { + statusCode: 409, + errorCode: 'consignment_inventory_unavailable', + }) + } + + const targetWorkerId = input.workerId || inventory.worker_id + if (!targetWorkerId) { + throw createHttpError('该寄卖物品未关联打手,请在指派弹窗中选择打手账号', { + statusCode: 400, + errorCode: 'consignment_worker_required', + }) + } + + // 1. 调用接单平台统一指派流程(进行打手可用性校验、余额与押金冻结、电子凭证核销、工单状态流转到 in_progress) + const assignResult = await assignAdminWorkOrderToWorker( + workOrder.id, + { workerId: targetWorkerId }, + input.actorName, + ) + + const now = nowIso() + const material = safeParseJson(workOrder.material_json) + + // 2. 将寄售手枪信息写入工单 material + const updatedMaterial = { + ...material, + consignment: { + inventoryId: Number(inventory.id), + inventoryNo: inventory.inventory_no, + itemName: inventory.item_name, + system: inventory.system, + serverZone: inventory.server_zone, + series: inventory.series, + diamondLevel: inventory.diamond_level, + brillianceCount: inventory.brilliance_count, + costPriceFen: inventory.cost_price_fen, + workerId: Number(targetWorkerId), + workerName: inventory.worker_name || '', + fulfillmentType: 'worker_deposit', + assignedAt: now, + assignedBy: input.actorName, + }, + } + + const rewardAmountYuan = + Number(inventory.cost_price_fen || 0) > 0 + ? Number((Number(inventory.cost_price_fen) / 100).toFixed(2)) + : Number(workOrder.reward_amount || 0) + + await updateWorkOrder(workOrder.id, { + material_json: updatedMaterial, + reward_amount: rewardAmountYuan, + updated_at: now, + }) + + // 3. 将寄卖库存置为 consumed 已出库/已安排打手 + const updatedInventory = await updateConsignmentInventory(inventory.id, { + status: 'consumed', + allocationStatus: '已指派打手(押金履约中)', + workerId: Number(targetWorkerId), + now, + }) + + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: input.actorName, + eventType: 'consignment_worker_assigned', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.IN_PROGRESS, + payloadJson: JSON.stringify({ + inventoryId: inventory.id, + inventoryNo: inventory.inventory_no, + workerId: targetWorkerId, + costPriceFen: inventory.cost_price_fen, + }), + now, + }) + + const refreshedOrder = await getWorkOrderById(workOrder.id) + + return { + order: mapWorkOrderAdmin(refreshedOrder || (assignResult.order as any)), + inventory: mapConsignmentInventory(updatedInventory!), + voucherConsume: assignResult.voucherConsume, + } +} + /** * 撤销寄售履约(用于选错重选、买家退款或物品异常) */ diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts index ad1a898c..7ebf7262 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts @@ -61,6 +61,12 @@ test('extractPeacekeeperSpecs extracts specs accurately', () => { assert.equal(spec2.series, '怒嚎') assert.equal(spec2.diamondLevel, 2) assert.equal(spec2.brillianceCount, 1) + + const spec3 = extractPeacekeeperSpecs('安卓区 血宴 7钻 7璀璨') + assert.equal(spec3.system, '安卓') + assert.equal(spec3.series, '血宴') + assert.equal(spec3.diamondLevel, 7) + assert.equal(spec3.brillianceCount, 7) }) test('matchPeacekeeperOrderWithInventory matches exact spec', () => { diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index f535e5f1..12c53c7f 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -62,16 +62,16 @@ export function extractPeacekeeperSpecs( } } - // 4. 钻级识别 (1~6钻) + // 4. 钻级识别 (1~7钻) let diamondLevel = extraMeta?.diamondLevel || 1 - const diamondMatch = text.match(/([1-6])\s*钻/) + const diamondMatch = text.match(/([1-7])\s*钻/) if (diamondMatch?.[1]) { diamondLevel = Number(diamondMatch[1]) } - // 5. 璀璨数量识别 (0~6璀璨) + // 5. 璀璨数量识别 (0~7璀璨) let brillianceCount = 0 - const brillianceMatch = text.match(/([1-6])\s*璀璨/) + const brillianceMatch = text.match(/([1-7])\s*璀璨/) if (brillianceMatch?.[1]) { brillianceCount = Number(brillianceMatch[1]) } else if (text.includes('璀璨')) { diff --git a/apps/backend/src/services/worker-platform/consignment-public-service.ts b/apps/backend/src/services/worker-platform/consignment-public-service.ts index 535dbe52..268375a0 100644 --- a/apps/backend/src/services/worker-platform/consignment-public-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-public-service.ts @@ -2,6 +2,7 @@ import { createConsignmentSubmission, getConsignmentCollectLinkByToken, } from '../../repositories/worker-platform/consignment-repo.js' +import { getWorkerUserByPhone } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { generateConsignmentSubmissionNo } from '../../utils/random.js' @@ -65,19 +66,89 @@ export async function submitPublicConsignment(token: string, payload: JsonObject ).trim() const contactInfo = String(payload.contactInfo || payload.sellerContact || '').trim() if (!contactInfo) { - throw createHttpError('请填写联系方式(微信号或手机号)', { + throw createHttpError('请填写微信号或QQ号以便联系与核实', { statusCode: 400, errorCode: 'contact_info_required', }) } + const contentPayload = + payload.contentJson && typeof payload.contentJson === 'object' + ? (payload.contentJson as Record) + : payload.content && typeof payload.content === 'object' + ? (payload.content as Record) + : {} + + const characterName = String( + contentPayload.characterName || payload.characterName || '', + ).trim() + const characterId = String(contentPayload.characterId || payload.characterId || '').trim() + if (!characterName) { + throw createHttpError('请填写游戏内角色名', { + statusCode: 400, + errorCode: 'character_name_required', + }) + } + if (!characterId) { + throw createHttpError('请填写角色数字ID', { + statusCode: 400, + errorCode: 'character_id_required', + }) + } + + const screenshots = Array.isArray(payload.screenshotsJson) + ? payload.screenshotsJson + : Array.isArray(payload.screenshots) + ? payload.screenshots + : [] + if (screenshots.length === 0) { + throw createHttpError('请上传游戏主页截图以供核验', { + statusCode: 400, + errorCode: 'screenshot_required', + }) + } + const now = nowIso() const submissionNo = generateConsignmentSubmissionNo() const askingPriceFen = Math.max( - 0, + 100, Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)), ) + const sellerPhone = String(payload.sellerPhone || '').trim() + if (!sellerPhone) { + throw createHttpError('请填写联系电话', { + statusCode: 400, + errorCode: 'seller_phone_required', + }) + } + + let workerId: number | null = null + let workerName = '' + let workerPhone = '' + + if (sellerPhone) { + const matchedWorker = await getWorkerUserByPhone(sellerPhone) + if (matchedWorker) { + workerId = Number(matchedWorker.id) + workerName = matchedWorker.display_name || matchedWorker.username || '' + workerPhone = matchedWorker.phone || sellerPhone + } + } + + const diamondLevel = Math.max( + 1, + Math.min(7, Number(payload.diamondLevel || link.diamond_level || 1)), + ) + const brillianceCount = Math.max( + 0, + Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)), + ) + + const sellerGameId = String( + payload.sellerGameId || `${characterName} (${characterId})`, + ).trim() + const submission = await createConsignmentSubmission({ submissionNo, linkId: Number(link.id), @@ -87,18 +158,28 @@ export async function submitPublicConsignment(token: string, payload: JsonObject system: String(payload.system || link.system || '').trim(), serverZone: String(payload.serverZone || link.server_zone || '').trim(), series: String(payload.series || link.series || '').trim(), - diamondLevel: Number(payload.diamondLevel || link.diamond_level || 1), - brillianceCount: Number(payload.brillianceCount ?? link.brilliance_count ?? 0), - sellerGameId: String(payload.sellerGameId || '').trim(), - sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(), - sellerPhone: String(payload.sellerPhone || '').trim(), + diamondLevel, + brillianceCount, + sellerGameId, + sellerPaymentInfo: String(payload.sellerPaymentInfo || '走接单平台钱包结算').trim(), + sellerPhone, contactInfo, - contentJson: - payload.content && typeof payload.content === 'object' - ? (payload.content as Record) - : (payload as Record), - screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [], + contentJson: { + ...contentPayload, + characterName, + characterId, + sellerNote: String(contentPayload.sellerNote || payload.sellerNote || '').trim(), + channel: 'collect_link', + }, + screenshotsJson: screenshots, askingPriceFen, + workerId, + workerName, + workerPhone, + depositAmountFen: Math.max( + 100, + Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)), + ), now, }) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts new file mode 100644 index 00000000..ef160180 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -0,0 +1,166 @@ +import { + createConsignmentSubmission, + listConsignmentInventories, + listConsignmentSubmissions, +} from '../../repositories/worker-platform/consignment-repo.js' +import { getWorkerUserById } from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { generateConsignmentSubmissionNo } from '../../utils/random.js' +import { nowIso } from '../../utils/time.js' + +export async function publishWorkerConsignment(workerId: number, payload: JsonObject) { + const worker = await getWorkerUserById(workerId) + if (!worker) { + throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' }) + } + + const system = String(payload.system || '苹果').trim() + const serverZone = String(payload.serverZone || '微信区').trim() + const series = String(payload.series || '暗潮').trim() + const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel || 1))) + const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount || 0))) + + const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim() + if (!contactInfo) { + throw createHttpError('请填写微信号或QQ号以便联系与核实', { + statusCode: 400, + errorCode: 'contact_required', + }) + } + + const characterName = String(payload.characterName || '').trim() + const characterId = String(payload.characterId || '').trim() + if (!characterName) { + throw createHttpError('请填写游戏内角色名', { + statusCode: 400, + errorCode: 'character_name_required', + }) + } + if (!characterId) { + throw createHttpError('请填写角色数字ID', { + statusCode: 400, + errorCode: 'character_id_required', + }) + } + + const screenshots = Array.isArray(payload.screenshotsJson) ? payload.screenshotsJson : [] + if (screenshots.length === 0) { + throw createHttpError('请上传游戏主页截图以供核验', { + statusCode: 400, + errorCode: 'screenshot_required', + }) + } + + const askingPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0))) + if (askingPriceFen < 100) { + throw createHttpError('寄售价格不能少于1元', { + statusCode: 400, + errorCode: 'invalid_asking_price', + }) + } + + const depositAmountFen = Math.max( + 100, + Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)), + ) + + const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim() + const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : '' + const defaultItemName = `${system}${serverZone}${series}${diamondLevel}钻${briStr} 苏尔南` + const itemName = String(payload.itemName || defaultItemName).trim() + + const submissionNo = generateConsignmentSubmissionNo() + const now = nowIso() + + const submission = await createConsignmentSubmission({ + submissionNo, + linkId: null, + gameName: '和平精英', + categoryId: null, + itemName, + system, + serverZone, + series, + diamondLevel, + brillianceCount, + sellerGameId: characterId ? `${characterName} (${characterId})` : characterName, + sellerPaymentInfo: '走打手接单平台钱包', + sellerPhone, + contactInfo, + contentJson: { + characterName, + characterId, + sellerNote: String(payload.sellerNote || '').trim(), + channel: 'worker_portal', + }, + screenshotsJson: screenshots, + askingPriceFen, + workerId: Number(worker.id), + workerName: worker.display_name || worker.username, + workerPhone: sellerPhone || worker.phone || '', + depositAmountFen, + now, + }) + + return { + id: submission.id, + submissionNo: submission.submission_no, + itemName: submission.item_name, + system: submission.system, + serverZone: submission.server_zone, + series: submission.series, + diamondLevel: submission.diamond_level, + brillianceCount: submission.brilliance_count, + askingPriceFen: submission.asking_price_fen, + status: submission.status, + workerId: submission.worker_id, + workerName: submission.worker_name, + workerPhone: submission.worker_phone, + depositAmountFen: submission.deposit_amount_fen, + createdAt: submission.created_at, + } +} + +export async function listWorkerConsignments(workerId: number) { + const [submissionsRes, inventoriesRes] = await Promise.all([ + listConsignmentSubmissions({ workerId, pageSize: 100 }), + listConsignmentInventories({ workerId, pageSize: 100 }), + ]) + + return { + submissions: submissionsRes.items.map((sub) => ({ + id: sub.id, + submissionNo: sub.submission_no, + itemName: sub.item_name, + system: sub.system, + serverZone: sub.server_zone, + series: sub.series, + diamondLevel: sub.diamond_level, + brillianceCount: sub.brilliance_count, + askingPriceFen: sub.asking_price_fen, + status: sub.status, + reviewNote: sub.review_note, + resultInventoryId: sub.result_inventory_id, + screenshotsJson: sub.screenshots_json, + contentJson: sub.content_json, + createdAt: sub.created_at, + })), + inventories: inventoriesRes.items.map((inv) => ({ + id: inv.id, + inventoryNo: inv.inventory_no, + itemName: inv.item_name, + system: inv.system, + serverZone: inv.server_zone, + series: inv.series, + diamondLevel: inv.diamond_level, + brillianceCount: inv.brilliance_count, + costPriceFen: inv.cost_price_fen, + status: inv.status, + allocationStatus: inv.allocation_status, + boundPlatformOrderId: inv.bound_platform_order_id, + depositAmountFen: inv.deposit_amount_fen, + createdAt: inv.created_at, + })), + } +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 987f0036..87e6145a 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -41,6 +41,7 @@ const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage')) const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage')) const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage')) const WorkerAfterSalesPage = lazy(() => import('@/pages/worker/WorkerAfterSalesPage')) +const WorkerConsignmentPage = lazy(() => import('@/pages/worker/WorkerConsignmentPage')) const WorkerProfilePage = lazy(() => import('@/pages/worker/WorkerProfilePage')) function RequireAdmin() { @@ -149,6 +150,7 @@ function WorkerApplication() { } /> } /> } /> + } /> } /> diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index c7adea38..20b9aa4c 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -1,5 +1,6 @@ import { ContainerOutlined, + DollarCircleOutlined, LogoutOutlined, SafetyCertificateOutlined, ShoppingOutlined, @@ -31,12 +32,14 @@ const menuItems: MenuProps['items'] = [ { key: '/hall', icon: , label: '抢单大厅' }, { key: '/orders', icon: , label: '我的订单' }, { key: '/orders/after-sales', icon: , label: '售后问题单' }, + { key: '/consignment', icon: , label: '我要寄卖' }, { key: '/profile', icon: , label: '个人中心' }, ] const tabItems = [ { key: '/hall', icon: ShoppingOutlined, label: '抢单大厅' }, { key: '/orders', icon: ContainerOutlined, label: '我的订单' }, + { key: '/consignment', icon: DollarCircleOutlined, label: '我要寄卖' }, { key: '/profile', icon: UserOutlined, label: '个人中心' }, ] @@ -84,6 +87,7 @@ export default function WorkerLayout() { ), }, + { key: '/consignment', icon: , label: '我要寄卖' }, { key: '/profile', icon: , label: '个人中心' }, ], [afterSalesPendingCount], @@ -552,6 +556,7 @@ function isRecord(value: unknown): value is Record { function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/orders/after-sales')) return '/orders/after-sales' if (pathname.startsWith('/orders')) return '/orders' + if (pathname.startsWith('/consignment')) return '/consignment' if (pathname.startsWith('/profile')) return '/profile' return '/hall' } diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index f39977c0..a720c784 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -103,7 +103,7 @@ export default function AdminWorkerPlatformPage() { }, { key: 'consignment-submissions', - label: '寄卖收集与审核', + label: '寄卖提报审核', children: , }, { @@ -123,7 +123,7 @@ export default function AdminWorkerPlatformPage() { }, { key: 'consignment-submissions', - label: '寄卖收集与审核', + label: '寄卖提报审核', children: , }, { diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 388d9605..94293c39 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -44,6 +44,7 @@ import { toggleAdminConsignmentMatching, updateAdminConsignmentInventory, } from '@/services/admin/consignment' +import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform' import type { ConsignmentInventory } from '@/types/consignment' const { Text } = Typography @@ -66,8 +67,6 @@ const SERIES_OPTIONS = [ { label: '暗潮', value: '暗潮' }, { label: '怒嚎', value: '怒嚎' }, { label: '血宴', value: '血宴' }, - { label: '矩阵', value: '矩阵' }, - { label: '其他', value: '其他' }, ] const DIAMOND_OPTIONS = [ @@ -78,6 +77,7 @@ const DIAMOND_OPTIONS = [ { label: '4 钻', value: 4 }, { label: '5 钻', value: 5 }, { label: '6 钻', value: 6 }, + { label: '7 钻', value: 7 }, ] const BUYER_COLORS: Record = { @@ -113,6 +113,18 @@ export default function ConsignmentInventoryPanel() { const [inboundForm] = Form.useForm() const [editForm] = Form.useForm() + // 查询活跃打手列表用于指派与关联 + const workersQuery = useQuery({ + queryKey: ['admin-worker-users-consignment-options'], + queryFn: () => fetchAdminWorkerUsers({ page: 1, pageSize: 200, status: 'active' }), + }) + const workerOptions = (workersQuery.data?.data.items || []).map((w) => ({ + label: `${w.displayName || w.username} (${w.phone || '无手机号'}) [ID:${w.workerId}]`, + value: w.workerId, + phone: w.phone, + name: w.displayName || w.username, + })) + // 数据查询 const inventoriesQuery = useQuery({ queryKey: [ @@ -171,6 +183,10 @@ export default function ConsignmentInventoryPanel() { const values = await inboundForm.validateFields() const count = Number(values.count || 1) const costPriceFen = Math.round(Number(values.costPrice || 0) * 100) + const depositAmountFen = values.depositAmount + ? Math.round(Number(values.depositAmount) * 100) + : undefined + const selectedWorker = workerOptions.find((w) => w.value === values.workerId) for (let i = 0; i < count; i++) { await createAdminConsignmentInventory({ @@ -188,6 +204,10 @@ export default function ConsignmentInventoryPanel() { sellerGameId: values.sellerGameId, sellerPaymentInfo: values.sellerPaymentInfo, sellerPhone: values.sellerPhone, + workerId: values.workerId || undefined, + workerName: selectedWorker?.name || undefined, + workerPhone: selectedWorker?.phone || undefined, + depositAmountFen, credentialsJson: { loginMethod: values.loginMethod, account: values.account, @@ -217,6 +237,10 @@ export default function ConsignmentInventoryPanel() { try { const values = await editForm.validateFields() const costPriceFen = Math.round(Number(values.costPrice || 0) * 100) + const depositAmountFen = values.depositAmount + ? Math.round(Number(values.depositAmount) * 100) + : undefined + const selectedWorker = workerOptions.find((w) => w.value === values.workerId) await updateAdminConsignmentInventory(editingItem.id, { itemName: values.itemName, @@ -233,6 +257,10 @@ export default function ConsignmentInventoryPanel() { sellerGameId: values.sellerGameId, sellerPaymentInfo: values.sellerPaymentInfo, sellerPhone: values.sellerPhone, + workerId: values.workerId || undefined, + workerName: selectedWorker?.name || editingItem.workerName || undefined, + workerPhone: selectedWorker?.phone || editingItem.workerPhone || undefined, + depositAmountFen, credentialsJson: { loginMethod: values.loginMethod, account: values.account, @@ -381,18 +409,28 @@ export default function ConsignmentInventoryPanel() { ), }, { - title: '卖家信息', + title: '卖家与供货打手', key: 'seller', - width: 180, + width: 200, render: (_, record) => (
{record.sellerContact ?
联系: {record.sellerContact}
: null} {record.sellerGameId ?
游戏ID: {record.sellerGameId}
: null} - {record.sellerPaymentInfo ? ( - - 收款: {record.sellerPaymentInfo} - - ) : null} + {record.sellerPhone ?
电话: {record.sellerPhone}
: null} + {record.workerId ? ( +
+ + 打手: {record.workerName || `ID:${record.workerId}`} + {record.depositAmountFen + ? ` (押¥${(record.depositAmountFen / 100).toFixed(0)})` + : ''} + +
+ ) : ( +
+ 内部现货 +
+ )}
), }, @@ -471,6 +509,8 @@ export default function ConsignmentInventoryPanel() { editForm.setFieldsValue({ ...record, costPrice: record.costPriceFen / 100, + workerId: record.workerId || undefined, + depositAmount: record.depositAmountFen ? record.depositAmountFen / 100 : 100, loginMethod: (record.credentialsJson as any)?.loginMethod, account: (record.credentialsJson as any)?.account, password: (record.credentialsJson as any)?.password, @@ -734,8 +774,6 @@ export default function ConsignmentInventoryPanel() { { label: '暗潮', value: '暗潮' }, { label: '怒嚎', value: '怒嚎' }, { label: '血宴', value: '血宴' }, - { label: '矩阵', value: '矩阵' }, - { label: '其他', value: '其他' }, ]} onChange={() => autoFillItemName(inboundForm)} /> @@ -751,6 +789,7 @@ export default function ConsignmentInventoryPanel() { { label: '4 钻', value: 4 }, { label: '5 钻', value: 5 }, { label: '6 钻', value: 6 }, + { label: '7 钻', value: 7 }, ]} onChange={() => autoFillItemName(inboundForm)} /> @@ -763,7 +802,7 @@ export default function ConsignmentInventoryPanel() { autoFillItemName(inboundForm)} /> @@ -829,6 +868,43 @@ export default function ConsignmentInventoryPanel() { + + + + @@ -925,24 +1001,29 @@ export default function ConsignmentInventoryPanel() { - + - + - + - - - + + + + + + + + @@ -983,6 +1064,43 @@ export default function ConsignmentInventoryPanel() { + + + + @@ -1064,6 +1182,23 @@ export default function ConsignmentInventoryPanel() { {viewingItem.sellerGameId || '-'} + + {viewingItem.workerId ? ( + + {viewingItem.workerName || `ID:${viewingItem.workerId}`} ( + {viewingItem.workerPhone || '无电话'}) + + ) : ( + 内部自营现货 + )} + + + {viewingItem.depositAmountFen ? ( + ¥{(viewingItem.depositAmountFen / 100).toFixed(0)} + ) : ( + 按平台默认规则 + )} + {viewingItem.sellerPaymentInfo || '-'} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index 55613e6d..50f5a030 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -1,16 +1,6 @@ -import { - CheckCircleOutlined, - CloseCircleOutlined, - CopyOutlined, - EyeOutlined, - LinkOutlined, - PlusOutlined, - ReloadOutlined, -} from '@ant-design/icons' +import { CheckCircleOutlined, CloseCircleOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' import { - Alert, - App, Badge, Button, Card, @@ -25,49 +15,49 @@ import { Row, Select, Space, - Switch, Table, - Tabs, Tag, Typography, + App, } from 'antd' import type { TableColumnsType } from 'antd' import { useState } from 'react' import { approveAdminConsignmentSubmission, - createAdminConsignmentLink, - fetchAdminConsignmentLinks, fetchAdminConsignmentSubmissions, rejectAdminConsignmentSubmission, - updateAdminConsignmentLink, } from '@/services/admin/consignment' -import type { ConsignmentCollectLink, ConsignmentSubmission } from '@/types/consignment' +import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform' +import type { ConsignmentSubmission } from '@/types/consignment' const { Text } = Typography export default function ConsignmentSubmissionsPanel() { const { message } = App.useApp() - const [activeSubTab, setActiveSubTab] = useState<'submissions' | 'links'>('submissions') - // 提报审核状态 const [submissionStatus, setSubmissionStatus] = useState('pending_review') const [submissionKeyword, setSubmissionKeyword] = useState('') const [submissionPage, setSubmissionPage] = useState(1) const [submissionPageSize, setSubmissionPageSize] = useState(20) - // 弹窗状态 - const [createLinkModalOpen, setCreateLinkModalOpen] = useState(false) - const [createdLinkUrl, setCreatedLinkUrl] = useState('') const [approveModalItem, setApproveModalItem] = useState(null) const [rejectModalItem, setRejectModalItem] = useState(null) const [viewingSubmission, setViewingSubmission] = useState(null) - const [rejectNote, setRejectNote] = useState('') - const [linkForm] = Form.useForm() const [approveForm] = Form.useForm() - // 查询提报列表 + const workersQuery = useQuery({ + queryKey: ['admin-worker-users-consignment-options'], + queryFn: () => fetchAdminWorkerUsers({ page: 1, pageSize: 200, status: 'active' }), + }) + const workerOptions = (workersQuery.data?.data.items || []).map((w) => ({ + label: `${w.displayName || w.username} (${w.phone || '无手机号'}) [ID:${w.workerId}]`, + value: w.workerId, + phone: w.phone, + name: w.displayName || w.username, + })) + const submissionsQuery = useQuery({ queryKey: [ 'admin-consignment-submissions', @@ -85,83 +75,19 @@ export default function ConsignmentSubmissionsPanel() { }), }) - // 查询链接列表 - const linksQuery = useQuery({ - queryKey: ['admin-consignment-links'], - queryFn: () => fetchAdminConsignmentLinks(), - }) - const submissions = submissionsQuery.data?.data.items || [] const submissionTotal = submissionsQuery.data?.data.total || 0 - const links = Array.isArray(linksQuery.data?.data) - ? linksQuery.data?.data - : linksQuery.data?.data?.items || [] + const pendingCount = submissions.filter((s) => s.status === 'pending_review').length - // 复制收集公开链接 - function copyPublicLink(token: string) { - const origin = window.location.origin - const url = `${origin}/#/consignment-collect/${token}` - void navigator.clipboard.writeText(url) - message.success('收集链接已复制到剪贴板,可直接发给客户!') - } - - // 切换链接启用/关闭 - async function handleToggleLink(record: ConsignmentCollectLink, enabled: boolean) { - try { - await updateAdminConsignmentLink(record.id, { enabled }) - message.success(enabled ? '链接已开启' : '链接已关闭') - await linksQuery.refetch() - } catch (err: any) { - message.error(err.message || '更新失败') - } - } - - // 创建收集链接 - async function handleCreateLinkSubmit() { - try { - const values = await linkForm.validateFields() - const presetPriceFen = Math.round(Number(values.presetPrice || 0) * 100) - const briStr = values.brillianceCount > 0 ? `${values.brillianceCount}璀璨` : '' - const defaultTitle = `${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 寄卖征集` - - const res = await createAdminConsignmentLink({ - title: values.title || defaultTitle, - system: values.system, - serverZone: values.serverZone, - series: values.series, - diamondLevel: Number(values.diamondLevel || 1), - brillianceCount: Number(values.brillianceCount || 0), - presetPriceFen, - buyerName: values.buyerName, - inboundBatch: values.inboundBatch, - isMultiUse: values.isMultiUse !== false, - }) - - const token = res.data?.token || '' - const url = `${window.location.origin}/#/consignment-collect/${token}` - setCreatedLinkUrl(url) - try { - await navigator.clipboard.writeText(url) - } catch { - // clipboard access might be unavailable - } - - message.success('收集链接生成成功!') - setCreateLinkModalOpen(false) - linkForm.resetFields() - await linksQuery.refetch() - } catch (err: any) { - if (err.errorFields) return - message.error(err.message || '创建收集链接失败') - } - } - - // 审核通过并入库 async function handleApproveSubmit() { if (!approveModalItem) return try { const values = await approveForm.validateFields() const costPriceFen = Math.round(Number(values.costPrice || 0) * 100) + const depositAmountFen = values.depositAmount + ? Math.round(Number(values.depositAmount) * 100) + : undefined + const selectedWorker = workerOptions.find((w) => w.value === values.workerId) await approveAdminConsignmentSubmission(approveModalItem.id, { costPriceFen, @@ -178,6 +104,10 @@ export default function ConsignmentSubmissionsPanel() { sellerGameId: values.sellerGameId, sellerPaymentInfo: values.sellerPaymentInfo, sellerPhone: values.sellerPhone, + workerId: values.workerId || undefined, + workerName: selectedWorker?.name || approveModalItem.workerName || undefined, + workerPhone: selectedWorker?.phone || approveModalItem.workerPhone || undefined, + depositAmountFen, reviewNote: values.reviewNote, }) @@ -190,7 +120,6 @@ export default function ConsignmentSubmissionsPanel() { } } - // 驳回提报 async function handleRejectConfirm() { if (!rejectModalItem) return try { @@ -204,18 +133,22 @@ export default function ConsignmentSubmissionsPanel() { } } - // 提报列表列定义 + function resolveSubmissionSource(record: ConsignmentSubmission) { + const channel = (record.contentJson as { channel?: string } | undefined)?.channel + if (channel === 'worker_portal' || record.workerId) return '打手端提报' + if (record.linkTitle) return `旧收集链接: ${record.linkTitle}` + return '其他渠道' + } + const submissionColumns: TableColumnsType = [ { title: '提报单号', key: 'submissionNo', - width: 170, + width: 180, render: (_, record) => (
{record.submissionNo} - {record.linkTitle ? ( -
来自: {record.linkTitle}
- ) : null} +
{resolveSubmissionSource(record)}
), }, @@ -249,14 +182,24 @@ export default function ConsignmentSubmissionsPanel() { ), }, { - title: '卖家联系方式', + title: '卖家与关联打手', key: 'contact', - width: 180, + width: 200, render: (_, record) => (
联系: {record.contactInfo}
{record.sellerGameId ?
游戏ID: {record.sellerGameId}
: null} {record.sellerPhone ?
电话: {record.sellerPhone}
: null} + {record.workerId ? ( +
+ + 打手: {record.workerName || `ID:${record.workerId}`} + {record.depositAmountFen + ? ` (押¥${(record.depositAmountFen / 100).toFixed(0)})` + : ''} + +
+ ) : null}
), }, @@ -298,7 +241,7 @@ export default function ConsignmentSubmissionsPanel() { { title: '操作', key: 'actions', - width: 160, + width: 200, render: (_, record) => ( - - ), - }, - ] - return (
- setActiveSubTab(k as any)} - items={[ - { - key: 'submissions', - label: ( - - 玩家提报审核{' '} - {submissions.filter((s) => s.status === 'pending_review').length > 0 ? ( - s.status === 'pending_review').length} - offset={[6, -2]} - /> - ) : null} - - ), - children: ( -
- {/* 过滤条 */} - - - { - setSubmissionStatus(e.target.value) - setSubmissionPage(1) - }} - buttonStyle="solid" - > - 待客服审核 - 已审核入库 - 已驳回 - 全部 - - - { - setSubmissionKeyword(v) - setSubmissionPage(1) - }} - /> - - - - - - {/* 提报审核表格 */} - - `共 ${t} 条提报记录`, - onChange: (p, ps) => { - setSubmissionPage(p) - setSubmissionPageSize(ps) - }, - }} - /> - - - ), - }, - { - key: 'links', - label: '收集链接生成与管理', - children: ( -
- - - - - - - - -
- - - ), - }, - ]} - /> - - {/* 生成收集链接弹窗 */} - setCreateLinkModalOpen(false)} - onOk={handleCreateLinkSubmit} - destroyOnClose - > -
- -
- - - - - - - - - - - - - - - - - - - - - - + + { + setSubmissionStatus(e.target.value) + setSubmissionPage(1) + }} + buttonStyle="solid" > - - + + 待客服审核 + {pendingCount > 0 ? ( + + ) : null} + + 已审核入库 + 已驳回 + 全部 + - - - - - - - - - - - - + { + setSubmissionKeyword(v) + setSubmissionPage(1) + }} + /> - - - - - + + + + + +
`共 ${t} 条提报记录`, + onChange: (p, ps) => { + setSubmissionPage(p) + setSubmissionPageSize(ps) + }, + }} + /> + - {/* 审核入库弹窗 */} - + - + - + - - - + + + + + + + + + + + + + + + + - + - + - - - + + + + + + + + + + + + + + + + + - - - - - - 2. 卖家联系与收款方式 - + + + 2. 卖家联系方式与游戏凭据 + + - + - - + + + + + + + + + + + + + + + - - - - - - - - - 3. 凭据截图与账号交接 (仅内部审核验号可见) - - - } - type="info" - message="信息安全保障" - description="您填写的账号凭据与截图受平台严格加密保护,仅由官方指定客服在验货核实时可见,绝不泄露给任何无关第三方。" - style={{ marginBottom: 16 }} - /> - - {fileList.length < 4 ? (
- -
上传截图
+ +
上传主页截图
) : null}
- - - 微信扫码登录 - QQ扫码登录 - 账号密码交付 - 游戏内转赠/投喂 - - - - -
- - - - - - - - - - - - - - - - + diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx new file mode 100644 index 00000000..20bcb605 --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -0,0 +1,739 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + CloudUploadOutlined, + DollarCircleOutlined, + HistoryOutlined, + InboxOutlined, + PlusOutlined, + ReloadOutlined, + SafetyCertificateOutlined, +} from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Col, + Divider, + Empty, + Form, + Image, + Input, + InputNumber, + Row, + Select, + Space, + Tabs, + Tag, + Typography, +} from 'antd' +import { useEffect, useMemo, useState } from 'react' + +import ImageUpload from '@/components/files/ImageUpload' +import { + fetchWorkerConsignments, + fetchWorkerProfile, + publishWorkerConsignment, +} from '@/services/worker' +import type { WorkerConsignmentItem, WorkerInventoryItem } from '@/services/worker' +import type { UploadedFile } from '@/types/worker-platform' +import { useIsMobile } from '@/utils/use-is-mobile' + +const SERIES_OPTIONS = [ + { label: '暗潮', value: '暗潮' }, + { label: '怒嚎', value: '怒嚎' }, + { label: '血宴', value: '血宴' }, +] + +const DIAMOND_OPTIONS = [1, 2, 3, 4, 5, 6, 7].map((v) => ({ + label: `${v} 钻`, + value: v, +})) + +const BRILLIANCE_OPTIONS = [ + { label: '无璀璨 (0)', value: 0 }, + ...[1, 2, 3, 4, 5, 6, 7].map((v) => ({ label: `${v} 璀璨`, value: v })), +] + +type RecordFilter = 'all' | 'pending' | 'in_stock' | 'done' + +export default function WorkerConsignmentPage() { + const isMobile = useIsMobile() + const { message } = App.useApp() + const [activeTab, setActiveTab] = useState<'publish' | 'records'>('publish') + const [recordFilter, setRecordFilter] = useState('all') + const [screenshots, setScreenshots] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [depositTouched, setDepositTouched] = useState(false) + const [form] = Form.useForm() + + const profileQuery = useQuery({ + queryKey: ['worker-profile'], + queryFn: () => fetchWorkerProfile(), + staleTime: 60_000, + }) + const worker = profileQuery.data?.data.worker + + const myConsignmentsQuery = useQuery({ + queryKey: ['worker-my-consignments'], + queryFn: () => fetchWorkerConsignments(), + }) + + const submissions = myConsignmentsQuery.data?.data.submissions || [] + const inventories = myConsignmentsQuery.data?.data.inventories || [] + + const pendingCount = submissions.filter((s) => s.status === 'pending_review').length + const inStockCount = inventories.filter((i) => i.status === 'in_stock').length + const doneCount = inventories.filter((i) => i.status === 'consumed').length + const rejectedCount = submissions.filter((s) => s.status === 'rejected').length + + useEffect(() => { + if (worker?.phone) { + form.setFieldsValue({ sellerPhone: worker.phone }) + } + }, [worker, form]) + + function handleFormValuesChange(changedValues: Record) { + const sys = form.getFieldValue('system') + const zone = form.getFieldValue('serverZone') + const ser = form.getFieldValue('series') + const dia = form.getFieldValue('diamondLevel') + const bri = form.getFieldValue('brillianceCount') + const next: Record = {} + + if (sys && zone && ser && dia != null && bri != null) { + const briNum = Number(bri) + const briStr = briNum > 0 ? `${briNum}璀璨` : '' + next.itemName = `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南` + } + + if (Object.prototype.hasOwnProperty.call(changedValues, 'depositAmount')) { + setDepositTouched(true) + } + + if ( + Object.prototype.hasOwnProperty.call(changedValues, 'askingPrice') && + !depositTouched && + !Object.prototype.hasOwnProperty.call(changedValues, 'depositAmount') + ) { + const price = Number(changedValues.askingPrice || 0) + if (price > 0) next.depositAmount = price + } + + if (Object.keys(next).length > 0) { + form.setFieldsValue(next) + } + } + + async function handlePublishSubmit(values: Record) { + const urls = screenshots.map((f) => f.url).filter(Boolean) + if (urls.length === 0) { + message.error('请上传游戏主页截图以供核验') + return + } + + const askingPriceFen = Math.round(Number(values.askingPrice || 0) * 100) + if (askingPriceFen < 100) { + message.error('寄售价格不能少于1元') + return + } + + const depositAmountFen = Math.round( + Number(values.depositAmount || values.askingPrice || 100) * 100, + ) + if (depositAmountFen < 100) { + message.error('履约押金不能少于1元') + return + } + + setSubmitting(true) + try { + await publishWorkerConsignment({ + itemName: values.itemName, + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + askingPriceFen, + sellerContact: values.sellerContact, + sellerPhone: values.sellerPhone || worker?.phone || '', + characterName: values.characterName, + characterId: values.characterId, + screenshotsJson: urls, + sellerNote: values.sellerNote, + depositAmountFen, + }) + + message.success('寄卖提报成功,已进入审核') + form.resetFields() + setDepositTouched(false) + setScreenshots([]) + await myConsignmentsQuery.refetch() + setRecordFilter('pending') + setActiveTab('records') + } catch (err: any) { + message.error(err.message || '发布寄卖失败,请重试') + } finally { + setSubmitting(false) + } + } + + const filteredRecords = useMemo(() => { + const pendingSubs = submissions.filter((s) => s.status === 'pending_review') + const rejectedSubs = submissions.filter((s) => s.status === 'rejected') + const approvedSubs = submissions.filter((s) => s.status === 'approved') + const inStock = inventories.filter((i) => i.status === 'in_stock') + const done = inventories.filter((i) => i.status === 'consumed' || i.status === 'archived') + + if (recordFilter === 'pending') { + return { submissions: [...pendingSubs, ...rejectedSubs], inventories: [] as WorkerInventoryItem[] } + } + if (recordFilter === 'in_stock') { + return { submissions: [] as WorkerConsignmentItem[], inventories: inStock } + } + if (recordFilter === 'done') { + return { submissions: [] as WorkerConsignmentItem[], inventories: done } + } + return { + submissions: [...pendingSubs, ...rejectedSubs, ...approvedSubs], + inventories, + } + }, [submissions, inventories, recordFilter]) + + const recordTotal = + filteredRecords.submissions.length + filteredRecords.inventories.length + + return ( +
+
+
+ + 苏尔南寄卖 + + + + 接单平台押金履约 + +
+
+ +
+
+ +
+
{ + setRecordFilter('pending') + setActiveTab('records') + }} + > +
+
+ +
+
+ 审核中 / 驳回 + + {pendingCount + rejectedCount} + 单 + +
+
+
+ + {pendingCount > 0 ? `${pendingCount} 单待客服审核` : '暂无待审提报'} + +
+
+ +
{ + setRecordFilter('in_stock') + setActiveTab('records') + }} + > +
+
+ +
+
+ 在库待派单 + + {inStockCount} + 把 + +
+
+
+ 已入库,等待匹配买家工单 +
+
+ +
{ + setRecordFilter('done') + setActiveTab('records') + }} + > +
+
+ +
+
+ 已履约出库 + + {doneCount} + 把 + +
+
+
+ 押金履约交付完成 +
+
+ +
setActiveTab('publish')} + > +
+
+ +
+
+ 发布寄卖 + + 苏尔南 + +
+
+
+ 暗潮 / 怒嚎 / 血宴 · 押金履约 +
+
+
+ + + setActiveTab(key as 'publish' | 'records')} + items={[ + { + key: 'publish', + label: ( + + 发布寄卖 + + ), + children: ( +
+ } + className="worker-consignment-tip" + message="走接单平台押金履约:审核入库后匹配买家工单,系统按登记押金冻结钱包;局内交枪验货通过后解冻并结算货款。" + /> + + + 规格与价格 + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + 联系方式与游戏凭据 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 提交后进入客服审核,通过后入库匹配 + +
+ + ), + }, + { + key: 'records', + label: ( + + 我的寄卖 + {submissions.length + inventories.length > 0 ? ( + + {submissions.length + inventories.length} + + ) : null} + + ), + children: ( + +
+ {( + [ + { key: 'all', label: '全部' }, + { key: 'pending', label: '审核中' }, + { key: 'in_stock', label: '在库' }, + { key: 'done', label: '已出库' }, + ] as const + ).map((item) => ( + + ))} +
+ + {myConsignmentsQuery.isLoading ? ( + 正在加载寄卖记录… + ) : recordTotal === 0 ? ( + + + + ) : ( +
+ {filteredRecords.inventories.map((inv) => ( + + ))} + {filteredRecords.submissions.map((sub) => ( + + ))} +
+ )} +
+ ), + }, + ]} + /> + + + ) +} + +function SpecTags({ + system, + serverZone, + series, + diamondLevel, + brillianceCount, +}: { + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number +}) { + return ( + + {system} + {serverZone} + {series} + {diamondLevel} 钻 + {brillianceCount > 0 ? {brillianceCount} 璀璨 : null} + + ) +} + +function InventoryRecordCard({ inv }: { inv: WorkerInventoryItem }) { + return ( +
+
+
+ {inv.itemName} + + {inv.inventoryNo} + + {inv.status === 'in_stock' ? ( + 在库待派 + ) : inv.status === 'consumed' ? ( + 已履约 + ) : ( + 已下架 + )} +
+ +
+ + 货款 ¥{(inv.costPriceFen / 100).toFixed(2)} + + + 押金 ¥{((inv.depositAmountFen || 0) / 100).toFixed(0)} + + {inv.boundPlatformOrderId ? ( + + 履约单 {inv.boundPlatformOrderId} + + ) : null} +
+
+ + {inv.createdAt} + +
+ ) +} + +function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) { + const shots = Array.isArray(sub.screenshotsJson) + ? sub.screenshotsJson.filter((s): s is string => typeof s === 'string') + : [] + + return ( +
+
+
+ {sub.itemName} + + {sub.submissionNo} + + {sub.status === 'pending_review' ? ( + 审核中 + ) : sub.status === 'approved' ? ( + 已通过 + ) : ( + 已驳回 + )} +
+ +
+ + 期望价 ¥{(sub.askingPriceFen / 100).toFixed(2)} + + {sub.reviewNote ? ( + 审核说明:{sub.reviewNote} + ) : null} +
+ {shots.length > 0 ? ( +
+ + + {shots.map((src, i) => ( + + ))} + + +
+ ) : null} +
+ + {sub.createdAt} + +
+ ) +} diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index b3940f17..71709f86 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -134,6 +134,17 @@ export function fulfillWorkOrderWithConsignment(payload: { ) } +export function assignWorkOrderWithConsignmentWorker(payload: { + workOrderId: number | string + inventoryId: number | string + workerId?: number | null +}) { + return apiPost<{ workOrder: unknown; inventory: ConsignmentInventory }>( + '/api/v1/admin/worker-platform/consignment/assign-worker', + payload, + ) +} + export function revokeWorkOrderConsignmentFulfillment(payload: { workOrderId: number | string action?: 'reopen' | 'cancel_order' | 'archive_inventory' diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index cfb08ac4..f2fe7476 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -352,3 +352,67 @@ export function submitCollectOrder(payload: { payload, ) } + +// ---------------- 打手端:我要寄卖与手枪库存 ---------------- + +export type WorkerConsignmentPublishPayload = { + itemName?: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + askingPriceFen: number + sellerContact: string + sellerPhone?: string + characterName: string + characterId: string + screenshotsJson: string[] + sellerNote?: string + depositAmountFen?: number +} + +export type WorkerConsignmentItem = { + id: number | string + submissionNo: string + itemName: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + askingPriceFen: number + status: 'pending_review' | 'approved' | 'rejected' + reviewNote?: string + resultInventoryId?: number | string | null + screenshotsJson: string[] + contentJson: Record + createdAt: string +} + +export type WorkerInventoryItem = { + id: number | string + inventoryNo: string + itemName: string + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number + costPriceFen: number + status: 'in_stock' | 'consumed' | 'archived' + allocationStatus: string + boundPlatformOrderId: string + depositAmountFen?: number + createdAt: string +} + +export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayload) { + return apiPost('/api/v1/worker/consignment/publish', payload) +} + +export function fetchWorkerConsignments() { + return apiGet<{ submissions: WorkerConsignmentItem[]; inventories: WorkerInventoryItem[] }>( + '/api/v1/worker/consignment/my-items', + ) +} diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 73b91b0d..7330c914 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -4210,3 +4210,121 @@ body { .worker-profile-details-card { border-radius: 12px; } + +/* ===== 苏尔南寄卖页(对齐订单/售后视觉) ===== */ +.worker-consignment-page .worker-orders-card { + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); +} + +.worker-consignment-tip { + margin-bottom: 8px; + border-radius: 8px; +} + +.worker-consignment-divider.ant-divider { + margin: 8px 0 16px; + border-color: #e2e8f0; + color: #64748b; + font-size: 13px; + font-weight: 600; +} + +.worker-consignment-form .ant-form-item { + margin-bottom: 14px; +} + +.worker-consignment-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid #f1f5f9; +} + +.worker-consignment-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.worker-consignment-record-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.worker-consignment-record-card { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; +} + +.worker-consignment-record-main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.worker-consignment-record-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.worker-consignment-record-no { + font-size: 12px; +} + +.worker-consignment-record-meta { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; + font-size: 13px; + color: #475569; +} + +.worker-consignment-record-meta strong { + color: #cf1322; + font-weight: 600; +} + +.worker-consignment-record-time { + flex: 0 0 auto; + font-size: 12px; + white-space: nowrap; +} + +.worker-consignment-record-shots { + margin-top: 2px; +} + +@media (max-width: 768px) { + .worker-consignment-record-card { + flex-direction: column; + } + + .worker-consignment-record-time { + white-space: normal; + } + + .worker-consignment-actions { + flex-direction: column; + align-items: stretch; + } + + .worker-consignment-actions .ant-btn { + width: 100%; + } +} + diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 619c5da7..9e172bc2 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -28,6 +28,10 @@ export type ConsignmentInventory = { boundPlatformOrderId: string sourceType: 'manual' | 'collection_link' | 'recycle' sourceSubmissionId: number | null + workerId?: number | null + workerName?: string + workerPhone?: string + depositAmountFen?: number inboundAt: string consumedAt: string | null createdAt: string @@ -80,6 +84,10 @@ export type ConsignmentSubmission = { contentJson: Record screenshotsJson: string[] askingPriceFen: number + workerId?: number | null + workerName?: string + workerPhone?: string + depositAmountFen?: number status: 'pending_review' | 'approved' | 'rejected' reviewNote: string reviewerName: string