feat(consignment): 苏尔南寄卖接入打手端押金履约

打手可直接提报寄卖,关联 worker 与履约押金后指派交枪;后台去掉旧收集链接管理,提报审核与库存/工单流程对齐接单平台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-15 16:00:33 +08:00
co-authored by Cursor
parent 7672360875
commit 4a6215e816
23 changed files with 2218 additions and 677 deletions
@@ -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';
@@ -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<string, unknown>
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<ConsignmentInventoryRow> {
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<ConsignmentInventoryRow>(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<ConsignmentInventoryRow | null> {
@@ -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<string, unknown>
screenshotsJson?: unknown[]
askingPriceFen?: number
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
now: string
}): Promise<ConsignmentSubmissionRow> {
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<string, unknown> | 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<ConsignmentInventoryRow>(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<ConsignmentSubmissionRow>(updateSubSql, [
inventory.id,
input.buyerName,
input.reviewNote || '',
workerId,
workerName,
workerPhone,
depositAmountFen,
input.now,
submission.id,
])
@@ -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']),
+3
View File
@@ -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))
})
@@ -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
@@ -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<string, unknown>)
: undefined,
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
reviewNote: String(payload.reviewNote || '').trim(),
workerId,
workerName,
workerPhone,
depositAmountFen,
now,
})
@@ -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<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
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,
}
}
/**
* 撤销寄售履约(用于选错重选、买家退款或物品异常)
*/
@@ -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', () => {
@@ -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('璀璨')) {
@@ -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<string, unknown>)
: payload.content && typeof payload.content === 'object'
? (payload.content as Record<string, unknown>)
: {}
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<string, unknown>)
: (payload as Record<string, unknown>),
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,
})
@@ -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,
})),
}
}
+2
View File
@@ -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() {
<Route path="hall" element={<WorkerHallPage />} />
<Route path="orders" element={<WorkerOrdersPage />} />
<Route path="orders/after-sales" element={<WorkerAfterSalesPage />} />
<Route path="consignment" element={<WorkerConsignmentPage />} />
<Route path="profile" element={<WorkerProfilePage />} />
</Route>
</Route>
@@ -1,5 +1,6 @@
import {
ContainerOutlined,
DollarCircleOutlined,
LogoutOutlined,
SafetyCertificateOutlined,
ShoppingOutlined,
@@ -31,12 +32,14 @@ const menuItems: MenuProps['items'] = [
{ key: '/hall', icon: <ShoppingOutlined />, label: '抢单大厅' },
{ key: '/orders', icon: <ContainerOutlined />, label: '我的订单' },
{ key: '/orders/after-sales', icon: <SafetyCertificateOutlined />, label: '售后问题单' },
{ key: '/consignment', icon: <DollarCircleOutlined />, label: '我要寄卖' },
{ key: '/profile', icon: <UserOutlined />, 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() {
</span>
),
},
{ key: '/consignment', icon: <DollarCircleOutlined />, label: '我要寄卖' },
{ key: '/profile', icon: <UserOutlined />, label: '个人中心' },
],
[afterSalesPendingCount],
@@ -552,6 +556,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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'
}
@@ -103,7 +103,7 @@ export default function AdminWorkerPlatformPage() {
},
{
key: 'consignment-submissions',
label: '寄卖收集与审核',
label: '寄卖提报审核',
children: <ConsignmentSubmissionsPanel />,
},
{
@@ -123,7 +123,7 @@ export default function AdminWorkerPlatformPage() {
},
{
key: 'consignment-submissions',
label: '寄卖收集与审核',
label: '寄卖提报审核',
children: <ConsignmentSubmissionsPanel />,
},
{
@@ -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<string, string> = {
@@ -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) => (
<div style={{ fontSize: 12 }}>
{record.sellerContact ? <div>联系: {record.sellerContact}</div> : null}
{record.sellerGameId ? <div>游戏ID: {record.sellerGameId}</div> : null}
{record.sellerPaymentInfo ? (
<Text type="secondary" ellipsis style={{ maxWidth: 160, display: 'inline-block' }}>
收款: {record.sellerPaymentInfo}
</Text>
) : null}
{record.sellerPhone ? <div>电话: {record.sellerPhone}</div> : null}
{record.workerId ? (
<div style={{ marginTop: 4 }}>
<Tag color="blue">
打手: {record.workerName || `ID:${record.workerId}`}
{record.depositAmountFen
? ` (押¥${(record.depositAmountFen / 100).toFixed(0)})`
: ''}
</Tag>
</div>
) : (
<div style={{ marginTop: 4 }}>
<Tag color="default">内部现货</Tag>
</div>
)}
</div>
),
},
@@ -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() {
<Form.Item name="brillianceCount" label="璀璨数">
<InputNumber
min={0}
max={6}
max={7}
style={{ width: '100%' }}
onChange={() => autoFillItemName(inboundForm)}
/>
@@ -829,6 +868,43 @@ export default function ConsignmentInventoryPanel() {
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="workerId"
label="走打手接单平台 (供货/履约打手)"
tooltip="关联打手账号后,工单匹配推荐时将直接指派该打手接单丢枪并冻结押金"
>
<Select
allowClear
showSearch
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
placeholder="选择关联的接单平台打手账号 (选填)"
options={workerOptions}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="depositAmount"
label="履约押金金额 (元)"
tooltip="指派该打手接单交付手枪时,平台自动冻结的押金金额(默认100元)"
>
<InputNumber
min={0}
step={10}
addonBefore="¥"
style={{ width: '100%' }}
placeholder="如 100"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息 (支付宝/微信号/银行卡)">
<Input placeholder="如:支付宝 13800000000 (张三)" />
</Form.Item>
@@ -925,24 +1001,29 @@ export default function ConsignmentInventoryPanel() {
</Row>
<Row gutter={16}>
<Col span={6}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Col span={5}>
<Form.Item name="series" label="系列">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="diamondLevel" label="钻石等级">
<InputNumber min={1} max={6} style={{ width: '100%' }} />
<Col span={5}>
<Form.Item name="diamondLevel" label="钻石等级 (1-7)">
<InputNumber min={1} max={7} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="brillianceCount" label="璀璨数 (0-7)">
<InputNumber min={0} max={7} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
@@ -983,6 +1064,43 @@ export default function ConsignmentInventoryPanel() {
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="workerId"
label="走打手接单平台 (供货/履约打手)"
tooltip="关联后,工单匹配推荐时将直接指派该打手接单丢枪并冻结押金"
>
<Select
allowClear
showSearch
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
placeholder="选择关联的接单平台打手账号 (选填)"
options={workerOptions}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="depositAmount"
label="履约押金金额 (元)"
tooltip="指派该打手接单交付手枪时,平台自动冻结的押金金额(默认100元)"
>
<InputNumber
min={0}
step={10}
addonBefore="¥"
style={{ width: '100%' }}
placeholder="如 100"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息">
<Input />
</Form.Item>
@@ -1064,6 +1182,23 @@ export default function ConsignmentInventoryPanel() {
<Descriptions.Item label="卖家游戏ID" span={2}>
<Text copyable>{viewingItem.sellerGameId || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="供货/接单打手">
{viewingItem.workerId ? (
<Tag color="blue">
{viewingItem.workerName || `ID:${viewingItem.workerId}`} (
{viewingItem.workerPhone || '无电话'})
</Tag>
) : (
<Text type="secondary">内部自营现货</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingItem.depositAmountFen ? (
<Tag color="gold">¥{(viewingItem.depositAmountFen / 100).toFixed(0)}</Tag>
) : (
<Text type="secondary">按平台默认规则</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="卖家收款信息" span={2}>
<Text copyable>{viewingItem.sellerPaymentInfo || '-'}</Text>
</Descriptions.Item>
@@ -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<string>('pending_review')
const [submissionKeyword, setSubmissionKeyword] = useState<string>('')
const [submissionPage, setSubmissionPage] = useState<number>(1)
const [submissionPageSize, setSubmissionPageSize] = useState<number>(20)
// 弹窗状态
const [createLinkModalOpen, setCreateLinkModalOpen] = useState(false)
const [createdLinkUrl, setCreatedLinkUrl] = useState<string>('')
const [approveModalItem, setApproveModalItem] = useState<ConsignmentSubmission | null>(null)
const [rejectModalItem, setRejectModalItem] = useState<ConsignmentSubmission | null>(null)
const [viewingSubmission, setViewingSubmission] = useState<ConsignmentSubmission | null>(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<ConsignmentSubmission> = [
{
title: '提报单号',
key: 'submissionNo',
width: 170,
width: 180,
render: (_, record) => (
<div>
<Text strong>{record.submissionNo}</Text>
{record.linkTitle ? (
<div style={{ fontSize: 11, color: '#8c8c8c' }}>来自: {record.linkTitle}</div>
) : null}
<div style={{ fontSize: 11, color: '#8c8c8c' }}>{resolveSubmissionSource(record)}</div>
</div>
),
},
@@ -249,14 +182,24 @@ export default function ConsignmentSubmissionsPanel() {
),
},
{
title: '卖家联系方式',
title: '卖家与关联打手',
key: 'contact',
width: 180,
width: 200,
render: (_, record) => (
<div style={{ fontSize: 12 }}>
<div>联系: {record.contactInfo}</div>
{record.sellerGameId ? <div>游戏ID: {record.sellerGameId}</div> : null}
{record.sellerPhone ? <div>电话: {record.sellerPhone}</div> : null}
{record.workerId ? (
<div style={{ marginTop: 4 }}>
<Tag color="blue">
打手: {record.workerName || `ID:${record.workerId}`}
{record.depositAmountFen
? ` (押¥${(record.depositAmountFen / 100).toFixed(0)})`
: ''}
</Tag>
</div>
) : null}
</div>
),
},
@@ -298,7 +241,7 @@ export default function ConsignmentSubmissionsPanel() {
{
title: '操作',
key: 'actions',
width: 160,
width: 200,
render: (_, record) => (
<Space size={8}>
<Button
@@ -314,6 +257,7 @@ export default function ConsignmentSubmissionsPanel() {
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => {
setApproveModalItem(record)
approveForm.setFieldsValue({
@@ -322,6 +266,10 @@ export default function ConsignmentSubmissionsPanel() {
sellerContact: record.contactInfo,
buyerName: '客服',
inboundBatch: new Date().toISOString().slice(0, 7),
workerId: record.workerId || undefined,
depositAmount: record.depositAmountFen
? record.depositAmountFen / 100
: record.askingPriceFen / 100,
})
}}
>
@@ -331,6 +279,7 @@ export default function ConsignmentSubmissionsPanel() {
size="small"
danger
type="text"
icon={<CloseCircleOutlined />}
onClick={() => {
setRejectModalItem(record)
setRejectNote('')
@@ -345,334 +294,65 @@ export default function ConsignmentSubmissionsPanel() {
},
]
// 链接列表列定义
const linkColumns: TableColumnsType<ConsignmentCollectLink> = [
{
title: '收集标题 / 规格',
key: 'title',
width: 280,
render: (_, record) => {
const fullUrl = `${window.location.origin}/#/consignment-collect/${record.token}`
return (
<div>
<div style={{ fontWeight: 600 }}>{record.title}</div>
<Space size={4} wrap style={{ marginTop: 4 }}>
<Tag color={record.system === '苹果' ? 'geekblue' : 'green'}>
{record.system || '通用'}
</Tag>
<Tag color="cyan">{record.serverZone || '全区'}</Tag>
<Tag color="orange">{record.series || '通用'}</Tag>
<Tag color="gold">{record.diamondLevel || 1} 钻</Tag>
{record.brillianceCount > 0 ? (
<Tag color="magenta">{record.brillianceCount} 璀璨</Tag>
) : null}
</Space>
<div style={{ marginTop: 6 }}>
<Text
copyable={{ text: fullUrl }}
style={{ fontSize: 11, color: '#1890ff', wordBreak: 'break-all' }}
>
{fullUrl}
</Text>
</div>
</div>
)
},
},
{
title: '指导参考价',
key: 'price',
width: 110,
render: (_, record) => (
<Text strong style={{ color: '#cf1322' }}>
¥{(record.presetPriceFen / 100).toFixed(2)}
</Text>
),
},
{
title: '创建人/批次',
key: 'creator',
width: 140,
render: (_, record) => (
<div>
<div>{record.createdBy || '-'}</div>
{record.inboundBatch ? (
<div style={{ fontSize: 11, color: '#8c8c8c' }}>批次: {record.inboundBatch}</div>
) : null}
</div>
),
},
{
title: '提报数量',
key: 'count',
width: 100,
render: (_, record) => (
<Badge
count={record.submissionCount || 0}
showZero
style={{ backgroundColor: (record.submissionCount || 0) > 0 ? '#52c41a' : '#d9d9d9' }}
/>
),
},
{
title: '状态开关',
key: 'enabled',
width: 100,
render: (_, record) => (
<Switch
checked={record.enabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => handleToggleLink(record, checked)}
/>
),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_, record) => (
<Space size={8}>
<Button
size="small"
type="primary"
ghost
icon={<CopyOutlined />}
onClick={() => copyPublicLink(record.token)}
>
复制公开链接
</Button>
</Space>
),
},
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Tabs
activeKey={activeSubTab}
onChange={(k) => setActiveSubTab(k as any)}
items={[
{
key: 'submissions',
label: (
<span>
玩家提报审核{' '}
{submissions.filter((s) => s.status === 'pending_review').length > 0 ? (
<Badge
count={submissions.filter((s) => s.status === 'pending_review').length}
offset={[6, -2]}
/>
) : null}
</span>
),
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 过滤条 */}
<Card size="small">
<Space wrap size={12}>
<Radio.Group
value={submissionStatus}
onChange={(e) => {
setSubmissionStatus(e.target.value)
setSubmissionPage(1)
}}
buttonStyle="solid"
>
<Radio.Button value="pending_review">待客服审核</Radio.Button>
<Radio.Button value="approved">已审核入库</Radio.Button>
<Radio.Button value="rejected">已驳回</Radio.Button>
<Radio.Button value="all">全部</Radio.Button>
</Radio.Group>
<Input.Search
placeholder="搜索提报单号/微信号/游戏ID"
allowClear
style={{ width: 240 }}
onSearch={(v) => {
setSubmissionKeyword(v)
setSubmissionPage(1)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => submissionsQuery.refetch()}>
刷新
</Button>
</Space>
</Card>
{/* 提报审核表格 */}
<Card size="small">
<Table
rowKey="id"
columns={submissionColumns}
dataSource={submissions}
loading={submissionsQuery.isLoading}
pagination={{
current: submissionPage,
pageSize: submissionPageSize,
total: submissionTotal,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条提报记录`,
onChange: (p, ps) => {
setSubmissionPage(p)
setSubmissionPageSize(ps)
},
}}
/>
</Card>
</div>
),
},
{
key: 'links',
label: '收集链接生成与管理',
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card size="small">
<Space wrap size={12}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
linkForm.resetFields()
linkForm.setFieldsValue({
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: 1,
brillianceCount: 0,
isMultiUse: true,
buyerName: '客服',
})
setCreateLinkModalOpen(true)
}}
>
生成定向收集链接
</Button>
<Button icon={<ReloadOutlined />} onClick={() => linksQuery.refetch()}>
刷新
</Button>
</Space>
</Card>
<Card size="small">
<Table
rowKey="id"
columns={linkColumns}
dataSource={links}
loading={linksQuery.isLoading}
pagination={false}
/>
</Card>
</div>
),
},
]}
/>
{/* 生成收集链接弹窗 */}
<Modal
title="生成寄卖线索收集链接"
open={createLinkModalOpen}
width={600}
onCancel={() => setCreateLinkModalOpen(false)}
onOk={handleCreateLinkSubmit}
destroyOnClose
>
<Form form={linkForm} layout="vertical">
<Row gutter={16}>
<Col span={8}>
<Form.Item name="system" label="指定系统" rules={[{ required: true }]}>
<Select
options={[
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用', value: '通用' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="serverZone" label="指定区服" rules={[{ required: true }]}>
<Select
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="series" label="枪系/系列" rules={[{ required: true }]}>
<Select
options={[
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
{ label: '矩阵', value: '矩阵' },
{ label: '其他', value: '其他' },
]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="diamondLevel" label="钻石等级" rules={[{ required: true }]}>
<Select
options={[
{ label: '1 钻', value: 1 },
{ label: '2 钻', value: 2 },
{ label: '3 钻', value: 3 },
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="brillianceCount" label="璀璨数">
<InputNumber min={0} max={6} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="presetPrice" label="指导收购价 (元)">
<InputNumber min={0} style={{ width: '100%' }} placeholder="如 80" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="title"
label="链接标题 (留空则根据规格自动生成)"
tooltip="玩家打开链接时看到的标题"
<Card size="small">
<Space wrap size={12}>
<Radio.Group
value={submissionStatus}
onChange={(e) => {
setSubmissionStatus(e.target.value)
setSubmissionPage(1)
}}
buttonStyle="solid"
>
<Input placeholder="如:【高价收】苹果微信暗潮1钻 极速审核" />
</Form.Item>
<Radio.Button value="pending_review">
待客服审核
{pendingCount > 0 ? (
<Badge count={pendingCount} size="small" offset={[6, -2]} />
) : null}
</Radio.Button>
<Radio.Button value="approved">已审核入库</Radio.Button>
<Radio.Button value="rejected">已驳回</Radio.Button>
<Radio.Button value="all">全部</Radio.Button>
</Radio.Group>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="buyerName" label="默认收购人">
<Input placeholder="客服姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="inboundBatch" label="归属批次">
<Input placeholder="如 202609-01" />
</Form.Item>
</Col>
</Row>
<Input.Search
placeholder="搜索提报单号/微信号/游戏ID/打手"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setSubmissionKeyword(v)
setSubmissionPage(1)
}}
/>
<Form.Item name="isMultiUse" label="允许多次提报" valuePropName="checked">
<Switch defaultChecked checkedChildren="多人可填" unCheckedChildren="单次失效" />
</Form.Item>
</Form>
</Modal>
<Button icon={<ReloadOutlined />} onClick={() => submissionsQuery.refetch()}>
刷新
</Button>
</Space>
</Card>
<Card size="small">
<Table
rowKey="id"
columns={submissionColumns}
dataSource={submissions}
loading={submissionsQuery.isLoading}
pagination={{
current: submissionPage,
pageSize: submissionPageSize,
total: submissionTotal,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条提报记录`,
onChange: (p, ps) => {
setSubmissionPage(p)
setSubmissionPageSize(ps)
},
}}
/>
</Card>
{/* 审核入库弹窗 */}
<Modal
title="审核寄卖申请并正式入库"
open={Boolean(approveModalItem)}
@@ -716,24 +396,66 @@ export default function ConsignmentSubmissionsPanel() {
</Row>
<Row gutter={16}>
<Col span={6}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Col span={5}>
<Form.Item name="series" label="系列">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="diamondLevel" label="钻石等级">
<InputNumber min={1} max={6} style={{ width: '100%' }} />
<Col span={5}>
<Form.Item name="diamondLevel" label="钻石等级 (1-7)">
<InputNumber min={1} max={7} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="brillianceCount" label="璀璨数 (0-7)">
<InputNumber min={0} max={7} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="workerId"
label="走打手接单平台 (关联履约打手)"
tooltip="指派该打手接单丢枪交货时,接单平台将自动冻结其押金并担保交付"
>
<Select
allowClear
showSearch
filterOption={(input, option) =>
String(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
placeholder="可选择关联的接单打手 (选填)"
options={workerOptions}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
name="depositAmount"
label="履约押金金额 (元)"
tooltip="指派该打手交枪履约时,系统冻结的押金金额"
>
<InputNumber
min={0}
step={10}
addonBefore="¥"
style={{ width: '100%' }}
placeholder="如 100"
/>
</Form.Item>
</Col>
</Row>
@@ -788,7 +510,6 @@ export default function ConsignmentSubmissionsPanel() {
</Form>
</Modal>
{/* 驳回弹窗 */}
<Modal
title="驳回寄卖提报"
open={Boolean(rejectModalItem)}
@@ -814,7 +535,6 @@ export default function ConsignmentSubmissionsPanel() {
</Form>
</Modal>
{/* 提报详情查看弹窗 */}
<Modal
title={`提报详情 [${viewingSubmission?.submissionNo}]`}
open={Boolean(viewingSubmission)}
@@ -831,6 +551,9 @@ export default function ConsignmentSubmissionsPanel() {
<Descriptions.Item label="提报单号" span={2}>
<Text copyable>{viewingSubmission.submissionNo}</Text>
</Descriptions.Item>
<Descriptions.Item label="来源" span={2}>
{resolveSubmissionSource(viewingSubmission)}
</Descriptions.Item>
<Descriptions.Item label="物品名称" span={2}>
{viewingSubmission.itemName}
</Descriptions.Item>
@@ -853,6 +576,23 @@ export default function ConsignmentSubmissionsPanel() {
<Descriptions.Item label="卖家游戏ID" span={2}>
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="关联接单打手">
{viewingSubmission.workerId ? (
<Tag color="blue">
{viewingSubmission.workerName || `ID:${viewingSubmission.workerId}`} (
{viewingSubmission.workerPhone || '无手机号'})
</Tag>
) : (
<Text type="secondary">未关联打手账号</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingSubmission.depositAmountFen ? (
<Tag color="gold">¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)}</Tag>
) : (
<Text type="secondary">按平台默认规则</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="卖家收款方式" span={2}>
<Text copyable>{viewingSubmission.sellerPaymentInfo || '-'}</Text>
</Descriptions.Item>
@@ -894,56 +634,6 @@ export default function ConsignmentSubmissionsPanel() {
</Descriptions>
) : null}
</Modal>
{/* 链接生成成功提示弹窗 */}
<Modal
title="收集链接生成成功"
open={Boolean(createdLinkUrl)}
onCancel={() => setCreatedLinkUrl('')}
footer={[
<Button
key="copy"
type="primary"
icon={<CopyOutlined />}
onClick={() => {
void navigator.clipboard?.writeText(createdLinkUrl)
message.success('链接已复制到剪贴板!')
}}
>
复制公开链接
</Button>,
<Button key="close" onClick={() => setCreatedLinkUrl('')}>
完成
</Button>,
]}
>
<Alert
type="success"
showIcon
message="定向收集链接已就绪"
description="该链接已自动复制到剪贴板。您可以直接发送给玩家卖家,玩家免登录打开即可登记账号与装备信息并上传凭据截图。"
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 8 }}>
<Text strong>公开收集链接:</Text>
</div>
<div
style={{
padding: '12px 16px',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
wordBreak: 'break-all',
}}
>
<Text
copyable={{ text: createdLinkUrl }}
style={{ fontSize: 13, color: '#389e0d', fontWeight: 500 }}
>
{createdLinkUrl}
</Text>
</div>
</Modal>
</div>
)
}
@@ -74,6 +74,7 @@ import {
updateAdminWorkOrderSharing,
} from '@/services/admin'
import {
assignWorkOrderWithConsignmentWorker,
checkWorkOrderConsignmentMatches,
fulfillWorkOrderWithConsignment,
revokeWorkOrderConsignmentFulfillment,
@@ -723,6 +724,24 @@ export default function WorkOrdersPanel() {
}
}
async function handleAssignWithConsignmentWorker(inventoryId: number, workerId?: number | null) {
if (!consignmentInterceptModal.order) return
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
try {
await assignWorkOrderWithConsignmentWorker({
workOrderId: consignmentInterceptModal.order.workOrderId,
inventoryId,
workerId,
})
message.success('已通过接单平台指派打手!系统已自动冻结履约押金,等待打手交货核销')
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
await refreshAll()
} catch (err: any) {
message.error(err.message || '走接单平台指派打手失败')
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
}
}
function openRevokeConsignmentModal(row: WorkOrder) {
setRevokeConsignmentModal({
open: true,
@@ -2879,6 +2898,16 @@ export default function WorkOrdersPanel() {
</strong>
</span>
<span>收购人: {inv.buyerName || '-'}</span>
{inv.workerName ? (
<Tag color="purple">
供货打手: {inv.workerName} ({inv.workerPhone || '无手机号'})
</Tag>
) : null}
{inv.depositAmountFen != null ? (
<Tag color="warning">
履约押金: ¥{(inv.depositAmountFen / 100).toFixed(2)}
</Tag>
) : null}
{inv.shopName ? <span>店铺: {inv.shopName}</span> : null}
{inv.inboundBatch ? <span>批次: {inv.inboundBatch}</span> : null}
</Space>
@@ -2890,13 +2919,25 @@ export default function WorkOrdersPanel() {
</div>
<div style={{ marginLeft: 16 }}>
<Button
type="primary"
loading={consignmentInterceptModal.submitting}
onClick={() => handleFulfillWithConsignment(inv.id)}
>
使用此库存直接履约
</Button>
<Space direction="vertical" align="end" size={8}>
{inv.workerId ? (
<Button
type="primary"
style={{ backgroundColor: '#722ed1', borderColor: '#722ed1' }}
loading={consignmentInterceptModal.submitting}
onClick={() => handleAssignWithConsignmentWorker(inv.id, inv.workerId)}
>
走接单平台指派打手(冻结押金交付)
</Button>
) : null}
<Button
type={inv.workerId ? 'default' : 'primary'}
loading={consignmentInterceptModal.submitting}
onClick={() => handleFulfillWithConsignment(inv.id)}
>
内部现货直接核销
</Button>
</Space>
</div>
</div>
</Card>
@@ -1,7 +1,8 @@
import {
CheckCircleOutlined,
CloudUploadOutlined,
LockOutlined,
DollarOutlined,
SafetyCertificateOutlined,
TrophyOutlined,
} from '@ant-design/icons'
import {
@@ -15,7 +16,6 @@ import {
Image,
Input,
InputNumber,
Radio,
Result,
Row,
Select,
@@ -48,9 +48,38 @@ export default function ConsignmentCollectPage() {
const [submittedResult, setSubmittedResult] = useState<ConsignmentSubmission | null>(null)
const [fileList, setFileList] = useState<UploadFile[]>([])
const [submitting, setSubmitting] = useState(false)
const [depositTouched, setDepositTouched] = useState(false)
const [form] = Form.useForm()
function updateAutoItemName(changedValues?: Record<string, unknown>) {
const sys = form.getFieldValue('system') || '苹果'
const zone = form.getFieldValue('serverZone') || '微信区'
const ser = form.getFieldValue('series') || '暗潮'
const dia = form.getFieldValue('diamondLevel') || 1
const bri = form.getFieldValue('brillianceCount') || 0
const briStr = bri > 0 ? `${bri}璀璨` : ''
const next: Record<string, unknown> = {
itemName: `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南`,
}
if (changedValues && Object.prototype.hasOwnProperty.call(changedValues, 'depositAmount')) {
setDepositTouched(true)
}
if (
changedValues &&
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
}
form.setFieldsValue(next)
}
useEffect(() => {
if (!token) {
setErrorMsg('缺少收集凭证')
@@ -66,15 +95,16 @@ export default function ConsignmentCollectPage() {
form.setFieldsValue({
itemName:
data.title ||
`${data.system}${data.serverZone}${data.series}${data.diamondLevel}钻${briStr}`,
`${data.system || '苹果'}${data.serverZone || '微信区'}${data.series || '暗潮'}${data.diamondLevel || 1}钻${briStr} 苏尔南`,
system: data.system || '苹果',
serverZone: data.serverZone || '微信区',
series: data.series || '暗潮',
diamondLevel: data.diamondLevel || 1,
brillianceCount: data.brillianceCount || 0,
askingPrice: data.presetPriceFen > 0 ? data.presetPriceFen / 100 : undefined,
loginMethod: 'wechat_qrcode',
depositAmount: data.presetPriceFen > 0 ? data.presetPriceFen / 100 : 100,
})
setDepositTouched(false)
})
.catch((err: any) => {
setErrorMsg(err.message || '获取收集信息失败或链接已失效')
@@ -86,15 +116,36 @@ export default function ConsignmentCollectPage() {
async function handleSubmit(values: any) {
if (!token) return
const screenshots = fileList
.filter((f) => f.status === 'done' && (f.response?.url || f.url))
.map((f) => f.response?.url || f.url)
if (screenshots.length === 0) {
message.error('请上传游戏主页截图以供核验!')
return
}
setSubmitting(true)
try {
const askingPriceFen = Math.round(Number(values.askingPrice || 0) * 100)
const screenshots = fileList
.filter((f) => f.status === 'done' && (f.response?.url || f.url))
.map((f) => f.response?.url || f.url)
const depositAmountFen = Math.round(
Number(values.depositAmount || values.askingPrice || 100) * 100,
)
if (askingPriceFen < 100) {
message.error('寄售价格不能少于1元')
return
}
if (depositAmountFen < 100) {
message.error('履约押金不能少于1元')
return
}
const payload = {
itemName: values.itemName,
itemName:
values.itemName ||
`${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻苏尔南`,
system: values.system,
serverZone: values.serverZone,
series: values.series,
@@ -103,22 +154,21 @@ export default function ConsignmentCollectPage() {
sellerContact: values.sellerContact,
contactInfo: values.sellerContact,
sellerPhone: values.sellerPhone,
sellerGameId: values.sellerGameId,
sellerPaymentInfo: values.sellerPaymentInfo,
sellerGameId: `${values.characterName} (${values.characterId})`,
sellerPaymentInfo: '走接单平台钱包结算',
askingPriceFen,
depositAmountFen,
screenshotsJson: screenshots,
contentJson: {
loginMethod: values.loginMethod,
account: values.account,
password: values.password,
authNote: values.authNote,
characterName: values.characterName,
characterId: values.characterId,
sellerNote: values.sellerNote,
},
}
const res = await submitPublicConsignment(token, payload)
setSubmittedResult(res.data)
message.success('寄卖申请提交成功!')
message.success('苏尔南手枪寄卖登记提交成功!')
} catch (err: any) {
message.error(err.message || '提交失败,请重试')
} finally {
@@ -128,16 +178,16 @@ export default function ConsignmentCollectPage() {
if (loading) {
return (
<div style={{ padding: '60px 20px', textAlign: 'center' }}>
<div style={{ padding: '80px 20px', textAlign: 'center' }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: '#8c8c8c' }}>正在加载寄卖登记信息...</div>
<div style={{ marginTop: 16, color: '#8c8c8c' }}>正在加载苏尔南手枪寄卖登记信息...</div>
</div>
)
}
if (errorMsg) {
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 16px' }}>
<div style={{ maxWidth: 600, margin: '60px auto', padding: '0 16px' }}>
<Result
status="warning"
title="链接无法访问"
@@ -150,24 +200,27 @@ export default function ConsignmentCollectPage() {
if (submittedResult) {
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 16px' }}>
<div style={{ maxWidth: 640, margin: '40px auto', padding: '0 16px' }}>
<Card
bordered={false}
style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.05)', borderRadius: 12 }}
style={{ boxShadow: '0 4px 16px rgba(0,0,0,0.06)', borderRadius: 16 }}
>
<Result
status="success"
title="寄卖登记提交成功!"
title="苏尔南手枪寄售登记成功!"
subTitle={
<div>
<div>
您的登记单号:
<Text strong copyable>
<div style={{ fontSize: 15, margin: '8px 0' }}>
登记单号:
<Text strong copyable style={{ color: '#1890ff' }}>
{submittedResult.submissionNo}
</Text>
</div>
<div style={{ marginTop: 8 }}>
客服将在核实账号及物品品质后,通过您填写的联系方式与您确认交接并打款。
<div style={{ marginTop: 12, color: '#595959', lineHeight: 1.7 }}>
您的寄售手枪已进入<strong>打手接单平台寄售池</strong>。
<br />
匹配到买家工单后,系统将通过接单平台指派您进行局内交付,并自动执行
<strong>押金保障机制</strong>。交付验收后货款将直达您的账户。
</div>
</div>
}
@@ -186,7 +239,7 @@ export default function ConsignmentCollectPage() {
<div
style={{
minHeight: '100vh',
background: '#f5f7fa',
background: '#f0f2f5',
padding: '24px 16px 60px',
}}
>
@@ -195,26 +248,26 @@ export default function ConsignmentCollectPage() {
<Card
bordered={false}
style={{
borderRadius: 12,
borderRadius: 16,
marginBottom: 16,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
boxShadow: '0 4px 14px rgba(24, 144, 255, 0.15)',
background: 'linear-gradient(135deg, #096dd9 0%, #1890ff 50%, #40a9ff 100%)',
color: '#fff',
}}
>
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TrophyOutlined style={{ fontSize: 24, color: '#ffd666' }} />
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<TrophyOutlined style={{ fontSize: 26, color: '#ffd666' }} />
<Title level={4} style={{ color: '#fff', margin: 0 }}>
{linkInfo?.title || '和平精英 装备/账号 寄卖登记'}
{linkInfo?.title || '苏尔南手枪 · 走打手接单平台寄卖'}
</Title>
</div>
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', margin: 0, fontSize: 13 }}>
平台保障 · 极速审核 · 验货即打款 · 请如实填写以下信息
<Paragraph style={{ color: 'rgba(255,255,255,0.92)', margin: 0, fontSize: 13.5 }}>
平台保障 · 走接单平台押金履约 · 验货即打款 · 杜绝私下跑单
</Paragraph>
{linkInfo ? (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{linkInfo.system ? <Tag color="blue">{linkInfo.system}</Tag> : null}
{linkInfo.serverZone ? <Tag color="cyan">{linkInfo.serverZone}</Tag> : null}
{linkInfo.series ? <Tag color="orange">{linkInfo.series}</Tag> : null}
@@ -232,22 +285,55 @@ export default function ConsignmentCollectPage() {
</Space>
</Card>
{/* 押金保障卡片 */}
<Alert
showIcon
icon={<SafetyCertificateOutlined style={{ fontSize: 20, color: '#52c41a' }} />}
type="success"
message="走打手接单平台 · 押金保障机制"
description="寄售手枪统一通过接单平台派单交付。登记时可填写履约押金(默认跟寄售价一致);指派交枪时冻结押金,买家验货确认后解冻并结算货款。"
style={{ marginBottom: 16, borderRadius: 12, border: '1px solid #b7eb8f' }}
/>
{/* 表单填写区 */}
<Card
bordered={false}
style={{
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
borderRadius: 16,
boxShadow: '0 2px 10px rgba(0,0,0,0.04)',
}}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Title level={5} style={{ marginBottom: 16 }}>
1. 物品与规格确认
</Title>
<Form
form={form}
layout="vertical"
initialValues={{ depositAmount: 100 }}
onFinish={handleSubmit}
onValuesChange={(changed) => updateAutoItemName(changed)}
>
{/* 1. 枪系与规格 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 16,
borderBottom: '1px solid #f0f0f0',
paddingBottom: 8,
}}
>
<TrophyOutlined style={{ color: '#1890ff', fontSize: 18 }} />
<Title level={5} style={{ margin: 0 }}>
1. 苏尔南手枪规格与价格
</Title>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="system" label="操作系统" rules={[{ required: true }]}>
<Form.Item
name="system"
label="操作系统"
rules={[{ required: true, message: '请选择操作系统' }]}
>
<Select
options={[
{ label: '苹果 (iOS)', value: '苹果' },
@@ -258,7 +344,11 @@ export default function ConsignmentCollectPage() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="serverZone" label="游戏区服" rules={[{ required: true }]}>
<Form.Item
name="serverZone"
label="游戏区服"
rules={[{ required: true, message: '请选择游戏区服' }]}
>
<Select
options={[
{ label: '微信区', value: '微信区' },
@@ -271,20 +361,26 @@ export default function ConsignmentCollectPage() {
<Row gutter={12}>
<Col span={8}>
<Form.Item name="series" label="系列/枪系" rules={[{ required: true }]}>
<Form.Item
name="series"
label="系列/枪系"
rules={[{ required: true, message: '请选择枪系' }]}
>
<Select
options={[
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
{ label: '矩阵', value: '矩阵' },
{ label: '其他', value: '其他' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="diamondLevel" label="钻石等级" rules={[{ required: true }]}>
<Form.Item
name="diamondLevel"
label="钻石等级 (1-7)"
rules={[{ required: true, message: '请选择钻石等级' }]}
>
<Select
options={[
{ label: '1 钻', value: 1 },
@@ -293,89 +389,138 @@ export default function ConsignmentCollectPage() {
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
{ label: '7 钻', value: 7 },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="brillianceCount" label="璀璨数">
<InputNumber min={0} max={6} style={{ width: '100%' }} />
<Form.Item name="brillianceCount" label="璀璨数 (0-7)">
<Select
options={[
{ label: '无璀璨 (0)', value: 0 },
{ label: '1 璀璨', value: 1 },
{ label: '2 璀璨', value: 2 },
{ label: '3 璀璨', value: 3 },
{ label: '4 璀璨', value: 4 },
{ label: '5 璀璨', value: 5 },
{ label: '6 璀璨', value: 6 },
{ label: '7 璀璨', value: 7 },
]}
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="itemName"
label="物品/账号名称"
rules={[{ required: true, message: '请输入物品名称' }]}
>
<Input placeholder="如:苹果微信暗潮1钻" />
</Form.Item>
<Row gutter={12}>
<Col span={10}>
<Form.Item
name="itemName"
label="物品名称"
rules={[{ required: true, message: '请输入物品名称' }]}
>
<Input placeholder="如:苹果微信暗潮1钻 苏尔南" />
</Form.Item>
</Col>
<Col span={7}>
<Form.Item
name="askingPrice"
label="寄售价格 (元)"
rules={[{ required: true, message: '请填写寄售价格' }]}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder="如 80"
addonBefore="¥"
/>
</Form.Item>
</Col>
<Col span={7}>
<Form.Item
name="depositAmount"
label="履约押金 (元)"
rules={[{ required: true, message: '请填写履约押金' }]}
tooltip="指派打手交枪时冻结;验货通过后解冻。建议不低于寄售价。"
extra="默认跟寄售价一致,也可手动调整"
>
<InputNumber
min={1}
step={10}
style={{ width: '100%' }}
placeholder="如 100"
addonBefore="¥"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="askingPrice"
label="期望转让/收购价格 (元)"
rules={[{ required: true, message: '请填写出价' }]}
{/* 2. 卖家联系方式与游戏凭据 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
margin: '24px 0 16px',
borderBottom: '1px solid #f0f0f0',
paddingBottom: 8,
}}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder="请输入您的心理价位 (元)"
/>
</Form.Item>
<Title level={5} style={{ margin: '24px 0 16px' }}>
2. 卖家联系与收款方式
</Title>
<SafetyCertificateOutlined style={{ color: '#52c41a', fontSize: 18 }} />
<Title level={5} style={{ margin: 0 }}>
2. 卖家联系方式与游戏凭据
</Title>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="sellerContact"
label="您的微信号/QQ号"
rules={[{ required: true, message: '请填写联系微信号或QQ' }]}
rules={[{ required: true, message: '请填写微信号或QQ' }]}
>
<Input placeholder="方便客服核实联系" />
<Input placeholder="方便客服核实与通知" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sellerPhone" label="联系电话">
<Input placeholder="手机号 (选填)" />
<Form.Item
name="sellerPhone"
label="联系电话 (用于打手平台关联)"
rules={[{ required: true, message: '请填写联系电话' }]}
tooltip="如您已是接单平台打手,请填写注册手机号,系统将自动识别并关联您的打手账户与押金额度"
>
<Input placeholder="手机号 (用于匹配打手)" />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="characterName"
label="游戏内角色名"
rules={[{ required: true, message: '请填写游戏内角色名' }]}
>
<Input placeholder="如:战神张三 (用于局内加好友丢枪)" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="characterId"
label="角色数字ID"
rules={[{ required: true, message: '请填写角色数字ID' }]}
>
<Input placeholder="如:1089238472 (核准号主身份)" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="sellerGameId"
label="游戏内角色名 / 角色数字ID"
rules={[{ required: true, message: '请填写游戏内角色名称或ID' }]}
label="游戏主页截图 (必须包含角色名、ID及仓库/手枪详情)"
required
tooltip="请上传游戏个人主页或背包手枪品质截图,供客服审核真实性"
>
<Input placeholder="如:和平精英-玩家张三 (用于核实号主)" />
</Form.Item>
<Form.Item
name="sellerPaymentInfo"
label="收款账户 (支付宝账号及姓名 或 微信收款卡号)"
rules={[{ required: true, message: '请填写收款信息' }]}
>
<Input placeholder="如:支付宝 13800000000 (张三) / 工商银行 6222... (张三)" />
</Form.Item>
<Title level={5} style={{ margin: '24px 0 16px' }}>
3. 凭据截图与账号交接 (仅内部审核验号可见)
</Title>
<Alert
showIcon
icon={<LockOutlined />}
type="info"
message="信息安全保障"
description="您填写的账号凭据与截图受平台严格加密保护,仅由官方指定客服在验货核实时可见,绝不泄露给任何无关第三方。"
style={{ marginBottom: 16 }}
/>
<Form.Item label="游戏内物品/品质/仓库截图证明 (最多4张)">
<Upload
listType="picture-card"
fileList={fileList}
@@ -393,41 +538,18 @@ export default function ConsignmentCollectPage() {
>
{fileList.length < 4 ? (
<div>
<CloudUploadOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ marginTop: 6, fontSize: 12 }}>上传截图</div>
<CloudUploadOutlined style={{ fontSize: 22, color: '#1890ff' }} />
<div style={{ marginTop: 6, fontSize: 12 }}>上传主页截图</div>
</div>
) : null}
</Upload>
</Form.Item>
<Form.Item name="loginMethod" label="交付/上号方式">
<Radio.Group>
<Radio value="wechat_qrcode">微信扫码登录</Radio>
<Radio value="qq_qrcode">QQ扫码登录</Radio>
<Radio value="account_pwd">账号密码交付</Radio>
<Radio value="in_game_transfer">游戏内转赠/投喂</Radio>
</Radio.Group>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="account" label="游戏账号/上号手机号">
<Input placeholder="如账号密码交付请填写" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="password" label="上号密码">
<Input.Password placeholder="如需密码登录请填写" />
</Form.Item>
</Col>
</Row>
<Form.Item name="authNote" label="上号与二次验证说明">
<Input placeholder="如:需发微信确认登录、备用安全码、在线时间段等" />
</Form.Item>
<Form.Item name="sellerNote" label="其他留言与补充">
<Input.TextArea rows={2} placeholder="如有其他装备搭配或说明可在此补充" />
<Input.TextArea
rows={2}
placeholder="如有手枪配件、子弹、涂装或在线时间段说明,可在此备注"
/>
</Form.Item>
<Button
@@ -436,14 +558,16 @@ export default function ConsignmentCollectPage() {
size="large"
block
loading={submitting}
icon={<DollarOutlined />}
style={{
marginTop: 16,
height: 48,
fontSize: 16,
borderRadius: 8,
borderRadius: 10,
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
}}
>
提交寄卖申请并登记
提交苏尔南手枪寄售登记
</Button>
</Form>
</Card>
@@ -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<RecordFilter>('all')
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
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<string, unknown>) {
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<string, unknown> = {}
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<string, any>) {
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 (
<section className="page-stack worker-consignment-page">
<div className="worker-orders-desktop-header">
<div className="worker-orders-header-left">
<Typography.Title level={3} className="worker-orders-heading">
苏尔南寄卖
</Typography.Title>
<span className="worker-orders-live-badge">
<span className="worker-orders-pulse-dot" />
接单平台押金履约
</span>
</div>
<div className="worker-orders-header-right">
<Button
icon={<ReloadOutlined />}
loading={myConsignmentsQuery.isFetching}
onClick={() => myConsignmentsQuery.refetch()}
>
刷新
</Button>
</div>
</div>
<div className="worker-orders-kpi-grid">
<div
className={`worker-orders-kpi-card ${recordFilter === 'pending' && activeTab === 'records' ? 'active' : ''}`}
onClick={() => {
setRecordFilter('pending')
setActiveTab('records')
}}
>
<div className="worker-orders-kpi-top">
<div className="worker-orders-kpi-icon-wrap pending">
<ClockCircleOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">审核中 / 驳回</span>
<strong className="worker-orders-kpi-val">
{pendingCount + rejectedCount}
<span className="worker-orders-kpi-unit"> 单</span>
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">
{pendingCount > 0 ? `${pendingCount} 单待客服审核` : '暂无待审提报'}
</span>
</div>
</div>
<div
className={`worker-orders-kpi-card ${recordFilter === 'in_stock' && activeTab === 'records' ? 'active' : ''}`}
onClick={() => {
setRecordFilter('in_stock')
setActiveTab('records')
}}
>
<div className="worker-orders-kpi-top">
<div className="worker-orders-kpi-icon-wrap in-progress">
<InboxOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">在库待派单</span>
<strong className="worker-orders-kpi-val">
{inStockCount}
<span className="worker-orders-kpi-unit"> 把</span>
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">已入库,等待匹配买家工单</span>
</div>
</div>
<div
className={`worker-orders-kpi-card ${recordFilter === 'done' && activeTab === 'records' ? 'active' : ''}`}
onClick={() => {
setRecordFilter('done')
setActiveTab('records')
}}
>
<div className="worker-orders-kpi-top">
<div className="worker-orders-kpi-icon-wrap accepted">
<CheckCircleOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">已履约出库</span>
<strong className="worker-orders-kpi-val">
{doneCount}
<span className="worker-orders-kpi-unit"> 把</span>
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">押金履约交付完成</span>
</div>
</div>
<div
className={`worker-orders-kpi-card ${activeTab === 'publish' ? 'active' : ''}`}
onClick={() => setActiveTab('publish')}
>
<div className="worker-orders-kpi-top">
<div className="worker-orders-kpi-icon-wrap problem">
<PlusOutlined />
</div>
<div className="worker-orders-kpi-info">
<span className="worker-orders-kpi-title">发布寄卖</span>
<strong className="worker-orders-kpi-val" style={{ fontSize: 18 }}>
苏尔南
</strong>
</div>
</div>
<div className="worker-orders-kpi-bottom">
<span className="worker-orders-kpi-sub-label">暗潮 / 怒嚎 / 血宴 · 押金履约</span>
</div>
</div>
</div>
<Card bordered={false} className="worker-orders-card">
<Tabs
className="worker-order-tabs"
activeKey={activeTab}
onChange={(key) => setActiveTab(key as 'publish' | 'records')}
items={[
{
key: 'publish',
label: (
<span>
<DollarCircleOutlined /> 发布寄卖
</span>
),
children: (
<Form
form={form}
layout="vertical"
className="worker-consignment-form"
onFinish={handlePublishSubmit}
onValuesChange={handleFormValuesChange}
>
<Alert
type="info"
showIcon
icon={<SafetyCertificateOutlined />}
className="worker-consignment-tip"
message="走接单平台押金履约:审核入库后匹配买家工单,系统按登记押金冻结钱包;局内交枪验货通过后解冻并结算货款。"
/>
<Divider orientation="left" plain className="worker-consignment-divider">
规格与价格
</Divider>
<Row gutter={[12, 0]}>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="system"
label="操作系统"
rules={[{ required: true, message: '请选择系统' }]}
>
<Select
placeholder="请选择"
options={[
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用/双端', value: '通用' },
]}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="serverZone"
label="游戏区服"
rules={[{ required: true, message: '请选择区服' }]}
>
<Select
placeholder="请选择"
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="series"
label="系列/枪系"
rules={[{ required: true, message: '请选择枪系' }]}
>
<Select placeholder="请选择" options={SERIES_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="diamondLevel"
label="钻石等级"
rules={[{ required: true, message: '请选择钻级' }]}
>
<Select placeholder="请选择" options={DIAMOND_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="brillianceCount"
label="璀璨数"
rules={[{ required: true, message: '请选择璀璨数' }]}
>
<Select placeholder="请选择" options={BRILLIANCE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={24} sm={16} md={12}>
<Form.Item
name="itemName"
label="物品名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="选择规格后自动生成,也可手动修改" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="askingPrice"
label="寄售价格 (元)"
rules={[{ required: true, message: '请填写寄售价格' }]}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder="请输入"
addonBefore="¥"
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6}>
<Form.Item
name="depositAmount"
label="履约押金 (元)"
rules={[{ required: true, message: '请填写履约押金' }]}
tooltip="派单交枪时冻结,验货后解冻。填写寄售价后可自动带出,也可手动改。"
>
<InputNumber
min={1}
step={10}
style={{ width: '100%' }}
placeholder="请输入"
addonBefore="¥"
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left" plain className="worker-consignment-divider">
联系方式与游戏凭据
</Divider>
<Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="sellerContact"
label="微信号 / QQ号"
rules={[{ required: true, message: '请填写微信号或QQ' }]}
>
<Input placeholder="方便派单与紧急联系" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerPhone"
label="联系电话"
tooltip="默认使用当前接单账号绑定手机号"
>
<Input placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterName"
label="游戏内角色名"
rules={[{ required: true, message: '请填写角色名' }]}
>
<Input placeholder="局内加好友丢枪用" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterId"
label="角色数字ID"
rules={[{ required: true, message: '请填写数字ID' }]}
>
<Input placeholder="如 1089238472" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="游戏主页截图"
required
extra="须包含角色名、ID 及仓库/手枪详情,最多 4 张"
>
<ImageUpload
value={screenshots}
onChange={setScreenshots}
scene="consignment_screenshot"
scope="worker"
maxCount={4}
showPasteHint={!isMobile}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="sellerNote" label="其他留言与补充">
<Input.TextArea
rows={2}
placeholder="配件、子弹、涂装或在线时段等(选填)"
/>
</Form.Item>
</Col>
</Row>
<div className="worker-consignment-actions">
<Button
type="primary"
htmlType="submit"
size="large"
loading={submitting}
icon={<CloudUploadOutlined />}
>
提交寄卖提报
</Button>
<Typography.Text type="secondary">
提交后进入客服审核,通过后入库匹配
</Typography.Text>
</div>
</Form>
),
},
{
key: 'records',
label: (
<span>
<HistoryOutlined /> 我的寄卖
{submissions.length + inventories.length > 0 ? (
<span className="worker-order-tab-count">
{submissions.length + inventories.length}
</span>
) : null}
</span>
),
children: (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div className="worker-consignment-filter-row">
{(
[
{ key: 'all', label: '全部' },
{ key: 'pending', label: '审核中' },
{ key: 'in_stock', label: '在库' },
{ key: 'done', label: '已出库' },
] as const
).map((item) => (
<Button
key={item.key}
size="small"
type={recordFilter === item.key ? 'primary' : 'default'}
onClick={() => setRecordFilter(item.key)}
>
{item.label}
</Button>
))}
</div>
{myConsignmentsQuery.isLoading ? (
<Typography.Text type="secondary">正在加载寄卖记录…</Typography.Text>
) : recordTotal === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无寄卖记录,去发布一单苏尔南吧"
>
<Button type="primary" onClick={() => setActiveTab('publish')}>
发布寄卖
</Button>
</Empty>
) : (
<div className="worker-consignment-record-list">
{filteredRecords.inventories.map((inv) => (
<InventoryRecordCard key={`inv-${inv.id}`} inv={inv} />
))}
{filteredRecords.submissions.map((sub) => (
<SubmissionRecordCard key={`sub-${sub.id}`} sub={sub} />
))}
</div>
)}
</Space>
),
},
]}
/>
</Card>
</section>
)
}
function SpecTags({
system,
serverZone,
series,
diamondLevel,
brillianceCount,
}: {
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
}) {
return (
<Space size={4} wrap>
<Tag>{system}</Tag>
<Tag>{serverZone}</Tag>
<Tag color="orange">{series}</Tag>
<Tag color="gold">{diamondLevel} 钻</Tag>
{brillianceCount > 0 ? <Tag color="magenta">{brillianceCount} 璀璨</Tag> : null}
</Space>
)
}
function InventoryRecordCard({ inv }: { inv: WorkerInventoryItem }) {
return (
<article className="worker-consignment-record-card">
<div className="worker-consignment-record-main">
<div className="worker-consignment-record-title-row">
<Typography.Text strong>{inv.itemName}</Typography.Text>
<Typography.Text type="secondary" className="worker-consignment-record-no">
{inv.inventoryNo}
</Typography.Text>
{inv.status === 'in_stock' ? (
<Tag color="green">在库待派</Tag>
) : inv.status === 'consumed' ? (
<Tag color="blue">已履约</Tag>
) : (
<Tag>已下架</Tag>
)}
</div>
<SpecTags
system={inv.system}
serverZone={inv.serverZone}
series={inv.series}
diamondLevel={inv.diamondLevel}
brillianceCount={inv.brillianceCount}
/>
<div className="worker-consignment-record-meta">
<span>
货款 <strong>¥{(inv.costPriceFen / 100).toFixed(2)}</strong>
</span>
<span>
押金 <strong>¥{((inv.depositAmountFen || 0) / 100).toFixed(0)}</strong>
</span>
{inv.boundPlatformOrderId ? (
<Typography.Text copyable={{ text: inv.boundPlatformOrderId }}>
履约单 {inv.boundPlatformOrderId}
</Typography.Text>
) : null}
</div>
</div>
<Typography.Text type="secondary" className="worker-consignment-record-time">
{inv.createdAt}
</Typography.Text>
</article>
)
}
function SubmissionRecordCard({ sub }: { sub: WorkerConsignmentItem }) {
const shots = Array.isArray(sub.screenshotsJson)
? sub.screenshotsJson.filter((s): s is string => typeof s === 'string')
: []
return (
<article className="worker-consignment-record-card">
<div className="worker-consignment-record-main">
<div className="worker-consignment-record-title-row">
<Typography.Text strong>{sub.itemName}</Typography.Text>
<Typography.Text type="secondary" className="worker-consignment-record-no">
{sub.submissionNo}
</Typography.Text>
{sub.status === 'pending_review' ? (
<Tag color="gold">审核中</Tag>
) : sub.status === 'approved' ? (
<Tag color="green">已通过</Tag>
) : (
<Tag color="red">已驳回</Tag>
)}
</div>
<SpecTags
system={sub.system}
serverZone={sub.serverZone}
series={sub.series}
diamondLevel={sub.diamondLevel}
brillianceCount={sub.brillianceCount}
/>
<div className="worker-consignment-record-meta">
<span>
期望价 <strong>¥{(sub.askingPriceFen / 100).toFixed(2)}</strong>
</span>
{sub.reviewNote ? (
<Typography.Text type="danger">审核说明:{sub.reviewNote}</Typography.Text>
) : null}
</div>
{shots.length > 0 ? (
<div className="worker-consignment-record-shots">
<Image.PreviewGroup>
<Space size={8}>
{shots.map((src, i) => (
<Image
key={`${src}-${i}`}
src={src}
width={44}
height={44}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
</div>
) : null}
</div>
<Typography.Text type="secondary" className="worker-consignment-record-time">
{sub.createdAt}
</Typography.Text>
</article>
)
}
@@ -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'
+64
View File
@@ -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<string, unknown>
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<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload)
}
export function fetchWorkerConsignments() {
return apiGet<{ submissions: WorkerConsignmentItem[]; inventories: WorkerInventoryItem[] }>(
'/api/v1/worker/consignment/my-items',
)
}
+118
View File
@@ -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%;
}
}
+8
View File
@@ -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<string, unknown>
screenshotsJson: string[]
askingPriceFen: number
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
status: 'pending_review' | 'approved' | 'rejected'
reviewNote: string
reviewerName: string