feat(worker-platform): 寄卖价格改由接单模板只读定价,我的寄卖支持服务端搜索与分页
1. 寄售价不再由卖家填写:打手端改为只读展示模板价(与履约押金同款只读框),服务端在发布时按同一套规则复算金额,保证展示口径与落库口径一致,直连接口也无法随意定价; 2. 模板缺档按「最接近的已配置档位」报价、距离相同取更低档:后台只配了 1/3/7 钻时,号主出 2 钻报 1 钻价、出 6 钻报 7 钻价,不再因为中间档没配价而拿不到价格; 3. 我的寄卖列表把分组、关键字、翻页全部下沉到 SQL:新增 listWorkerConsignmentPage 用 UNION ALL 合成「提报单行 + 无提报单库存兜底行」,CASE 推导 待审核/履约中/已完成 分组,返回当前分组 total 与三个分组的角标计数; 4. 关键字覆盖寄卖单号、规格名、系统/区服/系列/钻级、卖家角色名与数字 ID、联系方式、买家昵称与游戏 ID(读取 work_orders.material_json.collect.fields 的多种历史键名); 5. 前端搜索框 300ms 防抖后才请求服务端、换分组或换关键字自动回到第 1 页、列表底部增加分页器,并移除原来固定 pageSize 200 的隐形截断; 6. 新增仓库层与匹配层单测,其中包含 SQL 分组 CASE 与 domain/consignment-item-stage.ts 的 group 映射交叉断言,防止两处状态机漂移;后端 516 项、前端 66 项测试全部通过。
This commit is contained in:
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
|
||||
|
||||
test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…),且计数查询带上同一个 join', async () => {
|
||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||
// 卖家端与客服端现在只把提报单号(SUB…)当主标识展示,搜不到它客服等于瞎了
|
||||
@@ -55,3 +57,118 @@ test('占库写入的 allocation_status 默认值必须在 080 CHECK 枚举(
|
||||
`默认值「${match![1]}」违反 080 的 CHECK 约束,真实库必 500`,
|
||||
)
|
||||
})
|
||||
|
||||
function readWorkerPageQuery(source: string) {
|
||||
const start = source.indexOf('export async function listWorkerConsignmentPage')
|
||||
assert.ok(start > 0, '未找到「我的寄卖」分页查询')
|
||||
return source.slice(start, source.indexOf('\n}\n', start))
|
||||
}
|
||||
|
||||
test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/区服/钻级/角色/买家)', async () => {
|
||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||
const body = readWorkerPageQuery(source)
|
||||
|
||||
for (const field of [
|
||||
'cs.submission_no',
|
||||
'cs.item_name',
|
||||
'cs.system',
|
||||
'cs.server_zone',
|
||||
'cs.series',
|
||||
"cs.diamond_level::text || '钻'",
|
||||
'cs.seller_game_id',
|
||||
'cs.contact_info',
|
||||
'ci.inventory_no',
|
||||
"wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname'",
|
||||
"wo.material_json -> 'collect' -> 'fields' ->> 'gameId'",
|
||||
]) {
|
||||
assert.ok(body.includes(field), `关键字查询缺少可搜字段:${field}`)
|
||||
}
|
||||
|
||||
// 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错
|
||||
const orphanStart = body.indexOf('NULL AS submission_id')
|
||||
const orphanBody = body.slice(orphanStart, body.indexOf(')\n `', orphanStart))
|
||||
assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.')
|
||||
})
|
||||
|
||||
test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => {
|
||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||
const body = readWorkerPageQuery(source)
|
||||
|
||||
assert.ok(body.includes('ORDER BY sort_at DESC'), '排序必须在 SQL 完成')
|
||||
assert.ok(body.includes('LIMIT $'), '必须在 SQL 分页(LIMIT/OFFSET)')
|
||||
assert.ok(body.includes('OFFSET $'), '必须在 SQL 分页(LIMIT/OFFSET)')
|
||||
assert.ok(body.includes('COUNT(*)::integer AS total'), '必须返回总数供分页器使用')
|
||||
// 分组参数用独立的占位符,不能和关键字占位符撞号(撞号会把关键字当分组、条件全错)
|
||||
assert.ok(body.includes('WHERE group_key = $${groupValues.length}'), '分组过滤必须使用独立占位符')
|
||||
})
|
||||
|
||||
test('我的寄卖:SQL 里的分组口径与状态机的 group 映射保持一致', async () => {
|
||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||
const body = readWorkerPageQuery(source)
|
||||
|
||||
// 真源是 domain/consignment-item-stage.ts 的 STAGE_META.group:
|
||||
// pending_review → reviewing;rejected → finished;
|
||||
// 库存 archived → finished;工单 cancelled/accepted/completed → finished;其余 active
|
||||
const reviewerWorkOrder = {
|
||||
status: 'in_progress',
|
||||
acceptance_mode: 'standard',
|
||||
gift_phase: '',
|
||||
gift_available_at: null,
|
||||
friend_added_at: null,
|
||||
}
|
||||
const cases: Array<{ expect: string; patch: Record<string, unknown> }> = [
|
||||
{ expect: 'reviewing', patch: { submissionStatus: 'pending_review', inventoryStatus: null } },
|
||||
{ expect: 'finished', patch: { submissionStatus: 'rejected', inventoryStatus: null } },
|
||||
{
|
||||
expect: 'finished',
|
||||
patch: {
|
||||
submissionStatus: 'approved',
|
||||
inventoryStatus: 'archived',
|
||||
workOrder: reviewerWorkOrder,
|
||||
},
|
||||
},
|
||||
{
|
||||
expect: 'finished',
|
||||
patch: {
|
||||
submissionStatus: 'approved',
|
||||
inventoryStatus: 'consumed',
|
||||
workOrder: { ...reviewerWorkOrder, status: 'cancelled' },
|
||||
},
|
||||
},
|
||||
{
|
||||
expect: 'finished',
|
||||
patch: {
|
||||
submissionStatus: 'approved',
|
||||
inventoryStatus: 'consumed',
|
||||
workOrder: { ...reviewerWorkOrder, status: 'accepted' },
|
||||
},
|
||||
},
|
||||
{
|
||||
expect: 'active',
|
||||
patch: {
|
||||
submissionStatus: 'approved',
|
||||
inventoryStatus: 'consumed',
|
||||
workOrder: reviewerWorkOrder,
|
||||
},
|
||||
},
|
||||
{ expect: 'active', patch: { submissionStatus: 'approved', inventoryStatus: 'in_stock' } },
|
||||
]
|
||||
for (const item of cases) {
|
||||
const stage = deriveConsignmentItemStage({
|
||||
submissionStatus: String(item.patch.submissionStatus),
|
||||
inventoryStatus: (item.patch.inventoryStatus as string | null) ?? null,
|
||||
workOrder: (item.patch.workOrder as never) ?? null,
|
||||
viewer: { role: 'seller', workerId: 1 },
|
||||
})
|
||||
assert.equal(stage.group, item.expect, `状态机口径变化:${JSON.stringify(item.patch)}`)
|
||||
}
|
||||
|
||||
// 上面这些 group 的判定条件必须都能在 SQL 的 CASE 里找到对应分支
|
||||
assert.ok(body.includes("WHEN cs.status = 'pending_review' THEN 'reviewing'"))
|
||||
assert.ok(body.includes("WHEN cs.status = 'rejected' THEN 'finished'"))
|
||||
assert.ok(body.includes("WHEN ci.status = 'archived' THEN 'finished'"))
|
||||
assert.ok(
|
||||
body.includes("WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'"),
|
||||
)
|
||||
assert.ok(body.includes("ELSE 'active'"), '未匹配到的都归 active')
|
||||
})
|
||||
|
||||
@@ -940,6 +940,224 @@ export async function listConsignmentSubmissions(filters: {
|
||||
return { items: listRes.rows, total }
|
||||
}
|
||||
|
||||
/** 我的寄卖分组:与 domain/consignment-item-stage.ts 的 group 口径一致 */
|
||||
export type WorkerConsignmentGroup = 'all' | 'reviewing' | 'active' | 'finished'
|
||||
|
||||
export type WorkerConsignmentRowKey = {
|
||||
submissionId: number | null
|
||||
inventoryId: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type WorkerConsignmentPage = {
|
||||
rows: WorkerConsignmentRowKey[]
|
||||
/** 当前分组 + 关键字下的总条数(分页用) */
|
||||
total: number
|
||||
/** 各分组条数(受关键字影响,不受当前分组影响),与前端筛选按钮角标对齐 */
|
||||
counts: Record<WorkerConsignmentGroup, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* 「我的寄卖」服务端分页 / 搜索 / 分组计数。
|
||||
*
|
||||
* 两件事必须在 SQL 做掉,否则前端只能把全部记录捞回来自己过滤(超过 200 条就丢件):
|
||||
* 1. 关键字:单号 / 规格 / 系统 / 区服 / 系列 / 钻级 / 卖家角色 / 买家资料;
|
||||
* 2. 分组:状态机推导出来的阶段太多,但落到「待审核 / 履约中 / 已完成」三档只取决于
|
||||
* 提报单状态、库存状态、绑定工单状态——下面的 CASE 就是这个映射。
|
||||
* ⚠️ 改 domain/consignment-item-stage.ts 的阶段分组时必须同步改这里
|
||||
* (consignment-repo.test.ts 有一致的断言)。
|
||||
*
|
||||
* 只返回行标识(提报单/库存 id),完整数据由调用方按 id 批量取,避免在这里重复映射一遍字段。
|
||||
*/
|
||||
export async function listWorkerConsignmentPage(input: {
|
||||
workerId: number
|
||||
group?: WorkerConsignmentGroup | undefined
|
||||
keyword?: string | undefined
|
||||
page?: number | undefined
|
||||
pageSize?: number | undefined
|
||||
}): Promise<WorkerConsignmentPage> {
|
||||
const values: unknown[] = [input.workerId]
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
let submissionKeywordFilter = ''
|
||||
let orphanKeywordFilter = ''
|
||||
if (keyword) {
|
||||
values.push(`%${keyword}%`)
|
||||
const p = `$${values.length}`
|
||||
// 提报单行:提报单 + 它产出的库存 + 绑定工单上的买家资料
|
||||
submissionKeywordFilter = `
|
||||
AND (
|
||||
cs.submission_no ILIKE ${p}
|
||||
OR cs.item_name ILIKE ${p}
|
||||
OR cs.system ILIKE ${p}
|
||||
OR cs.server_zone ILIKE ${p}
|
||||
OR cs.series ILIKE ${p}
|
||||
OR cs.diamond_level::text || '钻' ILIKE ${p}
|
||||
OR cs.seller_game_id ILIKE ${p}
|
||||
OR cs.contact_info ILIKE ${p}
|
||||
OR cs.seller_phone ILIKE ${p}
|
||||
OR ci.inventory_no ILIKE ${p}
|
||||
OR ci.item_name ILIKE ${p}
|
||||
OR ci.seller_game_id ILIKE ${p}
|
||||
OR ci.seller_contact ILIKE ${p}
|
||||
OR ci.seller_phone ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'roleName' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'nickname' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameId' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p}
|
||||
)`
|
||||
// 兜底行(没有归属提报单的库存):只能搜库存与买家资料,不能引用提报单别名
|
||||
orphanKeywordFilter = `
|
||||
AND (
|
||||
ci.inventory_no ILIKE ${p}
|
||||
OR ci.item_name ILIKE ${p}
|
||||
OR ci.system ILIKE ${p}
|
||||
OR ci.server_zone ILIKE ${p}
|
||||
OR ci.series ILIKE ${p}
|
||||
OR ci.diamond_level::text || '钻' ILIKE ${p}
|
||||
OR ci.seller_game_id ILIKE ${p}
|
||||
OR ci.seller_contact ILIKE ${p}
|
||||
OR ci.seller_phone ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'roleName' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'nickname' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameId' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p}
|
||||
OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p}
|
||||
)`
|
||||
}
|
||||
// 提报单行(连带它产出的库存与绑定工单)与「没有归属提报单的库存」兜底行
|
||||
const baseSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
cs.id AS submission_id,
|
||||
ci.id AS inventory_id,
|
||||
cs.created_at AS sort_at,
|
||||
CASE
|
||||
WHEN cs.status = 'pending_review' THEN 'reviewing'
|
||||
WHEN cs.status = 'rejected' THEN 'finished'
|
||||
WHEN ci.status = 'archived' THEN 'finished'
|
||||
WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'
|
||||
ELSE 'active'
|
||||
END AS group_key
|
||||
FROM consignment_submissions cs
|
||||
LEFT JOIN consignment_inventories ci ON ci.source_submission_id = cs.id
|
||||
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
WHERE cs.worker_id = $1
|
||||
${submissionKeywordFilter}
|
||||
UNION ALL
|
||||
SELECT
|
||||
NULL AS submission_id,
|
||||
ci.id AS inventory_id,
|
||||
ci.created_at AS sort_at,
|
||||
CASE
|
||||
WHEN ci.status = 'archived' THEN 'finished'
|
||||
WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'
|
||||
ELSE 'active'
|
||||
END AS group_key
|
||||
FROM consignment_inventories ci
|
||||
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
WHERE ci.worker_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM consignment_submissions cs2
|
||||
WHERE cs2.id = ci.source_submission_id AND cs2.worker_id = $1
|
||||
)
|
||||
${orphanKeywordFilter}
|
||||
)
|
||||
`
|
||||
|
||||
const countsRes = await query<{ group_key: string; count: number }>(
|
||||
`${baseSql} SELECT group_key, COUNT(*)::integer AS count FROM base GROUP BY group_key`,
|
||||
values,
|
||||
)
|
||||
const counts: Record<WorkerConsignmentGroup, number> = {
|
||||
all: 0,
|
||||
reviewing: 0,
|
||||
active: 0,
|
||||
finished: 0,
|
||||
}
|
||||
for (const row of countsRes.rows) {
|
||||
const key = String(row.group_key || '') as WorkerConsignmentGroup
|
||||
const count = Number(row.count || 0)
|
||||
if (key === 'reviewing' || key === 'active' || key === 'finished') {
|
||||
counts[key] += count
|
||||
counts.all += count
|
||||
}
|
||||
}
|
||||
|
||||
const group = input.group || 'all'
|
||||
const page = Math.max(1, Number(input.page) || 1)
|
||||
const pageSize = Math.max(1, Math.min(50, Number(input.pageSize) || 10))
|
||||
let groupFilter = ''
|
||||
let groupValues = values
|
||||
if (group !== 'all') {
|
||||
groupValues = [...values, group]
|
||||
groupFilter = `WHERE group_key = $${groupValues.length}`
|
||||
}
|
||||
|
||||
const totalRes = await query<{ total: number }>(
|
||||
`${baseSql} SELECT COUNT(*)::integer AS total FROM base ${groupFilter}`,
|
||||
groupValues,
|
||||
)
|
||||
const total = Number(totalRes.rows[0]?.total || 0)
|
||||
|
||||
const listValues = [...groupValues, pageSize, (page - 1) * pageSize]
|
||||
const listRes = await query<{
|
||||
submission_id: string | number | null
|
||||
inventory_id: string | number | null
|
||||
sort_at: string
|
||||
}>(
|
||||
`${baseSql}
|
||||
SELECT submission_id, inventory_id, sort_at
|
||||
FROM base
|
||||
${groupFilter}
|
||||
ORDER BY sort_at DESC, COALESCE(submission_id, 0) DESC, COALESCE(inventory_id, 0) DESC
|
||||
LIMIT $${listValues.length - 1} OFFSET $${listValues.length}`,
|
||||
listValues,
|
||||
)
|
||||
|
||||
return {
|
||||
rows: listRes.rows.map((row) => ({
|
||||
submissionId: row.submission_id == null ? null : Number(row.submission_id),
|
||||
inventoryId: row.inventory_id == null ? null : Number(row.inventory_id),
|
||||
createdAt: row.sort_at,
|
||||
})),
|
||||
total,
|
||||
counts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 id 批量取提报单(「我的寄卖」分页后回填完整数据用)。
|
||||
* 这里的顺序不重要,调用方会用 Map 按 id 归位。
|
||||
*/
|
||||
export async function listConsignmentSubmissionsByIds(
|
||||
ids: Array<number | string>,
|
||||
): Promise<ConsignmentSubmissionRow[]> {
|
||||
const numericIds = [...new Set(ids.map(Number).filter((n) => Number.isFinite(n) && n > 0))]
|
||||
if (numericIds.length === 0) return []
|
||||
|
||||
const res = await query<ConsignmentSubmissionRow>(
|
||||
`
|
||||
SELECT
|
||||
cs.*,
|
||||
wc.name AS category_name,
|
||||
wo.acceptance_json AS gift_evidence_json,
|
||||
wo.work_order_no AS gift_work_order_no,
|
||||
wo.status AS gift_work_order_status
|
||||
FROM consignment_submissions cs
|
||||
LEFT JOIN work_categories wc ON wc.id = cs.category_id
|
||||
LEFT JOIN consignment_inventories rinv
|
||||
ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id)
|
||||
LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
|
||||
WHERE cs.id = ANY($1::bigint[])
|
||||
`,
|
||||
[numericIds],
|
||||
)
|
||||
return res.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 按提报单精确取现货。
|
||||
*
|
||||
|
||||
@@ -45,19 +45,30 @@ router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/worker/consignment/my-items
|
||||
* 打手查看自己在接单平台寄卖的手枪列表与状态
|
||||
* GET /api/v1/worker/consignment/my-items?group=&keyword=&page=&pageSize=
|
||||
* 打手查看自己在接单平台寄卖的手枪列表与状态(服务端搜索 + 分组 + 分页)
|
||||
*/
|
||||
router.get('/consignment/my-items', async (req, res) => {
|
||||
try {
|
||||
const session = getRequiredWorkerSession(req)
|
||||
const result = await listWorkerConsignments(session.workerId)
|
||||
const result = await listWorkerConsignments(session.workerId, {
|
||||
group: normalizeConsignmentGroup(req.query.group),
|
||||
keyword: String(req.query.keyword || ''),
|
||||
page: Number(req.query.page || 1),
|
||||
pageSize: Number(req.query.pageSize || 10),
|
||||
})
|
||||
res.json({ ok: true, data: result })
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '获取我的寄卖列表失败', '[worker/consignment/my-items]')
|
||||
}
|
||||
})
|
||||
|
||||
function normalizeConsignmentGroup(value: unknown): 'all' | 'reviewing' | 'active' | 'finished' {
|
||||
const raw = String(value || '').trim()
|
||||
if (raw === 'reviewing' || raw === 'active' || raw === 'finished') return raw
|
||||
return 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/worker/consignment/items/:submissionId/confirm-friend
|
||||
* 卖家确认已加好买家好友 → 进入赠送冷却期(确认后未完成赠送押金不退)
|
||||
|
||||
@@ -418,21 +418,80 @@ test('发布寄卖建议价:按 系统+系列+钻级 从接单模板带出(
|
||||
assert.equal(hit.suggestedPriceFen, 12000, '多条命中取最高价(12000 > 9999)')
|
||||
assert.equal(hit.matchedRules.length, 2)
|
||||
|
||||
// 系统不符不命中:苹果的 1钻 没配价
|
||||
// 该端只配了更高档:苹果在暗潮下只有 5钻,1钻没有任何更低档可退 → 落到唯一已配档(5钻)
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 }),
|
||||
null,
|
||||
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 })
|
||||
?.suggestedPriceFen,
|
||||
30000,
|
||||
)
|
||||
// 钻级不符不命中:2钻规则没价格、6钻是另一档
|
||||
// 该端在该系列下完全没有模板才返回 null(苹果+怒嚎:怒嚎只有安卓配过价)
|
||||
// 中间钻级没配价 → 退到最接近的已配置钻级:2钻距 1钻(1) 比 6钻(4) 更近 → 报 1钻价
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 }),
|
||||
null,
|
||||
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 })
|
||||
?.suggestedPriceFen,
|
||||
12000,
|
||||
'2钻未配价时取最接近的 1钻',
|
||||
)
|
||||
// 超出后台最大已配钻级 → 落到最大档
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 7 })
|
||||
?.suggestedPriceFen,
|
||||
50000,
|
||||
'7钻超出配置上限时落到 6钻',
|
||||
)
|
||||
// 系列不符不命中
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '怒嚎', diamondLevel: 1 }),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '怒嚎', diamondLevel: 1 }),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('发布寄卖建议价:钻级/璀璨距离相同时取更低档,不给低配报高价', () => {
|
||||
const rules = [
|
||||
{ ruleKey: 'd1', productName: '安卓暗潮1钻', rewardAmountFen: 10000 },
|
||||
{ ruleKey: 'd3', productName: '安卓暗潮3钻', rewardAmountFen: 30000 },
|
||||
]
|
||||
// 2钻到 1钻/3钻 距离都是 1 → 取更低的 1钻
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 })
|
||||
?.suggestedPriceFen,
|
||||
10000,
|
||||
)
|
||||
// 低于最小已配钻级 → 落到最小档
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 1 })
|
||||
?.suggestedPriceFen,
|
||||
10000,
|
||||
)
|
||||
// 璀璨档缺失:3钻只配了 0璀 与 3璀,号主出 1璀 → 距离 1 与 2 → 取 0璀
|
||||
const brillianceRules = [
|
||||
{ ruleKey: 'b0', productName: '安卓暗潮3钻', rewardAmountFen: 30000 },
|
||||
{ ruleKey: 'b3', productName: '安卓暗潮3钻3璀', rewardAmountFen: 45000 },
|
||||
]
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(brillianceRules, {
|
||||
system: '安卓',
|
||||
series: '暗潮',
|
||||
diamondLevel: 3,
|
||||
brillianceCount: 1,
|
||||
})?.suggestedPriceFen,
|
||||
30000,
|
||||
'1璀未配价时取最接近的 0璀',
|
||||
)
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(brillianceRules, {
|
||||
system: '安卓',
|
||||
series: '暗潮',
|
||||
diamondLevel: 3,
|
||||
brillianceCount: 5,
|
||||
})?.suggestedPriceFen,
|
||||
45000,
|
||||
'超出最大璀璨档时落到 3璀',
|
||||
)
|
||||
})
|
||||
|
||||
test('发布寄卖建议价:模板写「通用/双端」时两端都命中', () => {
|
||||
@@ -551,7 +610,7 @@ test('发布寄卖建议价:按璀璨数精准区分价格,不会同钻级
|
||||
assert.equal(hit5?.suggestedPriceFen, 28000)
|
||||
})
|
||||
|
||||
test('发布寄卖建议价:号主钻级比后台配置高时,按已配置的最高钻级报价(7钻报6钻价,低钻不报高钻)', () => {
|
||||
test('发布寄卖建议价:号主钻级比后台配置高时按最高档,缺档时取最接近档(距离相同取更低档)', () => {
|
||||
const rules = [
|
||||
{ ruleKey: 'rule-1z', productName: '安卓怒嚎1钻', rewardAmountFen: 10000 },
|
||||
{ ruleKey: 'rule-6z-0c', productName: '安卓怒嚎6钻', rewardAmountFen: 35000 },
|
||||
@@ -579,16 +638,17 @@ test('发布寄卖建议价:号主钻级比后台配置高时,按已配置
|
||||
assert.equal(hit7z5c?.suggestedPriceFen, 42000)
|
||||
assert.equal(hit7z5c?.matchedRules[0].ruleKey, 'rule-6z-2c')
|
||||
|
||||
// 低配不能充高配:号主出 2钻(未超出 6钻 上限,但未单独配置 2钻):不命中返回 null,绝不误报 6钻
|
||||
// 号主出 2钻:后台只配了 1钻 与 6钻,2钻到 1钻(距离1) 比到 6钻(距离4) 更近 → 报 1钻价,绝不误报 6钻
|
||||
const hit2z = suggestConsignmentPriceFromRules(rules, {
|
||||
system: '安卓',
|
||||
series: '怒嚎',
|
||||
diamondLevel: 2,
|
||||
brillianceCount: 0,
|
||||
})
|
||||
assert.equal(hit2z, null)
|
||||
assert.equal(hit2z?.suggestedPriceFen, 10000)
|
||||
assert.equal(hit2z?.matchedRules[0].ruleKey, 'rule-1z')
|
||||
|
||||
// 号主出 1钻,但如果后台只有 3钻 以上规则,不命中返回 null
|
||||
// 号主出 1钻,后台只有 3钻 规则:没有任何更低档可退,只能落到唯一的最低已配档(3钻)
|
||||
const onlyHighRules = [{ ruleKey: 'rule-3z', productName: '安卓怒嚎3钻', rewardAmountFen: 20000 }]
|
||||
assert.equal(
|
||||
suggestConsignmentPriceFromRules(onlyHighRules, {
|
||||
@@ -596,8 +656,8 @@ test('发布寄卖建议价:号主钻级比后台配置高时,按已配置
|
||||
series: '怒嚎',
|
||||
diamondLevel: 1,
|
||||
brillianceCount: 0,
|
||||
}),
|
||||
null,
|
||||
})?.suggestedPriceFen,
|
||||
20000,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -623,12 +623,12 @@ export type ConsignmentPriceSuggestionRule = {
|
||||
*
|
||||
* 模板的商品名(如「安卓暗潮1钻」)用与订单同一套规格解析器拆解:
|
||||
* 系统(含通用通配)、系列必须一致;区服不参与比对。
|
||||
* 钻级与璀璨数支持高配向下兜底匹配(号主出得比配置高时,报价当前已配置的最高规格):
|
||||
* 1. 钻级对齐:优先精确匹配号主钻级;若号主钻级高于后台配置上限(如号主出7钻但后台只配了1~6钻),
|
||||
* 按后台已配置的最大钻级(6钻)报价。低钻不能配高钻(号主出1钻不报6钻价)。
|
||||
* 2. 璀璨对齐:在该钻级下,优先精确匹配号主璀璨;若号主璀璨高于后台配置上限(如号主出3~5璀但后台只配了1~2璀),
|
||||
* 按后台该钻级已配置的最大璀璨档位报价。
|
||||
* 3. 命中多条(如通用与专属端同存)取最高价,卖家仍可手动修改。
|
||||
* 钻级与璀璨数按「最接近的已配置档位」兜底,保证规格选齐后一定给得出价格:
|
||||
* 1. 钻级对齐:优先精确匹配号主钻级;该钻级没配价时取最接近的已配置钻级,
|
||||
* 距离相同取更低档(号主出2钻、后台只配了1钻与3钻 → 报1钻价)。低钻不能配高钻。
|
||||
* 2. 璀璨对齐:在该钻级下,优先精确匹配号主璀璨;该璀璨档没配价时同样取最接近的已配置档位,距离相同取更低档。
|
||||
* 3. 命中多条(如通用与专属端同存)取最高价。
|
||||
* 只有当「该系列在该端下完全没有配置过任何有价模板」时才返回 null。
|
||||
*/
|
||||
export function suggestConsignmentPriceFromRules(
|
||||
rules: ConsignmentPriceSuggestionRule[],
|
||||
@@ -671,10 +671,11 @@ export function suggestConsignmentPriceFromRules(
|
||||
if (seriesCandidates.length === 0) return null
|
||||
|
||||
// 2. 钻级对齐:
|
||||
// 查找当前系统与系列已配置的最大钻级;若号主出的钻级比后台配置高(如后台只设置了6个价格,号主出7个),
|
||||
// 按后台已配置的最大钻级报价;未超出配置上限时必须精确对齐钻级(低钻不报高钻,中间未配价的钻级不向下误报)
|
||||
const maxConfiguredDiamond = Math.max(...seriesCandidates.map((item) => item.diamondLevel))
|
||||
const targetDiamond = wantedDiamond > maxConfiguredDiamond ? maxConfiguredDiamond : wantedDiamond
|
||||
// 优先精确对齐;该钻级没配价时退到「最接近的已配置钻级」(如后台只配了 1/3/7 钻、号主出 2 钻 → 取 1 钻),
|
||||
// 距离相同取更低档,避免把低钻按高钻价报出去;号主钻级高于后台已配最大档时自然落到最大档。
|
||||
const configuredDiamonds = Array.from(new Set(seriesCandidates.map((item) => item.diamondLevel)))
|
||||
const targetDiamond = pickNearestConfiguredTier(configuredDiamonds, wantedDiamond)
|
||||
if (targetDiamond === null) return null
|
||||
|
||||
const targetDiamondCandidates = seriesCandidates.filter(
|
||||
(item) => item.diamondLevel === targetDiamond,
|
||||
@@ -682,25 +683,20 @@ export function suggestConsignmentPriceFromRules(
|
||||
if (targetDiamondCandidates.length === 0) return null
|
||||
|
||||
// 3. 璀璨对齐:
|
||||
// 若未指定璀璨(null),取 targetDiamond 下所有规则
|
||||
// 若指定了璀璨:比后台配置高(如后台该钻级只配置到 1~2 璀,号主出 3/4/5 等),按已配置的最大璀璨报价;
|
||||
// 未超出配置上限时严格精确对齐璀璨档位(0璀对0璀、1璀对1璀,不拿高璀价格报给低璀)
|
||||
// 若未指定璀璨(null),取 targetDiamond 下所有规则;
|
||||
// 若指定了璀璨:优先精确对齐,该璀璨档没配价时退到最接近的已配置档位,距离相同取更低档。
|
||||
let matchedItems: typeof targetDiamondCandidates
|
||||
if (wantedBrilliance === null) {
|
||||
matchedItems = targetDiamondCandidates
|
||||
} else {
|
||||
const maxConfiguredBrilliance = Math.max(
|
||||
...targetDiamondCandidates.map((item) => item.brillianceCount),
|
||||
const configuredBrilliance = Array.from(
|
||||
new Set(targetDiamondCandidates.map((item) => item.brillianceCount)),
|
||||
)
|
||||
const targetBrilliance = pickNearestConfiguredTier(configuredBrilliance, wantedBrilliance)
|
||||
if (targetBrilliance === null) return null
|
||||
matchedItems = targetDiamondCandidates.filter(
|
||||
(item) => item.brillianceCount === targetBrilliance,
|
||||
)
|
||||
if (wantedBrilliance > maxConfiguredBrilliance) {
|
||||
matchedItems = targetDiamondCandidates.filter(
|
||||
(item) => item.brillianceCount === maxConfiguredBrilliance,
|
||||
)
|
||||
} else {
|
||||
matchedItems = targetDiamondCandidates.filter(
|
||||
(item) => item.brillianceCount === wantedBrilliance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedItems.length === 0) return null
|
||||
@@ -708,3 +704,26 @@ export function suggestConsignmentPriceFromRules(
|
||||
const matchedRules = matchedItems.map((item) => item.rule)
|
||||
return { suggestedPriceFen, matchedRules }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「已配置档位」里挑最接近目标的一档。
|
||||
* 距离相同时取更低档:宁可少报也不要把低配按高配价格报出去。
|
||||
*/
|
||||
function pickNearestConfiguredTier(configured: number[], wanted: number): number | null {
|
||||
const tiers = configured
|
||||
.map((value) => Math.round(Number(value)))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.sort((a, b) => a - b)
|
||||
if (tiers.length === 0) return null
|
||||
|
||||
let best = tiers[0]!
|
||||
let bestDistance = Math.abs(best - wanted)
|
||||
for (const tier of tiers) {
|
||||
const distance = Math.abs(tier - wanted)
|
||||
if (distance < bestDistance) {
|
||||
best = tier
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -95,3 +95,15 @@ test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截',
|
||||
'未开通必须返回专门的 consignment_permission_required 错误码',
|
||||
)
|
||||
})
|
||||
|
||||
test('寄售价以接单模板为准:服务端复算模板价,不信任前端传来的金额', async () => {
|
||||
const serviceSource = await readServiceSource()
|
||||
assert.ok(
|
||||
serviceSource.includes('getWorkerConsignmentPriceSuggestion({'),
|
||||
'发布时必须按同一套模板规则复算价格',
|
||||
)
|
||||
assert.ok(
|
||||
serviceSource.includes('templatePriceFen > 0 ? templatePriceFen : submittedPriceFen'),
|
||||
'模板命中时用模板价,只有完全没配模板时才回落到调用方传来的金额',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
createConsignmentSubmissionWithClient,
|
||||
getConsignmentInventoryBySubmissionId,
|
||||
getConsignmentSubmissionById,
|
||||
listConsignmentInventories,
|
||||
listConsignmentSubmissions,
|
||||
listConsignmentInventoriesByIds,
|
||||
listConsignmentSubmissionsByIds,
|
||||
listWorkerConsignmentPage,
|
||||
type WorkerConsignmentGroup,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
|
||||
import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/worker-platform/product-match-repo.js'
|
||||
@@ -85,7 +87,7 @@ export type ConsignmentSellerInfo = {
|
||||
}
|
||||
|
||||
function extractConsignmentSellerInfoFromSubmission(
|
||||
sub: Awaited<ReturnType<typeof listConsignmentSubmissions>>['items'][number],
|
||||
sub: Awaited<ReturnType<typeof listConsignmentSubmissionsByIds>>[number],
|
||||
): ConsignmentSellerInfo {
|
||||
const content = toRecord(sub.content_json) || {}
|
||||
let characterName = String(content.characterName || '').trim()
|
||||
@@ -114,7 +116,7 @@ function extractConsignmentSellerInfoFromSubmission(
|
||||
}
|
||||
|
||||
function extractConsignmentSellerInfoFromInventory(
|
||||
inv: Awaited<ReturnType<typeof listConsignmentInventories>>['items'][number],
|
||||
inv: Awaited<ReturnType<typeof listConsignmentInventoriesByIds>>[number],
|
||||
): ConsignmentSellerInfo {
|
||||
let characterName = ''
|
||||
let characterId = ''
|
||||
@@ -244,13 +246,18 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
})
|
||||
}
|
||||
|
||||
const askingPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
|
||||
if (askingPriceFen < 100) {
|
||||
throw createHttpError('寄售价格不能少于1元', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_asking_price',
|
||||
})
|
||||
}
|
||||
// 寄售价以「接单模板」为准:服务端按同一套规则复算,不信任前端传来的金额
|
||||
// (卖家端已把该字段改成只读展示,这里再兜一道,避免直连接口随意定价)。
|
||||
// 只有该规格在模板里完全没配过价时才回落到调用方传来的值。
|
||||
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
|
||||
system,
|
||||
series,
|
||||
diamondLevel,
|
||||
brillianceCount,
|
||||
})
|
||||
const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0)))
|
||||
const submittedPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
|
||||
const askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen
|
||||
|
||||
const depositAmountFen = getConsignmentPublishDepositFen()
|
||||
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
|
||||
@@ -348,26 +355,45 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
* 卖家看到同一件货两行、两个号。现在**以提报单号为唯一锚点**聚合:一个提报单一条记录,
|
||||
* 库存与绑定工单的信息挂上去,阶段由统一状态机推导(三套底层状态 → 一个卖家看得懂的阶段)。
|
||||
*/
|
||||
export async function listWorkerConsignments(workerId: number) {
|
||||
const [submissionsRes, inventoriesRes] = await Promise.all([
|
||||
listConsignmentSubmissions({ workerId, pageSize: 200 }),
|
||||
listConsignmentInventories({ workerId, pageSize: 200 }),
|
||||
])
|
||||
/**
|
||||
* 「我的寄卖」列表:服务端搜索 + 分组 + 分页。
|
||||
*
|
||||
* 分组、关键字、翻页全部在 SQL 完成(见 listWorkerConsignmentPage),
|
||||
* 这里只负责按行标识回填完整数据并推导展示阶段——阶段口径仍然只由
|
||||
* domain/consignment-item-stage.ts 决定,避免两处状态机漂移。
|
||||
*/
|
||||
export async function listWorkerConsignments(
|
||||
workerId: number,
|
||||
options: {
|
||||
group?: WorkerConsignmentGroup | undefined
|
||||
keyword?: string | undefined
|
||||
page?: number | undefined
|
||||
pageSize?: number | undefined
|
||||
} = {},
|
||||
) {
|
||||
const page = await listWorkerConsignmentPage({
|
||||
workerId,
|
||||
group: options.group,
|
||||
keyword: options.keyword,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
})
|
||||
const publishDepositFen = getConsignmentPublishDepositFen()
|
||||
|
||||
const inventoryBySubmission = new Map<number, (typeof inventoriesRes.items)[number]>()
|
||||
const orphanInventories: typeof inventoriesRes.items = []
|
||||
for (const inv of inventoriesRes.items) {
|
||||
const submissionId = Number(inv.source_submission_id || 0)
|
||||
if (submissionId > 0 && !inventoryBySubmission.has(submissionId)) {
|
||||
inventoryBySubmission.set(submissionId, inv)
|
||||
} else {
|
||||
// 客服手工入库的现货没有提报单,用库存号自己当锚点
|
||||
orphanInventories.push(inv)
|
||||
}
|
||||
}
|
||||
const submissionIds = page.rows
|
||||
.map((row) => row.submissionId)
|
||||
.filter((id): id is number => Number(id) > 0)
|
||||
const inventoryIds = page.rows
|
||||
.map((row) => row.inventoryId)
|
||||
.filter((id): id is number => Number(id) > 0)
|
||||
const [submissions, inventories] = await Promise.all([
|
||||
listConsignmentSubmissionsByIds(submissionIds),
|
||||
listConsignmentInventoriesByIds(inventoryIds),
|
||||
])
|
||||
const submissionById = new Map(submissions.map((sub) => [Number(sub.id), sub]))
|
||||
const inventoryById = new Map(inventories.map((inv) => [Number(inv.id), inv]))
|
||||
|
||||
const boundWorkOrderIds = [...inventoryBySubmission.values(), ...orphanInventories]
|
||||
const boundWorkOrderIds = inventories
|
||||
.map((inv) => Number(inv.bound_work_order_id || 0))
|
||||
.filter((id) => id > 0)
|
||||
const workOrders = await listWorkOrdersByIds(boundWorkOrderIds)
|
||||
@@ -376,7 +402,7 @@ export async function listWorkerConsignments(workerId: number) {
|
||||
const viewer = { role: 'seller', workerId: Number(workerId) } as const
|
||||
const resolveStage = (
|
||||
submissionStatus: string,
|
||||
inv: (typeof inventoriesRes.items)[number] | undefined,
|
||||
inv: (typeof inventories)[number] | undefined,
|
||||
) => {
|
||||
const workOrder = inv?.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
@@ -399,98 +425,101 @@ export async function listWorkerConsignments(workerId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
const items = [
|
||||
...submissionsRes.items.map((sub) => {
|
||||
const inv = inventoryBySubmission.get(Number(sub.id))
|
||||
const boundWo = inv?.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
: undefined
|
||||
const stage = resolveStage(String(sub.status || ''), inv)
|
||||
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
|
||||
return {
|
||||
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
|
||||
consignmentNo: sub.submission_no,
|
||||
submissionId: sub.id,
|
||||
inventoryId: inv?.id ?? null,
|
||||
workOrderId: inv?.bound_work_order_id ?? null,
|
||||
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,
|
||||
depositAmountFen: sub.deposit_amount_fen,
|
||||
reviewNote: sub.review_note,
|
||||
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
|
||||
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
|
||||
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
|
||||
.map((url) => refreshStoredFileUrl(url)),
|
||||
giftEvidenceImages: evidence.giftEvidenceImages,
|
||||
giftEvidenceNote: evidence.giftEvidenceNote,
|
||||
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
|
||||
createdAt: sub.created_at,
|
||||
...stage,
|
||||
/** 冷却截止时间,前端拿它显示倒计时 */
|
||||
giftAvailableAt: inv?.bound_work_order_id
|
||||
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
|
||||
: null,
|
||||
friendAddedAt: inv?.bound_work_order_id
|
||||
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
|
||||
: null,
|
||||
sellerInfo: extractConsignmentSellerInfoFromSubmission(sub),
|
||||
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
|
||||
}
|
||||
}),
|
||||
...orphanInventories.map((inv) => {
|
||||
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
|
||||
const boundWo = inv.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
: undefined
|
||||
const stage = resolveStage('approved', inv)
|
||||
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
|
||||
return {
|
||||
consignmentNo: '客服手工入库',
|
||||
...(stage.canConfirmFriend || stage.canSubmitEvidence
|
||||
? { canConfirmFriend: false, canSubmitEvidence: false }
|
||||
: {}),
|
||||
submissionId: null,
|
||||
inventoryId: inv.id,
|
||||
workOrderId: inv.bound_work_order_id ?? null,
|
||||
itemName: inv.item_name,
|
||||
system: inv.system,
|
||||
serverZone: inv.server_zone,
|
||||
series: inv.series,
|
||||
diamondLevel: inv.diamond_level,
|
||||
brillianceCount: inv.brilliance_count,
|
||||
askingPriceFen: inv.cost_price_fen,
|
||||
depositAmountFen: inv.deposit_amount_fen,
|
||||
reviewNote: '',
|
||||
screenshotsJson: [],
|
||||
giftEvidenceImages: evidence.giftEvidenceImages,
|
||||
giftEvidenceNote: evidence.giftEvidenceNote,
|
||||
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
|
||||
createdAt: inv.created_at,
|
||||
...stage,
|
||||
giftAvailableAt: inv.bound_work_order_id
|
||||
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
|
||||
: null,
|
||||
friendAddedAt: inv.bound_work_order_id
|
||||
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
|
||||
: null,
|
||||
sellerInfo: extractConsignmentSellerInfoFromInventory(inv),
|
||||
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
|
||||
}
|
||||
}),
|
||||
].sort((a, b) => Date.parse(String(b.createdAt || '')) - Date.parse(String(a.createdAt || '')))
|
||||
const buildSubmissionItem = (
|
||||
sub: (typeof submissions)[number],
|
||||
inv: (typeof inventories)[number] | undefined,
|
||||
) => {
|
||||
const boundWo = inv?.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
: undefined
|
||||
const stage = resolveStage(String(sub.status || ''), inv)
|
||||
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
|
||||
return {
|
||||
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
|
||||
consignmentNo: sub.submission_no,
|
||||
submissionId: sub.id,
|
||||
inventoryId: inv?.id ?? null,
|
||||
workOrderId: inv?.bound_work_order_id ?? null,
|
||||
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,
|
||||
depositAmountFen: sub.deposit_amount_fen,
|
||||
reviewNote: sub.review_note,
|
||||
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
|
||||
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
|
||||
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
|
||||
.map((url) => refreshStoredFileUrl(url)),
|
||||
giftEvidenceImages: evidence.giftEvidenceImages,
|
||||
giftEvidenceNote: evidence.giftEvidenceNote,
|
||||
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
|
||||
createdAt: sub.created_at,
|
||||
...stage,
|
||||
/** 冷却截止时间,前端拿它显示倒计时 */
|
||||
giftAvailableAt: boundWo?.gift_available_at ?? null,
|
||||
friendAddedAt: boundWo?.friend_added_at ?? null,
|
||||
sellerInfo: extractConsignmentSellerInfoFromSubmission(sub),
|
||||
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
|
||||
}
|
||||
}
|
||||
|
||||
const buildOrphanInventoryItem = (inv: (typeof inventories)[number]) => {
|
||||
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
|
||||
const boundWo = inv.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
: undefined
|
||||
const stage = resolveStage('approved', inv)
|
||||
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
|
||||
return {
|
||||
consignmentNo: '客服手工入库',
|
||||
...(stage.canConfirmFriend || stage.canSubmitEvidence
|
||||
? { canConfirmFriend: false, canSubmitEvidence: false }
|
||||
: {}),
|
||||
submissionId: null,
|
||||
inventoryId: inv.id,
|
||||
workOrderId: inv.bound_work_order_id ?? null,
|
||||
itemName: inv.item_name,
|
||||
system: inv.system,
|
||||
serverZone: inv.server_zone,
|
||||
series: inv.series,
|
||||
diamondLevel: inv.diamond_level,
|
||||
brillianceCount: inv.brilliance_count,
|
||||
askingPriceFen: inv.cost_price_fen,
|
||||
depositAmountFen: inv.deposit_amount_fen,
|
||||
reviewNote: '',
|
||||
screenshotsJson: [],
|
||||
giftEvidenceImages: evidence.giftEvidenceImages,
|
||||
giftEvidenceNote: evidence.giftEvidenceNote,
|
||||
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
|
||||
createdAt: inv.created_at,
|
||||
...stage,
|
||||
giftAvailableAt: boundWo?.gift_available_at ?? null,
|
||||
friendAddedAt: boundWo?.friend_added_at ?? null,
|
||||
sellerInfo: extractConsignmentSellerInfoFromInventory(inv),
|
||||
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
|
||||
}
|
||||
}
|
||||
|
||||
// 顺序完全由服务端分页决定,这里不再排序
|
||||
const items = page.rows
|
||||
.map((row) => {
|
||||
const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined
|
||||
const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined
|
||||
if (sub) return buildSubmissionItem(sub, inv)
|
||||
return inv ? buildOrphanInventoryItem(inv) : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
|
||||
return {
|
||||
publishDepositFen,
|
||||
items,
|
||||
stageCounts: items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.stage] = (acc[item.stage] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
total: page.total,
|
||||
page: Math.max(1, Number(options.page) || 1),
|
||||
pageSize: Math.max(1, Number(options.pageSize) || 10),
|
||||
counts: page.counts,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DollarCircleOutlined,
|
||||
HistoryOutlined,
|
||||
InboxOutlined,
|
||||
SearchOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Pagination,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
@@ -116,12 +118,19 @@ function getInitialRecordFilter(): RecordFilter {
|
||||
|
||||
/** 赠送冷却倒计时刷新间隔:30 秒足够展示,不需要秒级跳动 */
|
||||
const COOLDOWN_TICK_MS = 30_000
|
||||
/** 「我的寄卖」每页条数:桌面上够看又不至于一屏拉太长 */
|
||||
const RECORD_PAGE_SIZE = 10
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
export default function WorkerConsignmentPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { message, modal } = App.useApp()
|
||||
const [activeTab, setActiveTabState] = useState<'publish' | 'records'>(getInitialActiveTab)
|
||||
const [recordFilter, setRecordFilterState] = useState<RecordFilter>(getInitialRecordFilter)
|
||||
/** 搜索框里的原始输入(立刻回显),防抖后才作为服务端查询条件 */
|
||||
const [recordKeywordInput, setRecordKeywordInput] = useState('')
|
||||
const [recordKeyword, setRecordKeyword] = useState('')
|
||||
const [recordPage, setRecordPage] = useState(1)
|
||||
|
||||
const setActiveTab = (tab: 'publish' | 'records') => {
|
||||
setActiveTabState(tab)
|
||||
@@ -159,12 +168,13 @@ export default function WorkerConsignmentPage() {
|
||||
const [cooldownNow, setCooldownNow] = useState(() => Date.now())
|
||||
const [form] = Form.useForm()
|
||||
const currentDiamondLevel = Form.useWatch('diamondLevel', form)
|
||||
// 建议价:规格选齐后从「接单模板」带出(安卓+暗潮+1钻+0璀 → ¥120);卖家手动改价则只提示不覆盖
|
||||
const [priceSuggestion, setPriceSuggestion] = useState<{
|
||||
fen: number
|
||||
matchedCount: number
|
||||
} | null>(null)
|
||||
const priceTouchedRef = useRef(false)
|
||||
// 寄售价:完全由后台「接单模板」决定(规格选齐后自动带出),卖家只读不可改。
|
||||
const [priceState, setPriceState] = useState<
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'matched'; fen: number }
|
||||
| { status: 'empty' }
|
||||
>({ status: 'idle' })
|
||||
const priceFetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastPriceQueryKeyRef = useRef('')
|
||||
|
||||
@@ -179,8 +189,14 @@ export default function WorkerConsignmentPage() {
|
||||
const financeConfig = profileQuery.data?.data.financeConfig
|
||||
|
||||
const myConsignmentsQuery = useQuery({
|
||||
queryKey: ['worker-my-consignments'],
|
||||
queryFn: () => fetchWorkerConsignments(),
|
||||
queryKey: ['worker-my-consignments', recordFilter, recordKeyword, recordPage],
|
||||
queryFn: () =>
|
||||
fetchWorkerConsignments({
|
||||
group: recordFilter,
|
||||
keyword: recordKeyword,
|
||||
page: recordPage,
|
||||
pageSize: RECORD_PAGE_SIZE,
|
||||
}),
|
||||
})
|
||||
|
||||
// 切换标签页时自动刷新最新数据
|
||||
@@ -191,9 +207,15 @@ export default function WorkerConsignmentPage() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['worker-profile'] })
|
||||
}, [activeTab, queryClient])
|
||||
|
||||
// 后端已按「一件货一行」聚合,前端不再合并提报单与库存
|
||||
// 后端已按「一件货一行」聚合,分组/搜索/翻页都在服务端完成
|
||||
const records = myConsignmentsQuery.data?.data.items || []
|
||||
const stageCounts = myConsignmentsQuery.data?.data.stageCounts || {}
|
||||
const recordTotal = Number(myConsignmentsQuery.data?.data.total || 0)
|
||||
const groupCounts = myConsignmentsQuery.data?.data.counts || {
|
||||
all: 0,
|
||||
reviewing: 0,
|
||||
active: 0,
|
||||
finished: 0,
|
||||
}
|
||||
const publishDepositFen = Number(
|
||||
myConsignmentsQuery.data?.data.publishDepositFen ??
|
||||
financeConfig?.consignmentPublishDepositFen ??
|
||||
@@ -209,29 +231,6 @@ export default function WorkerConsignmentPage() {
|
||||
})
|
||||
const [contactOpen, setContactOpen] = useState(false)
|
||||
|
||||
// 阶段 → 分组由后端下发(item.group),这里只把它映射到筛选角标计数上
|
||||
const groupCounts = useMemo(() => {
|
||||
const stageGroups = new Map<string, string>()
|
||||
records.forEach((item) => stageGroups.set(item.stage, item.group))
|
||||
const counts: Record<'reviewing' | 'active' | 'finished', number> = {
|
||||
reviewing: 0,
|
||||
active: 0,
|
||||
finished: 0,
|
||||
}
|
||||
Object.entries(stageCounts).forEach(([stage, count]) => {
|
||||
const group = stageGroups.get(stage)
|
||||
if (group === 'reviewing' || group === 'active' || group === 'finished') {
|
||||
counts[group] += Number(count) || 0
|
||||
}
|
||||
})
|
||||
return counts
|
||||
}, [records, stageCounts])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (recordFilter === 'all') return records
|
||||
return records.filter((item) => item.group === recordFilter)
|
||||
}, [records, recordFilter])
|
||||
|
||||
const hasCooldownItem = records.some(
|
||||
(item) => item.stage === 'gift_cooldown' && Boolean(item.giftAvailableAt),
|
||||
)
|
||||
@@ -243,6 +242,19 @@ export default function WorkerConsignmentPage() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [hasCooldownItem])
|
||||
|
||||
// 搜索防抖:输入停下 300ms 才去请求服务端,避免每敲一个字都打接口
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setRecordKeyword(recordKeywordInput.trim())
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [recordKeywordInput])
|
||||
|
||||
// 换分组或换关键字都要回到第 1 页,否则会停在一个不存在的页码上
|
||||
useEffect(() => {
|
||||
setRecordPage(1)
|
||||
}, [recordFilter, recordKeyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (worker?.phone) {
|
||||
form.setFieldsValue({ sellerPhone: worker.phone })
|
||||
@@ -276,10 +288,6 @@ export default function WorkerConsignmentPage() {
|
||||
}, [profileQuery.isLoading, modal, publishDepositYuan])
|
||||
|
||||
function handleFormValuesChange(changed: Record<string, unknown>) {
|
||||
// 卖家手动改过价格后,本规格内不再自动覆盖(换规格会重新带出)
|
||||
if (changed && changed.askingPrice !== undefined) {
|
||||
priceTouchedRef.current = true
|
||||
}
|
||||
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
|
||||
if (changed && changed.diamondLevel !== undefined) {
|
||||
const newDia = Number(changed.diamondLevel || 0)
|
||||
@@ -303,44 +311,50 @@ export default function WorkerConsignmentPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出建议价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。
|
||||
// 防抖 + 同规格去重 + 过期响应丢弃;建议价拿不到不影响发布(静默)。
|
||||
if (priceFetchTimer.current) clearTimeout(priceFetchTimer.current)
|
||||
if (sys && ser && dia != null && Number(dia) > 0 && bri != null) {
|
||||
const queryKey = `${sys}|${ser}|${dia}|${bri}`
|
||||
if (lastPriceQueryKeyRef.current !== queryKey) {
|
||||
lastPriceQueryKeyRef.current = queryKey
|
||||
// 换了规格:重新允许自动带价(上一规格的手改标记不再拦新规格)
|
||||
priceTouchedRef.current = false
|
||||
// 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出寄售价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。
|
||||
// 价格只读、卖家不用填;只有规格真的变了才重新匹配(改联系方式等字段不会触发)。
|
||||
const specKey =
|
||||
sys && ser && dia != null && Number(dia) > 0 && bri != null
|
||||
? `${sys}|${ser}|${dia}|${bri}`
|
||||
: ''
|
||||
if (!specKey) {
|
||||
if (lastPriceQueryKeyRef.current !== '') {
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setPriceState({ status: 'idle' })
|
||||
form.setFieldsValue({ askingPrice: undefined })
|
||||
}
|
||||
priceFetchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchWorkerConsignmentPriceSuggestion({
|
||||
system: String(sys),
|
||||
series: String(ser),
|
||||
diamondLevel: Number(dia),
|
||||
brillianceCount: Number(bri),
|
||||
})
|
||||
if (lastPriceQueryKeyRef.current !== queryKey) return
|
||||
const fen = Number(res.data?.suggestedPriceFen || 0)
|
||||
if (fen > 0) {
|
||||
setPriceSuggestion({ fen, matchedCount: Number(res.data?.matchedCount || 0) })
|
||||
if (!priceTouchedRef.current) {
|
||||
form.setFieldsValue({ askingPrice: fen / 100 })
|
||||
}
|
||||
} else {
|
||||
setPriceSuggestion(null)
|
||||
}
|
||||
} catch {
|
||||
// 建议价是锦上添花,失败不打扰卖家
|
||||
}
|
||||
})()
|
||||
}, 300)
|
||||
} else {
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setPriceSuggestion(null)
|
||||
return
|
||||
}
|
||||
if (specKey === lastPriceQueryKeyRef.current) return
|
||||
lastPriceQueryKeyRef.current = specKey
|
||||
setPriceState({ status: 'loading' })
|
||||
// 换规格先把旧价清掉,避免用上一档价格提交
|
||||
form.setFieldsValue({ askingPrice: undefined })
|
||||
if (priceFetchTimer.current) clearTimeout(priceFetchTimer.current)
|
||||
priceFetchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchWorkerConsignmentPriceSuggestion({
|
||||
system: String(sys),
|
||||
series: String(ser),
|
||||
diamondLevel: Number(dia),
|
||||
brillianceCount: Number(bri),
|
||||
})
|
||||
if (lastPriceQueryKeyRef.current !== specKey) return
|
||||
const fen = Number(res.data?.suggestedPriceFen || 0)
|
||||
if (fen > 0) {
|
||||
setPriceState({ status: 'matched', fen })
|
||||
form.setFieldsValue({ askingPrice: fen / 100 })
|
||||
} else {
|
||||
setPriceState({ status: 'empty' })
|
||||
}
|
||||
} catch {
|
||||
if (lastPriceQueryKeyRef.current === specKey) {
|
||||
setPriceState({ status: 'empty' })
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handlePublishSubmit(values: Record<string, any>) {
|
||||
@@ -360,7 +374,16 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
const askingPriceFen = Math.round(Number(values.askingPrice || 0) * 100)
|
||||
// 寄售价由后台接单模板决定,卖家不用填;没匹配到模板价就不让发,避免产出没有定价依据的单
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在匹配接单模板价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched') {
|
||||
message.error('该规格暂无接单模板价,请联系客服补充模板后再提报')
|
||||
return
|
||||
}
|
||||
const askingPriceFen = priceState.fen
|
||||
if (askingPriceFen < 100) {
|
||||
message.error('寄售价格不能少于1元')
|
||||
return
|
||||
@@ -393,8 +416,7 @@ export default function WorkerConsignmentPage() {
|
||||
|
||||
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
|
||||
form.resetFields()
|
||||
setPriceSuggestion(null)
|
||||
priceTouchedRef.current = false
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setScreenshots([])
|
||||
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
|
||||
@@ -493,7 +515,7 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const recordTotal = filteredRecords.length
|
||||
const recordKeywordTrimmed = recordKeywordInput.trim()
|
||||
|
||||
return (
|
||||
<section className="page-stack worker-consignment-page">
|
||||
@@ -739,24 +761,27 @@ export default function WorkerConsignmentPage() {
|
||||
|
||||
<div className="worker-consignment-price-grid">
|
||||
<Form.Item
|
||||
name="askingPrice"
|
||||
label="寄售价格"
|
||||
rules={[{ required: true, message: '请填写寄售价格' }]}
|
||||
extra={
|
||||
priceSuggestion
|
||||
? `已按接单模板匹配 ${priceSuggestion.matchedCount} 条规则,建议价 ¥${(priceSuggestion.fen / 100).toFixed(0)}(可修改)`
|
||||
: undefined
|
||||
}
|
||||
tooltip="由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。"
|
||||
className="worker-consignment-spec-card is-price"
|
||||
>
|
||||
<InputNumber
|
||||
size="large"
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请输入"
|
||||
addonBefore="¥"
|
||||
/>
|
||||
<div className="worker-consignment-price-readonly">
|
||||
{priceState.status === 'loading' ? (
|
||||
<span className="is-pending">
|
||||
<Spin size="small" /> 匹配模板价…
|
||||
</span>
|
||||
) : priceState.status === 'matched' ? (
|
||||
<span className="is-amount">¥{(priceState.fen / 100).toFixed(0)}</span>
|
||||
) : priceState.status === 'empty' ? (
|
||||
<span className="is-empty">该规格暂无模板价,请联系客服</span>
|
||||
) : (
|
||||
<span className="is-empty">选完规格后自动带出</span>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
{/* 价格只读展示,但仍要随表单提交给后端(后端也会按模板复算) */}
|
||||
<Form.Item name="askingPrice" hidden>
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="履约押金"
|
||||
@@ -869,8 +894,8 @@ export default function WorkerConsignmentPage() {
|
||||
label: (
|
||||
<span>
|
||||
<HistoryOutlined /> 我的寄卖
|
||||
{records.length > 0 ? (
|
||||
<span className="worker-order-tab-count">{records.length}</span>
|
||||
{groupCounts.all > 0 ? (
|
||||
<span className="worker-order-tab-count">{groupCounts.all}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
@@ -879,7 +904,7 @@ export default function WorkerConsignmentPage() {
|
||||
<div className="worker-consignment-filter-row">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: '全部', count: records.length },
|
||||
{ key: 'all', label: '全部', count: groupCounts.all },
|
||||
{ key: 'reviewing', label: '待审核', count: groupCounts.reviewing },
|
||||
{ key: 'active', label: '履约中', count: groupCounts.active },
|
||||
{ key: 'finished', label: '已完成', count: groupCounts.finished },
|
||||
@@ -895,28 +920,39 @@ export default function WorkerConsignmentPage() {
|
||||
{item.count > 0 ? ` (${item.count})` : ''}
|
||||
</Button>
|
||||
))}
|
||||
<Input
|
||||
className="worker-consignment-search"
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜单号 / 角色名 / 区服 / 买家"
|
||||
value={recordKeywordInput}
|
||||
onChange={(event) => setRecordKeywordInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{myConsignmentsQuery.isLoading ? (
|
||||
<Typography.Text type="secondary">正在加载寄卖记录…</Typography.Text>
|
||||
) : groupCounts.all === 0 && !recordKeywordTrimmed ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无寄卖记录,去发布一单苏尔南吧"
|
||||
>
|
||||
<Button type="primary" onClick={() => setActiveTab('publish')}>
|
||||
发布寄卖
|
||||
</Button>
|
||||
</Empty>
|
||||
) : recordTotal === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
recordFilter === 'all'
|
||||
? '暂无寄卖记录,去发布一单苏尔南吧'
|
||||
recordKeywordTrimmed
|
||||
? `没有找到与「${recordKeywordTrimmed}」匹配的寄卖记录`
|
||||
: '当前筛选下没有寄卖记录'
|
||||
}
|
||||
>
|
||||
{recordFilter === 'all' ? (
|
||||
<Button type="primary" onClick={() => setActiveTab('publish')}>
|
||||
发布寄卖
|
||||
</Button>
|
||||
) : null}
|
||||
</Empty>
|
||||
/>
|
||||
) : (
|
||||
<div className="worker-consignment-record-list">
|
||||
{filteredRecords.map((record) => (
|
||||
{records.map((record) => (
|
||||
<ConsignmentRecordCard
|
||||
key={record.consignmentNo}
|
||||
record={record}
|
||||
@@ -927,6 +963,20 @@ export default function WorkerConsignmentPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordTotal > RECORD_PAGE_SIZE ? (
|
||||
<div className="worker-consignment-pagination">
|
||||
<Pagination
|
||||
size={isMobile ? 'small' : undefined}
|
||||
current={recordPage}
|
||||
pageSize={RECORD_PAGE_SIZE}
|
||||
total={recordTotal}
|
||||
showSizeChanger={false}
|
||||
showLessItems={isMobile}
|
||||
onChange={(nextPage) => setRecordPage(nextPage)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -458,19 +458,40 @@ export type WorkerConsignmentRecord = {
|
||||
|
||||
export type { ConsignmentBuyerInfo, ConsignmentSellerInfo } from '@/types/consignment'
|
||||
|
||||
export type WorkerConsignmentListCounts = {
|
||||
all: number
|
||||
reviewing: number
|
||||
active: number
|
||||
finished: number
|
||||
}
|
||||
|
||||
export type WorkerConsignmentListResponse = {
|
||||
publishDepositFen: number
|
||||
items: WorkerConsignmentRecord[]
|
||||
/** 各阶段件数,用于筛选 tab 角标 */
|
||||
stageCounts: Record<string, number>
|
||||
/** 当前分组 + 关键字下的总条数(服务端分页用) */
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 各分组条数,用于筛选角标;受关键字影响、不受当前分组影响 */
|
||||
counts: WorkerConsignmentListCounts
|
||||
}
|
||||
|
||||
export type WorkerConsignmentListParams = {
|
||||
group?: 'all' | 'reviewing' | 'active' | 'finished'
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayload) {
|
||||
return apiPost<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload)
|
||||
}
|
||||
|
||||
export function fetchWorkerConsignments() {
|
||||
return apiGet<WorkerConsignmentListResponse>('/api/v1/worker/consignment/my-items')
|
||||
export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}) {
|
||||
return apiGet<WorkerConsignmentListResponse>(
|
||||
'/api/v1/worker/consignment/my-items',
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4520,6 +4520,37 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 寄售价只读展示:做成和「履约押金」同款的只读框,避免卡片里只剩一个数字显得空 */
|
||||
.worker-consignment-price-readonly {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
gap: 2px 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.worker-consignment-price-readonly .is-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ff6a00;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.worker-consignment-price-readonly .is-pending,
|
||||
.worker-consignment-price-readonly .is-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.worker-consignment-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -4557,12 +4588,32 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 「我的寄卖」搜索:单号/角色名/区服/买家,靠右不与筛选按钮抢位 */
|
||||
.worker-consignment-search {
|
||||
margin-left: auto;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.worker-consignment-search {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.worker-consignment-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 「我的寄卖」服务端分页 */
|
||||
.worker-consignment-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.worker-consignment-record-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user