From 1636616ec13ceb21005f3aea3aee16221489b182 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 11:07:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(worker-platform):=20=E5=AF=84=E5=8D=96?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E6=94=B9=E7=94=B1=E6=8E=A5=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=8F=AA=E8=AF=BB=E5=AE=9A=E4=BB=B7=EF=BC=8C=E6=88=91?= =?UTF-8?q?=E7=9A=84=E5=AF=84=E5=8D=96=E6=94=AF=E6=8C=81=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=90=9C=E7=B4=A2=E4=B8=8E=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 项测试全部通过。 --- .../worker-platform/consignment-repo.test.ts | 117 ++++++++ .../worker-platform/consignment-repo.ts | 218 +++++++++++++++ apps/backend/src/routes/worker/consignment.ts | 17 +- .../consignment-matching-service.test.ts | 84 +++++- .../consignment-matching-service.ts | 67 +++-- .../consignment-worker-service.test.ts | 12 + .../consignment-worker-service.ts | 263 ++++++++++-------- .../pages/worker/WorkerConsignmentPage.tsx | 260 ++++++++++------- apps/frontend/src/services/worker.ts | 29 +- apps/frontend/src/styles/worker.css | 51 ++++ 10 files changed, 853 insertions(+), 265 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts index 9d86256f..1f36ce65 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts @@ -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 }> = [ + { 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') +}) diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 0d60914a..60d18591 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 +} + +/** + * 「我的寄卖」服务端分页 / 搜索 / 分组计数。 + * + * 两件事必须在 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 { + 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 = { + 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, +): Promise { + const numericIds = [...new Set(ids.map(Number).filter((n) => Number.isFinite(n) && n > 0))] + if (numericIds.length === 0) return [] + + const res = await query( + ` + 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 +} + /** * 按提报单精确取现货。 * diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index 6325bf4f..44cb69e3 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -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 * 卖家确认已加好买家好友 → 进入赠送冷却期(确认后未完成赠送押金不退) diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts index 6a9909b5..3508a5f6 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts @@ -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, ) }) diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index 57e1d59d..6a4b6af9 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -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 +} diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index 9b1a0c4f..d8e1afb7 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -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'), + '模板命中时用模板价,只有完全没配模板时才回落到调用方传来的金额', + ) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 432519cd..5cdbced9 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -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>['items'][number], + sub: Awaited>[number], ): ConsignmentSellerInfo { const content = toRecord(sub.content_json) || {} let characterName = String(content.characterName || '').trim() @@ -114,7 +116,7 @@ function extractConsignmentSellerInfoFromSubmission( } function extractConsignmentSellerInfoFromInventory( - inv: Awaited>['items'][number], + inv: Awaited>[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() - 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 => item !== null) return { publishDepositFen, items, - stageCounts: items.reduce>((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, } } diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 253ff511..42eb13fe 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -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(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 | 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() - 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) { - // 卖家手动改过价格后,本规格内不再自动覆盖(换规格会重新带出) - 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) { @@ -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 (
@@ -739,24 +761,27 @@ export default function WorkerConsignmentPage() {
- +
+ {priceState.status === 'loading' ? ( + + 匹配模板价… + + ) : priceState.status === 'matched' ? ( + ¥{(priceState.fen / 100).toFixed(0)} + ) : priceState.status === 'empty' ? ( + 该规格暂无模板价,请联系客服 + ) : ( + 选完规格后自动带出 + )} +
+
+ {/* 价格只读展示,但仍要随表单提交给后端(后端也会按模板复算) */} + 我的寄卖 - {records.length > 0 ? ( - {records.length} + {groupCounts.all > 0 ? ( + {groupCounts.all} ) : null} ), @@ -879,7 +904,7 @@ export default function WorkerConsignmentPage() {
{( [ - { 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})` : ''} ))} + } + placeholder="搜单号 / 角色名 / 区服 / 买家" + value={recordKeywordInput} + onChange={(event) => setRecordKeywordInput(event.target.value)} + />
{myConsignmentsQuery.isLoading ? ( 正在加载寄卖记录… + ) : groupCounts.all === 0 && !recordKeywordTrimmed ? ( + + + ) : recordTotal === 0 ? ( - {recordFilter === 'all' ? ( - - ) : null} - + /> ) : (
- {filteredRecords.map((record) => ( + {records.map((record) => ( )} + + {recordTotal > RECORD_PAGE_SIZE ? ( +
+ setRecordPage(nextPage)} + /> +
+ ) : null} ), }, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 3b7d702c..4a77183e 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -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 + /** 当前分组 + 关键字下的总条数(服务端分页用) */ + 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('/api/v1/worker/consignment/publish', payload) } -export function fetchWorkerConsignments() { - return apiGet('/api/v1/worker/consignment/my-items') +export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}) { + return apiGet( + '/api/v1/worker/consignment/my-items', + params as Record, + ) } /** diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index e8aa382d..8e1cfd05 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -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;