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:
yml2213
2026-10-01 11:07:28 +08:00
parent ef491660e7
commit 1636616ec1
10 changed files with 853 additions and 265 deletions
@@ -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
}
/**
* 按提报单精确取现货。
*
+14 -3
View File
@@ -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>
),
},
+25 -4
View File
@@ -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>,
)
}
/**
+51
View File
@@ -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;