From 622bd779ddf4385247d2dc0df0ef8e2f021bd9fd Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Tue, 27 Oct 2026 13:39:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=84=E5=8D=96=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E5=AE=A2=E6=9C=8D=EF=BC=8Cdb=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=BF=81=E7=A7=BB=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...work_order_share_cancel_reason_comment.sql | 2 +- .../112_consignment_contact_supporter.sql | 41 +++ .../consignment-contact-repo.ts | 190 ++++++++++ .../worker-platform/consignment-repo.ts | 99 ++++- .../src/repositories/worker-platform/index.ts | 1 + .../admin/worker-platform/consignment.ts | 65 ++++ apps/backend/src/routes/worker/consignment.ts | 14 + .../consignment-admin-service.ts | 31 +- .../consignment-contact-service.ts | 232 ++++++++++++ .../consignment-fulfillment-service.ts | 16 + .../consignment-worker-service.ts | 30 ++ .../src/services/worker-platform/index.ts | 1 + apps/frontend/src/App.tsx | 4 + .../admin/ConsignmentManualMatchModal.tsx | 28 +- apps/frontend/src/layouts/AdminLayout.tsx | 7 + .../admin/AdminConsignmentContactsPage.tsx | 343 ++++++++++++++++++ .../panels/ConsignmentInventoryPanel.tsx | 66 ++++ .../panels/ConsignmentSubmissionsPanel.tsx | 38 ++ .../pages/admin/panels/WorkOrdersPanel.tsx | 29 +- .../pages/worker/WorkerConsignmentPage.tsx | 73 ++++ .../src/services/admin/consignment.ts | 40 +- apps/frontend/src/services/worker.ts | 21 +- apps/frontend/src/types/consignment.ts | 184 ++++++---- .../frontend/src/utils/consignment-contact.ts | 37 ++ .../tests/consignment-contact.test.ts | 29 ++ 25 files changed, 1515 insertions(+), 106 deletions(-) create mode 100644 apps/backend/src/db/migrations/112_consignment_contact_supporter.sql create mode 100644 apps/backend/src/repositories/worker-platform/consignment-contact-repo.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-contact-service.ts create mode 100644 apps/frontend/src/pages/admin/AdminConsignmentContactsPage.tsx create mode 100644 apps/frontend/src/utils/consignment-contact.ts create mode 100644 apps/frontend/tests/consignment-contact.test.ts diff --git a/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql b/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql index 286c4bef..274f5e29 100644 --- a/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql +++ b/apps/backend/src/db/migrations/111_work_order_share_cancel_reason_comment.sql @@ -1,4 +1,4 @@ --- 115_work_order_share_cancel_reason_comment.sql +-- 111_work_order_share_cancel_reason_comment.sql -- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。 -- 纯注释变更,无结构/数据改动;重复执行无副作用。 diff --git a/apps/backend/src/db/migrations/112_consignment_contact_supporter.sql b/apps/backend/src/db/migrations/112_consignment_contact_supporter.sql new file mode 100644 index 00000000..128e7bb4 --- /dev/null +++ b/apps/backend/src/db/migrations/112_consignment_contact_supporter.sql @@ -0,0 +1,41 @@ +-- 112_consignment_contact_supporter.sql +-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。 +-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。 +-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。 +-- 全部语句幂等,可重复执行。 + +CREATE TABLE IF NOT EXISTS consignment_contact_supporters ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + phone TEXT NOT NULL DEFAULT '', + wechat TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引 +CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order + ON consignment_contact_supporters (enabled, sort_order, id); + +ALTER TABLE consignment_submissions + ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT + REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL; + +ALTER TABLE consignment_inventories + ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT + REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter + ON consignment_submissions (contact_supporter_id); + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter + ON consignment_inventories (contact_supporter_id); + +-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。 +-- 后台可直接改名/补充联系方式,不会重复插入。 +INSERT INTO consignment_contact_supporters (name, note, sort_order) +SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0 +WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters); diff --git a/apps/backend/src/repositories/worker-platform/consignment-contact-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-contact-repo.ts new file mode 100644 index 00000000..43e2a32a --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-contact-repo.ts @@ -0,0 +1,190 @@ +import { query } from '../../db/client.js' + +/** + * 寄卖「对接客服」名单。 + * + * 独立于后台账号表:客服离职或后台账号改角色,都不应影响历史寄卖归属; + * 提报单 / 现货只保存名单 id,展示时 join 出当时选择的客服资料。 + */ +export type ConsignmentContactRow = { + id: string | number + name: string + phone: string + wechat: string + note: string + enabled: boolean + sort_order: number + created_at: string + updated_at: string +} + +export async function listConsignmentContacts(filters: { + keyword?: string | undefined + enabled?: boolean | undefined +}): Promise { + const conditions: string[] = [] + const values: unknown[] = [] + + if (filters.enabled !== undefined) { + values.push(filters.enabled) + conditions.push(`enabled = $${values.length}`) + } + if (filters.keyword) { + values.push(`%${filters.keyword.trim()}%`) + conditions.push( + `(name ILIKE $${values.length} OR phone ILIKE $${values.length} OR wechat ILIKE $${values.length} OR note ILIKE $${values.length})`, + ) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const res = await query( + `SELECT * FROM consignment_contact_supporters ${whereClause} ORDER BY sort_order ASC, id ASC`, + values, + ) + return res.rows +} + +/** 打手端发布下拉:只返回启用中的客服。 */ +export async function listEnabledConsignmentContacts(): Promise { + return listConsignmentContacts({ enabled: true }) +} + +export async function getConsignmentContactById( + id: number | string, +): Promise { + const res = await query( + 'SELECT * FROM consignment_contact_supporters WHERE id = $1 LIMIT 1', + [id], + ) + return res.rows[0] || null +} + +/** 重名检查:名单用于归属统计,同名会让运营无法判断到底是谁。 */ +export async function findConsignmentContactByName( + name: string, + excludeId?: number | string, +): Promise { + const res = await query( + `SELECT * FROM consignment_contact_supporters + WHERE name = $1 ${excludeId !== undefined ? 'AND id <> $2' : ''} + ORDER BY id ASC + LIMIT 1`, + excludeId !== undefined ? [name, excludeId] : [name], + ) + return res.rows[0] || null +} + +export async function createConsignmentContact(input: { + name: string + phone?: string | undefined + wechat?: string | undefined + note?: string | undefined + enabled?: boolean | undefined + sortOrder?: number | undefined + now: string +}): Promise { + const res = await query( + `INSERT INTO consignment_contact_supporters ( + name, phone, wechat, note, enabled, sort_order, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + RETURNING *`, + [ + input.name, + input.phone || '', + input.wechat || '', + input.note || '', + input.enabled !== false, + input.sortOrder || 0, + input.now, + ], + ) + return res.rows[0]! +} + +export async function updateConsignmentContact( + id: number | string, + input: { + name?: string | undefined + phone?: string | undefined + wechat?: string | undefined + note?: string | undefined + sortOrder?: number | undefined + now: string + }, +): Promise { + const updates: string[] = [] + const values: unknown[] = [] + + if (input.name !== undefined) { + values.push(input.name) + updates.push(`name = $${values.length}`) + } + if (input.phone !== undefined) { + values.push(input.phone) + updates.push(`phone = $${values.length}`) + } + if (input.wechat !== undefined) { + values.push(input.wechat) + updates.push(`wechat = $${values.length}`) + } + if (input.note !== undefined) { + values.push(input.note) + updates.push(`note = $${values.length}`) + } + if (input.sortOrder !== undefined) { + values.push(input.sortOrder) + updates.push(`sort_order = $${values.length}`) + } + + if (updates.length === 0) { + return getConsignmentContactById(id) + } + + values.push(input.now) + updates.push(`updated_at = $${values.length}`) + values.push(id) + + const res = await query( + `UPDATE consignment_contact_supporters + SET ${updates.join(', ')} + WHERE id = $${values.length} + RETURNING *`, + values, + ) + return res.rows[0] || null +} + +export async function setConsignmentContactEnabled( + id: number | string, + enabled: boolean, + now: string, +): Promise { + const res = await query( + `UPDATE consignment_contact_supporters + SET enabled = $1, updated_at = $2 + WHERE id = $3 + RETURNING *`, + [enabled, now, id], + ) + return res.rows[0] || null +} + +/** 删除前统计引用:有历史寄卖时禁止硬删,避免历史单据的对接客服变成空白。 */ +export async function countConsignmentContactReferences(id: number | string): Promise { + const res = await query<{ total: number }>( + `SELECT ( + (SELECT COUNT(*) FROM consignment_submissions WHERE contact_supporter_id = $1) + + (SELECT COUNT(*) FROM consignment_inventories WHERE contact_supporter_id = $1) + )::integer AS total`, + [id], + ) + return Number(res.rows[0]?.total || 0) +} + +export async function deleteConsignmentContact(id: number | string): Promise { + const res = await query<{ id: string | number }>( + 'DELETE FROM consignment_contact_supporters WHERE id = $1 RETURNING id', + [id], + ) + return res.rows.length > 0 +} diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 073b0bc7..ef90dfc2 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -50,6 +50,11 @@ export type ConsignmentInventoryRow = { worker_phone?: string assigned_worker_id?: string | number | null deposit_amount_fen?: number + /** 对接客服(发布/审核入库时选定,用于提成归属与联系) */ + contact_supporter_id?: string | number | null + contact_supporter_name?: string + contact_supporter_phone?: string + contact_supporter_wechat?: string inbound_at: string consumed_at: string | null created_at: string @@ -79,6 +84,11 @@ export type ConsignmentSubmissionRow = { worker_name?: string worker_phone?: string deposit_amount_fen?: number + /** 对接客服(发布时选定,用于提成归属与联系) */ + contact_supporter_id?: string | number | null + contact_supporter_name?: string + contact_supporter_phone?: string + contact_supporter_wechat?: string status: 'pending_review' | 'approved' | 'rejected' review_note: string reviewer_name: string @@ -175,13 +185,13 @@ export async function listConsignmentInventories(filters: { if (filters.keyword) { values.push(`%${filters.keyword.trim()}%`) conditions.push( - `(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length})`, + `(ci.item_name ILIKE $${values.length} OR ci.inventory_no ILIKE $${values.length} OR ci.bound_platform_order_id ILIKE $${values.length} OR ci.seller_contact ILIKE $${values.length} OR ci.seller_game_id ILIKE $${values.length} OR ci.seller_payment_info ILIKE $${values.length} OR ci.remark ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR ccs.name ILIKE $${values.length})`, ) } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - // 计数也要带上提报单 join:关键字现在包含 cs.submission_no(卖家端主标识就是它) - const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id ${whereClause}` + // 计数也要带上提报单 / 对接客服 join:关键字现在包含 cs.submission_no 与 ccs.name + const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id ${whereClause}` const countRes = await query<{ total: number }>(countSql, values) const total = countRes.rows[0]?.total || 0 @@ -202,10 +212,14 @@ export async function listConsignmentInventories(filters: { wo.gift_phase AS gift_phase, wo.friend_added_at AS gift_friend_added_at, wo.material_json AS bound_work_order_material_json, - wo.requirement_json AS bound_work_order_requirement_json + wo.requirement_json AS bound_work_order_requirement_json, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id ${whereClause} ORDER BY ci.id DESC @@ -321,10 +335,14 @@ export async function getConsignmentInventoryById( wo.gift_phase AS gift_phase, wo.friend_added_at AS gift_friend_added_at, wo.material_json AS bound_work_order_material_json, - wo.requirement_json AS bound_work_order_requirement_json + wo.requirement_json AS bound_work_order_requirement_json, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id WHERE ci.id = $1 LIMIT 1 @@ -344,10 +362,14 @@ export async function listConsignmentInventoriesByIds( ci.*, wc.name AS category_name, cs.submission_no AS source_submission_no, - cs.screenshots_json AS submission_screenshots_json + cs.screenshots_json AS submission_screenshots_json, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id WHERE ci.id = ANY($1::bigint[]) ` const res = await query(sql, [numericIds]) @@ -379,6 +401,7 @@ export async function updateConsignmentInventory( workerName?: string | undefined workerPhone?: string | undefined depositAmountFen?: number | undefined + contactSupporterId?: number | null | undefined now: string }, ): Promise { @@ -473,6 +496,10 @@ export async function updateConsignmentInventory( values.push(input.depositAmountFen) updates.push(`deposit_amount_fen = $${values.length}`) } + if (input.contactSupporterId !== undefined) { + values.push(input.contactSupporterId) + updates.push(`contact_supporter_id = $${values.length}`) + } if (updates.length === 0) return getConsignmentInventoryById(id) @@ -484,10 +511,13 @@ export async function updateConsignmentInventory( UPDATE consignment_inventories SET ${updates.join(', ')} WHERE id = $${values.length} - RETURNING * + RETURNING id ` - const res = await query(sql, values) - return res.rows[0] || null + const res = await query<{ id: string | number }>(sql, values) + const updatedId = res.rows[0]?.id + // 重新走带 join 的详情查询:RETURNING * 拿不到对接客服姓名, + // 编辑保存后的即时回显会变成空白(列表刷新前)。 + return updatedId === undefined ? null : getConsignmentInventoryById(updatedId) } /** @@ -880,9 +910,13 @@ export async function listActiveConsignmentInventoriesForMatching(options?: { const sql = ` SELECT ci.*, - cs.submission_no AS source_submission_no + cs.submission_no AS source_submission_no, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id WHERE ${conditions.join(' AND ')} ORDER BY ci.id ASC ` @@ -913,12 +947,12 @@ export async function listConsignmentSubmissions(filters: { if (filters.keyword) { values.push(`%${filters.keyword.trim()}%`) conditions.push( - `(cs.item_name ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR cs.contact_info ILIKE $${values.length} OR cs.seller_game_id ILIKE $${values.length} OR cs.seller_phone ILIKE $${values.length})`, + `(cs.item_name ILIKE $${values.length} OR cs.submission_no ILIKE $${values.length} OR cs.contact_info ILIKE $${values.length} OR cs.seller_game_id ILIKE $${values.length} OR cs.seller_phone ILIKE $${values.length} OR ccs.name ILIKE $${values.length})`, ) } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs ${whereClause}` + const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_submissions cs LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_id ${whereClause}` const countRes = await query<{ total: number }>(countSql, values) const total = countRes.rows[0]?.total || 0 @@ -934,9 +968,13 @@ export async function listConsignmentSubmissions(filters: { 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 + wo.status AS gift_work_order_status, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_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 ${whereClause} @@ -1152,9 +1190,13 @@ export async function listConsignmentSubmissionsByIds( 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 + wo.status AS gift_work_order_status, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_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 @@ -1179,10 +1221,14 @@ export async function getConsignmentInventoryBySubmissionId( SELECT ci.*, wc.name AS category_name, - cs.submission_no AS source_submission_no + cs.submission_no AS source_submission_no, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id WHERE ci.source_submission_id = $1 ORDER BY ci.id DESC LIMIT 1 @@ -1201,9 +1247,13 @@ export async function getConsignmentSubmissionById( 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 + wo.status AS gift_work_order_status, + ccs.name AS contact_supporter_name, + ccs.phone AS contact_supporter_phone, + ccs.wechat AS contact_supporter_wechat FROM consignment_submissions cs LEFT JOIN work_categories wc ON wc.id = cs.category_id + LEFT JOIN consignment_contact_supporters ccs ON ccs.id = cs.contact_supporter_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 = $1 @@ -1234,6 +1284,7 @@ export async function createConsignmentSubmission(input: { workerName?: string workerPhone?: string depositAmountFen?: number + contactSupporterId?: number | null now: string }): Promise { return withTransaction((client) => createConsignmentSubmissionWithClient(client, input)) @@ -1262,6 +1313,7 @@ export async function createConsignmentSubmissionWithClient( workerName?: string workerPhone?: string depositAmountFen?: number + contactSupporterId?: number | null now: string }, ): Promise { @@ -1272,14 +1324,14 @@ export async function createConsignmentSubmissionWithClient( seller_game_id, seller_payment_info, seller_phone, contact_info, content_json, screenshots_json, asking_price_fen, worker_id, worker_name, worker_phone, deposit_amount_fen, - status, created_at, updated_at + contact_supporter_id, status, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, $15::jsonb, $16, $17, $18, $19, $20, - 'pending_review', $21, $21 + $21, 'pending_review', $22, $22 ) RETURNING * ` @@ -1304,6 +1356,7 @@ export async function createConsignmentSubmissionWithClient( input.workerName || '', input.workerPhone || '', input.depositAmountFen || 0, + input.contactSupporterId || null, input.now, ]) return res.rows[0]! @@ -1371,6 +1424,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { workerName?: string | undefined workerPhone?: string | undefined depositAmountFen?: number | undefined + contactSupporterId?: number | null | undefined now: string }): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> { return withTransaction(async (client: PoolClient) => { @@ -1391,6 +1445,10 @@ export async function approveConsignmentSubmissionWithInbound(input: { input.depositAmountFen !== undefined ? input.depositAmountFen : submission.deposit_amount_fen || 0 + const contactSupporterId = + input.contactSupporterId !== undefined + ? input.contactSupporterId + : (submission.contact_supporter_id ?? null) const invSql = ` INSERT INTO consignment_inventories ( @@ -1400,7 +1458,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { seller_phone, credentials_json, screenshots_json, remark, allow_matching, status, allocation_status, source_type, source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen, - inbound_at, created_at, updated_at + contact_supporter_id, inbound_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, @@ -1408,7 +1466,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { $16, $17::jsonb, $18::jsonb, $19, TRUE, 'in_stock', '待安排', 'worker_portal', $20, $21, $22, $23, $24, - $25, $25, $25 + $25, $26, $26, $26 ) RETURNING * ` @@ -1437,6 +1495,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { workerName, workerPhone, depositAmountFen, + contactSupporterId, input.now, ] const invRes = await client.query(invSql, invValues) diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index a5e60432..77730d14 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -26,3 +26,4 @@ export * from './after-sales-repo.js' export * from './product-match-repo.js' export * from './sms-code-repo.js' export * from './consignment-repo.js' +export * from './consignment-contact-repo.js' diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 2b24b36c..c5d8d58d 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -13,6 +13,13 @@ import { toggleAdminConsignmentMatching, updateAdminConsignmentInventory, } from '../../../services/worker-platform/consignment-admin-service.js' +import { + createAdminConsignmentContact, + deleteAdminConsignmentContact, + fetchAdminConsignmentContacts, + setAdminConsignmentContactEnabled, + updateAdminConsignmentContact, +} from '../../../services/worker-platform/consignment-contact-service.js' import { assignWorkOrderWithConsignmentWorker, checkWorkOrderInventoryMatches, @@ -54,6 +61,64 @@ router.get( }), ) +// ---------------- 对接客服名单 ---------------- + +router.get( + '/worker-platform/consignment/contacts', + requireAdminPermission('consignment.view'), + createJsonHandler((req) => fetchAdminConsignmentContacts(req.query), { + successMessage: 'ok', + errorMessage: '读取对接客服名单失败', + scope: '[admin/consignment/contacts]', + }), +) + +router.post( + '/worker-platform/consignment/contacts', + requireAdminPermission('consignment.manage'), + createJsonHandler((req) => createAdminConsignmentContact(req.body || {}), { + successMessage: '对接客服已新增', + errorMessage: '新增对接客服失败', + scope: '[admin/consignment/contacts/create]', + }), +) + +router.put( + '/worker-platform/consignment/contacts/:id', + requireAdminPermission('consignment.manage'), + createJsonHandler( + (req) => updateAdminConsignmentContact(String(req.params.id || ''), req.body || {}), + { + successMessage: '对接客服已更新', + errorMessage: '更新对接客服失败', + scope: '[admin/consignment/contacts/update]', + }, + ), +) + +router.patch( + '/worker-platform/consignment/contacts/:id/enabled', + requireAdminPermission('consignment.manage'), + createJsonHandler( + (req) => setAdminConsignmentContactEnabled(String(req.params.id || ''), req.body?.enabled), + { + successMessage: '对接客服状态已更新', + errorMessage: '更新对接客服状态失败', + scope: '[admin/consignment/contacts/enabled]', + }, + ), +) + +router.delete( + '/worker-platform/consignment/contacts/:id', + requireAdminPermission('consignment.manage'), + createJsonHandler((req) => deleteAdminConsignmentContact(String(req.params.id || '')), { + successMessage: '对接客服已删除', + errorMessage: '删除对接客服失败', + scope: '[admin/consignment/contacts/delete]', + }), +) + router.get( '/worker-platform/consignment/inventories/:id', requireAdminPermission('consignment.view'), diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index 44cb69e3..3e649905 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -7,11 +7,25 @@ import { publishWorkerConsignment, submitWorkerConsignmentGiftEvidence, } from '../../services/worker-platform/consignment-worker-service.js' +import { fetchWorkerConsignmentContacts } from '../../services/worker-platform/consignment-contact-service.js' import { sendRouteError } from '../../utils/http.js' import { getRequiredWorkerSession, requireActiveWorker } from './session.js' const router = Router() +/** + * GET /api/v1/worker/consignment/contacts + * 打手发布寄卖时的「对接客服」下拉:只返回启用中的名单 + */ +router.get('/consignment/contacts', async (req, res) => { + try { + const result = await fetchWorkerConsignmentContacts() + res.json({ ok: true, data: result }) + } catch (error) { + sendRouteError(res, error, '获取对接客服失败', '[worker/consignment/contacts]') + } +}) + /** * POST /api/v1/worker/consignment/publish * 打手在接单平台直接提报苏尔南手枪寄卖 diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 17cc20bd..0e34f5f9 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -226,6 +226,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { workerPhone: row.worker_phone || '', assignedWorkerId: row.assigned_worker_id ? Number(row.assigned_worker_id) : null, depositAmountFen: Number(row.deposit_amount_fen || 0), + contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null, + contactSupporterName: row.contact_supporter_name || '', + contactSupporterPhone: row.contact_supporter_phone || '', + contactSupporterWechat: row.contact_supporter_wechat || '', inboundAt: row.inbound_at || row.created_at, consumedAt: row.consumed_at, createdAt: row.created_at, @@ -266,6 +270,10 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { workerName: row.worker_name || '', workerPhone: row.worker_phone || '', depositAmountFen: Number(row.deposit_amount_fen || 0), + contactSupporterId: row.contact_supporter_id ? Number(row.contact_supporter_id) : null, + contactSupporterName: row.contact_supporter_name || '', + contactSupporterPhone: row.contact_supporter_phone || '', + contactSupporterWechat: row.contact_supporter_wechat || '', status: row.status, reviewNote: row.review_note || '', reviewerName: row.reviewer_name || '', @@ -407,6 +415,12 @@ export async function updateAdminConsignmentInventory( workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined, depositAmountFen: payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined, + contactSupporterId: + payload.contactSupporterId !== undefined + ? payload.contactSupporterId + ? Number(payload.contactSupporterId) + : null + : undefined, now, }) @@ -655,12 +669,25 @@ export async function approveAdminConsignmentSubmission( workerName, workerPhone, depositAmountFen, + contactSupporterId: + payload.contactSupporterId !== undefined + ? payload.contactSupporterId + ? Number(payload.contactSupporterId) + : null + : undefined, now, }) + // 入库 INSERT 的 RETURNING * 不带 join 出来的对接客服姓名,这里按 id 重读一次再出参, + // 避免审核成功后前端立即刷新时「对接客服」短暂显示为空。 + const [joinedInventory, joinedSubmission] = await Promise.all([ + getConsignmentInventoryById(res.inventory.id), + getConsignmentSubmissionById(id), + ]) + return { - submission: mapConsignmentSubmission(res.submission), - inventory: mapConsignmentInventory(res.inventory), + submission: mapConsignmentSubmission(joinedSubmission || res.submission), + inventory: mapConsignmentInventory(joinedInventory || res.inventory), } } diff --git a/apps/backend/src/services/worker-platform/consignment-contact-service.ts b/apps/backend/src/services/worker-platform/consignment-contact-service.ts new file mode 100644 index 00000000..8fb6a08f --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-contact-service.ts @@ -0,0 +1,232 @@ +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { + countConsignmentContactReferences, + createConsignmentContact, + deleteConsignmentContact, + findConsignmentContactByName, + getConsignmentContactById, + listConsignmentContacts, + listEnabledConsignmentContacts, + setConsignmentContactEnabled, + updateConsignmentContact, + type ConsignmentContactRow, +} from '../../repositories/worker-platform/consignment-contact-repo.js' + +export function mapConsignmentContact(row: ConsignmentContactRow) { + return { + id: Number(row.id), + name: row.name, + phone: row.phone || '', + wechat: row.wechat || '', + note: row.note || '', + enabled: Boolean(row.enabled), + sortOrder: Number(row.sort_order || 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function normalizeText(value: unknown): string { + return String(value ?? '').trim() +} + +function normalizeSortOrder(value: unknown): number { + const num = Number(value) + return Number.isFinite(num) ? Math.round(num) : 0 +} + +function requireContactName(value: unknown): string { + const name = normalizeText(value) + if (!name) { + throw createHttpError('请填写对接客服姓名', { + statusCode: 400, + errorCode: 'consignment_contact_name_required', + }) + } + if (name.length > 60) { + throw createHttpError('对接客服姓名不能超过 60 个字符', { + statusCode: 400, + errorCode: 'consignment_contact_name_too_long', + }) + } + return name +} + +async function assertContactNameAvailable(name: string, excludeId?: number | string) { + const duplicated = await findConsignmentContactByName(name, excludeId) + if (duplicated) { + throw createHttpError(`已存在同名对接客服「${name}」,请换一个名字`, { + statusCode: 409, + errorCode: 'consignment_contact_name_duplicated', + }) + } +} + +/** 管理端:完整名单(含禁用),用于配置页。 */ +export async function fetchAdminConsignmentContacts(query: JsonObject = {}) { + const keyword = normalizeText(query.keyword) + const enabledRaw = normalizeText(query.enabled) + const enabled = enabledRaw === 'true' ? true : enabledRaw === 'false' ? false : undefined + const rows = await listConsignmentContacts({ + keyword: keyword || undefined, + enabled, + }) + return { + items: rows.map(mapConsignmentContact), + total: rows.length, + } +} + +/** 打手端:发布下拉只取启用项。 */ +export async function fetchWorkerConsignmentContacts() { + const rows = await listEnabledConsignmentContacts() + return { + items: rows.map(mapConsignmentContact), + } +} + +export async function createAdminConsignmentContact(payload: JsonObject) { + const name = requireContactName(payload.name) + await assertContactNameAvailable(name) + const row = await createConsignmentContact({ + name, + phone: normalizeText(payload.phone), + wechat: normalizeText(payload.wechat), + note: normalizeText(payload.note), + enabled: payload.enabled !== false, + sortOrder: normalizeSortOrder(payload.sortOrder), + now: nowIso(), + }) + return mapConsignmentContact(row) +} + +export async function updateAdminConsignmentContact(id: number | string, payload: JsonObject) { + const current = await getConsignmentContactById(id) + if (!current) { + throw createHttpError('对接客服不存在', { + statusCode: 404, + errorCode: 'consignment_contact_not_found', + }) + } + + const name = payload.name !== undefined ? requireContactName(payload.name) : undefined + if (name !== undefined) { + await assertContactNameAvailable(name, id) + } + + const updated = await updateConsignmentContact(id, { + name, + phone: payload.phone !== undefined ? normalizeText(payload.phone) : undefined, + wechat: payload.wechat !== undefined ? normalizeText(payload.wechat) : undefined, + note: payload.note !== undefined ? normalizeText(payload.note) : undefined, + sortOrder: payload.sortOrder !== undefined ? normalizeSortOrder(payload.sortOrder) : undefined, + now: nowIso(), + }) + if (!updated) { + throw createHttpError('对接客服不存在', { + statusCode: 404, + errorCode: 'consignment_contact_not_found', + }) + } + return mapConsignmentContact(updated) +} + +export async function setAdminConsignmentContactEnabled( + id: number | string, + enabledValue: unknown, +) { + if (typeof enabledValue !== 'boolean') { + throw createHttpError('启用状态必须是布尔值', { + statusCode: 400, + errorCode: 'consignment_contact_enabled_invalid', + }) + } + const enabled = enabledValue + const current = await getConsignmentContactById(id) + if (!current) { + throw createHttpError('对接客服不存在', { + statusCode: 404, + errorCode: 'consignment_contact_not_found', + }) + } + if (!enabled && current.enabled) { + const enabledRows = await listEnabledConsignmentContacts() + if (enabledRows.length <= 1) { + throw createHttpError('至少保留一个启用中的对接客服,否则打手无法发布寄卖', { + statusCode: 409, + errorCode: 'consignment_contact_last_enabled', + }) + } + } + const updated = await setConsignmentContactEnabled(id, enabled, nowIso()) + if (!updated) { + throw createHttpError('对接客服不存在', { + statusCode: 404, + errorCode: 'consignment_contact_not_found', + }) + } + return mapConsignmentContact(updated) +} + +export async function deleteAdminConsignmentContact(id: number | string) { + const current = await getConsignmentContactById(id) + if (!current) { + throw createHttpError('对接客服不存在', { + statusCode: 404, + errorCode: 'consignment_contact_not_found', + }) + } + + const referenceCount = await countConsignmentContactReferences(id) + if (referenceCount > 0) { + throw createHttpError( + `该对接客服已被 ${referenceCount} 条寄卖记录使用,不能删除;请改为「禁用」,历史记录仍会保留。`, + { statusCode: 409, errorCode: 'consignment_contact_in_use' }, + ) + } + + if (current.enabled) { + const enabledRows = await listEnabledConsignmentContacts() + if (enabledRows.length <= 1) { + throw createHttpError('至少保留一个启用中的对接客服,请先新增并启用其他客服', { + statusCode: 409, + errorCode: 'consignment_contact_last_enabled', + }) + } + } + + const deleted = await deleteConsignmentContact(id) + if (!deleted) { + throw createHttpError('对接客服不存在', { + statusCode: 404, + errorCode: 'consignment_contact_not_found', + }) + } + return { deleted: true } +} + +/** + * 发布寄卖时校验「对接客服」:必填 + 必须存在于启用名单。 + * 返回整行,调用方取 id 落库即可。 + */ +export async function resolveEnabledConsignmentContact( + contactSupporterId: unknown, +): Promise { + const id = Number(contactSupporterId) + if (!Number.isFinite(id) || id <= 0) { + throw createHttpError('请选择对接客服', { + statusCode: 400, + errorCode: 'consignment_contact_required', + }) + } + const row = await getConsignmentContactById(id) + if (!row || !row.enabled) { + throw createHttpError('对接客服不存在或已停用,请刷新后重新选择', { + statusCode: 400, + errorCode: 'consignment_contact_invalid', + }) + } + return row +} diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 1a6dfd34..601bf327 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -507,6 +507,12 @@ export async function fulfillWorkOrderWithConsignment(input: { sellerGameId: inv.seller_game_id || '', sellerContact: inv.seller_contact || '', sellerPhone: inv.seller_phone || '', + contactSupporterId: inventory.contact_supporter_id + ? Number(inventory.contact_supporter_id) + : null, + contactSupporterName: inventory.contact_supporter_name || '', + contactSupporterPhone: inventory.contact_supporter_phone || '', + contactSupporterWechat: inventory.contact_supporter_wechat || '', workerName: inv.worker_name || '', workerPhone: inv.worker_phone || '', remark: inv.remark || '', @@ -552,6 +558,10 @@ export async function fulfillWorkOrderWithConsignment(input: { priceAdjustReason: '寄卖内部现货履约按现货结算成本价更新发单金额', buyerName: inv.buyer_name, shopName, + contactSupporterId: inventory.contact_supporter_id + ? Number(inventory.contact_supporter_id) + : null, + contactSupporterName: inventory.contact_supporter_name || '', matchType: specMatch?.matchType || (input.allowSpecMismatch === true ? 'conflict' : 'exact'), manualMatch: Boolean(matchNote) || input.allowSpecMismatch === true, @@ -757,6 +767,12 @@ export async function assignWorkOrderWithConsignmentWorker(input: { sellerGameId: claimed.seller_game_id || '', sellerContact: claimed.seller_contact || '', sellerPhone: claimed.seller_phone || '', + contactSupporterId: inventory.contact_supporter_id + ? Number(inventory.contact_supporter_id) + : null, + contactSupporterName: inventory.contact_supporter_name || '', + contactSupporterPhone: inventory.contact_supporter_phone || '', + contactSupporterWechat: inventory.contact_supporter_wechat || '', workerId: Number(targetWorkerId), workerName: claimed.worker_name || '', workerPhone: claimed.worker_phone || '', 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 2eeeb982..f8dbe932 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -15,6 +15,7 @@ import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-ord import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js' import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js' +import { resolveEnabledConsignmentContact } from './consignment-contact-service.js' import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js' import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js' import type { JsonObject } from '../../types/json.js' @@ -43,6 +44,21 @@ function toRecord(value: unknown): Record | null { : null } +/** 提报单 / 现货都可能带对接客服信息,统一成前端字段。 */ +function mapConsignmentContactSupporter(source: { + contact_supporter_id?: string | number | null + contact_supporter_name?: string + contact_supporter_phone?: string + contact_supporter_wechat?: string +}) { + return { + contactSupporterId: source.contact_supporter_id ? Number(source.contact_supporter_id) : null, + contactSupporterName: source.contact_supporter_name || '', + contactSupporterPhone: source.contact_supporter_phone || '', + contactSupporterWechat: source.contact_supporter_wechat || '', + } +} + function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) { if (!boundWo) { return { @@ -162,6 +178,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb }) } + // 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。 + const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId) + const system = String(payload.system || '').trim() const serverZone = String(payload.serverZone || '').trim() const series = String(payload.series || '').trim() @@ -298,6 +317,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb workerName: worker.display_name || worker.username, workerPhone: sellerPhone || worker.phone || '', depositAmountFen, + contactSupporterId: Number(contactSupporter.id), now, }) @@ -330,6 +350,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb workerName: submission.worker_name, workerPhone: submission.worker_phone, depositAmountFen: submission.deposit_amount_fen, + contactSupporterId: submission.contact_supporter_id + ? Number(submission.contact_supporter_id) + : null, + contactSupporterName: contactSupporter.name, + contactSupporterPhone: contactSupporter.phone || '', + contactSupporterWechat: contactSupporter.wechat || '', createdAt: submission.created_at, } } catch (error) { @@ -438,6 +464,8 @@ export async function listWorkerConsignments( const evidence = showingMatchedBuyer ? extractConsignmentGiftEvidenceFromWorkOrder(boundWo) : { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' } + // 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。 + const contactSupporter = inv?.contact_supporter_id ? inv : sub return { /** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */ consignmentNo: sub.submission_no, @@ -452,6 +480,7 @@ export async function listWorkerConsignments( brillianceCount: sub.brilliance_count, askingPriceFen: sub.asking_price_fen, depositAmountFen: sub.deposit_amount_fen, + ...mapConsignmentContactSupporter(contactSupporter), reviewNote: sub.review_note, // 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图 screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : []) @@ -496,6 +525,7 @@ export async function listWorkerConsignments( brillianceCount: inv.brilliance_count, askingPriceFen: inv.cost_price_fen, depositAmountFen: inv.deposit_amount_fen, + ...mapConsignmentContactSupporter(inv), reviewNote: '', screenshotsJson: [], giftEvidenceImages: evidence.giftEvidenceImages, diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 1ba3fc5c..f0b60fd8 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -62,3 +62,4 @@ export * from './worker-announcement-config-service.js' export * from './worker-hall-config-service.js' export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' +export * from './consignment-contact-service.js' diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 8591e9cf..37934933 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -24,6 +24,9 @@ const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage')) const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage')) const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage')) const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage')) +const AdminConsignmentContactsPage = lazy( + () => import('@/pages/admin/AdminConsignmentContactsPage'), +) const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage')) @@ -166,6 +169,7 @@ function AdminApplication() { }> } /> + } /> } /> } /> } /> diff --git a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx index 271ee90c..216435cd 100644 --- a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx @@ -28,6 +28,7 @@ import { resolveManualMatchRequirement, type ManualMatchType, } from '@/utils/consignment-manual-match' +import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { formatDateTime } from '@/utils/date-time' import { notifyVoucherConsume } from '@/utils/voucher-consume' @@ -45,6 +46,10 @@ type Selected = { /** 供货打手(用于回显供货来源) */ workerId?: number | null workerName?: string + /** 对接客服(用于履约前核对归属) */ + contactSupporterName?: string + contactSupporterPhone?: string + contactSupporterWechat?: string } export type ConsignmentManualMatchModalProps = { @@ -79,6 +84,11 @@ export default function ConsignmentManualMatchModal({ workerId: number | null workerName: string } | null>(null) + const [inventoryContact, setInventoryContact] = useState<{ + contactSupporterName: string + contactSupporterPhone: string + contactSupporterWechat: string + } | null>(null) const [selected, setSelected] = useState(null) const [confirmedPartial, setConfirmedPartial] = useState(false) const [confirmedMismatch, setConfirmedMismatch] = useState(false) @@ -94,6 +104,7 @@ export default function ConsignmentManualMatchModal({ setConfirmedMismatch(false) setMatchNote('') setInventoryInboundAt('') + setInventoryContact(null) } const load = async () => { @@ -116,6 +127,11 @@ export default function ConsignmentManualMatchModal({ workerId: res.data?.inventory?.workerId ?? null, workerName: res.data?.inventory?.workerName || '', }) + setInventoryContact({ + contactSupporterName: res.data?.inventory?.contactSupporterName || '', + contactSupporterPhone: res.data?.inventory?.contactSupporterPhone || '', + contactSupporterWechat: res.data?.inventory?.contactSupporterWechat || '', + }) } else if (workOrder) { const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId) setOptions(res.data?.options || []) @@ -319,7 +335,8 @@ export default function ConsignmentManualMatchModal({ ? `${option.inventory.diamondLevel}钻` : ''}{' '} · 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥ - {(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} + {(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} · + 对接客服 {formatConsignmentContactSupporter(option.inventory).text} {option.inventory.inboundAt || option.inventory.createdAt ? ( <> {' '} @@ -356,6 +373,9 @@ export default function ConsignmentManualMatchModal({ conflicts: option.conflicts, workerId: option.inventory.workerId, workerName: option.inventory.workerName, + contactSupporterName: option.inventory.contactSupporterName, + contactSupporterPhone: option.inventory.contactSupporterPhone, + contactSupporterWechat: option.inventory.contactSupporterWechat, }) setConfirmedPartial(false) setConfirmedMismatch(false) @@ -439,6 +459,9 @@ export default function ConsignmentManualMatchModal({ conflicts: order.conflicts, workerId: inventoryWorker?.workerId ?? null, workerName: inventoryWorker?.workerName || '', + contactSupporterName: inventoryContact?.contactSupporterName || '', + contactSupporterPhone: inventoryContact?.contactSupporterPhone || '', + contactSupporterWechat: inventoryContact?.contactSupporterWechat || '', }) setConfirmedPartial(false) setConfirmedMismatch(false) @@ -492,6 +515,9 @@ export default function ConsignmentManualMatchModal({ 内部库存现货(无打手关联) )} + + {formatConsignmentContactSupporter(selected).text} + {describeManualMatchType(selected.matchType).label} diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index bbdbed4c..34041eb0 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -4,6 +4,7 @@ import { ApartmentOutlined, BellOutlined, BugOutlined, + CustomerServiceOutlined, DashboardOutlined, FileSearchOutlined, LogoutOutlined, @@ -282,6 +283,11 @@ export default function AdminLayout() { label: '配置', children: [ { key: '/admin/users', icon: , label: '后台用户' }, + { + key: '/admin/consignment-contacts', + icon: , + label: '对接客服', + }, { key: '/admin/platform-shops', icon: , label: '平台配置' }, { key: '/admin/platform-fulfillment', icon: , label: '履约配置' }, ], @@ -850,6 +856,7 @@ function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/admin/finance')) return '/admin/finance' if (pathname.startsWith('/admin/tasks')) return '/admin/tasks' if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform' + if (pathname.startsWith('/admin/consignment-contacts')) return '/admin/consignment-contacts' if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry' if (pathname === '/admin/platform-shops') return '/admin/platform-shops' return pathname diff --git a/apps/frontend/src/pages/admin/AdminConsignmentContactsPage.tsx b/apps/frontend/src/pages/admin/AdminConsignmentContactsPage.tsx new file mode 100644 index 00000000..ac9c52b2 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminConsignmentContactsPage.tsx @@ -0,0 +1,343 @@ +import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Switch, + Tag, + Typography, +} from 'antd' +import { useEffect, useState } from 'react' + +import PageHeader from '@/components/admin/PageHeader' +import PercentTable from '@/components/common/PercentTable' +import { + createAdminConsignmentContact, + deleteAdminConsignmentContact, + fetchAdminConsignmentContacts, + setAdminConsignmentContactEnabled, + updateAdminConsignmentContact, +} from '@/services/admin/consignment' +import type { ConsignmentContactSupporter } from '@/types/consignment' +import { formatDateTime } from '@/utils/date-time' +import type { TableColumnsType } from 'antd' + +const { Text } = Typography + +type ContactForm = { + name: string + phone?: string + wechat?: string + note?: string + sortOrder?: number + enabled?: boolean +} + +const ENABLED_FILTER_OPTIONS = [ + { label: '全部状态', value: '' }, + { label: '启用中', value: 'true' }, + { label: '已禁用', value: 'false' }, +] + +/** + * 配置 → 对接客服。 + * + * 独立名单(不复用后台账号):打手发布寄卖时必选,后台按人统计提成与快速联系。 + * 已被寄卖记录引用的客服只能禁用,不能删除,保证历史单归属不丢。 + */ +export default function AdminConsignmentContactsPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + + const [keywordInput, setKeywordInput] = useState('') + const [keyword, setKeyword] = useState('') + const [enabledFilter, setEnabledFilter] = useState('') + const [editingContact, setEditingContact] = useState(null) + const [formOpen, setFormOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [actionId, setActionId] = useState(null) + + useEffect(() => { + const timer = window.setTimeout(() => setKeyword(keywordInput.trim()), 300) + return () => window.clearTimeout(timer) + }, [keywordInput]) + + const query = useQuery({ + queryKey: ['admin-consignment-contacts', keyword, enabledFilter], + queryFn: () => + fetchAdminConsignmentContacts({ + keyword: keyword || undefined, + enabled: enabledFilter || undefined, + }), + }) + + const items = query.data?.data.items || [] + const total = Number(query.data?.data.total || 0) + const enabledCount = items.filter((item) => item.enabled).length + + async function reload() { + await queryClient.invalidateQueries({ queryKey: ['admin-consignment-contacts'] }) + } + + function openCreate() { + setEditingContact(null) + form.resetFields() + form.setFieldsValue({ enabled: true, sortOrder: 0 }) + setFormOpen(true) + } + + function openEdit(record: ConsignmentContactSupporter) { + setEditingContact(record) + form.setFieldsValue({ + name: record.name, + phone: record.phone, + wechat: record.wechat, + note: record.note, + sortOrder: record.sortOrder, + enabled: record.enabled, + }) + setFormOpen(true) + } + + async function handleSubmit() { + try { + const values = await form.validateFields() + setSubmitting(true) + const payload = { + name: values.name, + phone: values.phone || '', + wechat: values.wechat || '', + note: values.note || '', + sortOrder: Number(values.sortOrder || 0), + enabled: values.enabled !== false, + } + if (editingContact) { + await updateAdminConsignmentContact(editingContact.id, payload) + message.success('对接客服已更新') + } else { + await createAdminConsignmentContact(payload) + message.success('对接客服已新增') + } + setFormOpen(false) + setEditingContact(null) + await reload() + } catch (error: any) { + if (error?.errorFields) return + message.error(error?.message || '保存对接客服失败') + } finally { + setSubmitting(false) + } + } + + async function handleToggleEnabled(record: ConsignmentContactSupporter, enabled: boolean) { + setActionId(record.id) + try { + await setAdminConsignmentContactEnabled(record.id, enabled) + message.success(enabled ? '已启用该对接客服' : '已禁用该对接客服') + await reload() + } catch (error: any) { + message.error(error?.message || '更新对接客服状态失败') + } finally { + setActionId(null) + } + } + + function handleDelete(record: ConsignmentContactSupporter) { + modal.confirm({ + title: `删除对接客服「${record.name}」?`, + content: + '只有未被任何寄卖记录使用的客服可以删除。已被使用的请改为「禁用」,历史寄卖单仍会保留对接客服信息。', + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminConsignmentContact(record.id) + message.success('对接客服已删除') + await reload() + } catch (error: any) { + message.error(error?.message || '删除对接客服失败') + throw error + } + }, + }) + } + + const columns: TableColumnsType = [ + { + title: '姓名', + key: 'name', + width: 160, + render: (_, record) => ( + + {record.name} + {record.note ? ( + + {record.note} + + ) : null} + + ), + }, + { + title: '手机号', + dataIndex: 'phone', + width: 150, + render: (value: string) => value || -, + }, + { + title: '微信号', + dataIndex: 'wechat', + width: 160, + render: (value: string) => value || -, + }, + { + title: '排序', + dataIndex: 'sortOrder', + width: 80, + render: (value: number) => value || 0, + }, + { + title: '状态', + key: 'enabled', + width: 110, + render: (_, record) => ( + + void handleToggleEnabled(record, checked)} + /> + + {record.enabled ? '启用中' : '已禁用'} + + + ), + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 170, + render: (value: string) => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 140, + render: (_, record) => ( + + + + + ), + }, + ] + + return ( +
+ } onClick={openCreate}> + 新增对接客服 + + } + /> + + + + } + placeholder="搜索姓名 / 手机 / 微信 / 备注" + value={keywordInput} + onChange={(event) => setKeywordInput(event.target.value)} + style={{ width: 280 }} + /> + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index e9d2c4d4..a48d0f2f 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -29,6 +29,7 @@ import { useState } from 'react' import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' import { archiveAdminConsignmentInventory, + fetchAdminConsignmentContacts, fetchAdminConsignmentInventories, fetchAdminConsignmentStats, returnAdminConsignmentInventory, @@ -40,6 +41,7 @@ import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMat import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment' import { fetchAdminSession } from '@/services/admin/auth' import { resolveAdminPermission } from '@/utils/admin-auth' +import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec' import { formatDateTime } from '@/utils/date-time' @@ -174,6 +176,18 @@ export default function ConsignmentInventoryPanel() { name: w.displayName || w.username, })) + // 对接客服名单:编辑现货时可改派对接人(含已禁用项,历史数据需要能显示原值) + const contactsQuery = useQuery({ + queryKey: ['admin-consignment-contacts'], + queryFn: () => fetchAdminConsignmentContacts(), + }) + const contactSupporterOptions = (contactsQuery.data?.data.items || []).map((item) => ({ + label: `${item.name}${item.enabled ? '' : '(已禁用)'}${ + item.phone ? ` · ${item.phone}` : item.wechat ? ` · ${item.wechat}` : '' + }`, + value: item.id, + })) + // 数据查询 const inventoriesQuery = useQuery({ queryKey: [ @@ -255,6 +269,7 @@ export default function ConsignmentInventoryPanel() { workerId: values.workerId || undefined, workerName: selectedWorker?.name || editingItem.workerName || undefined, workerPhone: selectedWorker?.phone || editingItem.workerPhone || undefined, + contactSupporterId: values.contactSupporterId ?? null, remark: values.remark, allowMatching: values.allowMatching !== false, }) @@ -456,6 +471,22 @@ export default function ConsignmentInventoryPanel() { ), }, + { + title: '对接客服', + key: 'contactSupporter', + width: 150, + render: (_, record) => { + const contact = formatConsignmentContactSupporter(record) + return ( +
+
{contact.name || contact.text}
+ {contact.contact ? ( +
{contact.contact}
+ ) : null} +
+ ) + }, + }, { title: '凭证', key: 'evidence', @@ -990,6 +1021,24 @@ export default function ConsignmentInventoryPanel() { + + + + @@ -1082,6 +1131,23 @@ export default function ConsignmentInventoryPanel() { 内部自营现货 )}
+ + {(() => { + const contact = formatConsignmentContactSupporter(viewingItem) + return contact.name ? ( + + {contact.name} + {contact.contact ? ( + + {contact.contact} + + ) : null} + + ) : ( + 未指定 + ) + })()} + {viewingItem.depositAmountFen ? ( ¥{(viewingItem.depositAmountFen / 100).toFixed(0)} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index f9357469..f1bb0aa3 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -40,6 +40,7 @@ import { fetchAdminSession } from '@/services/admin/auth' import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform' import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment' import { resolveAdminPermission } from '@/utils/admin-auth' +import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec' import { formatDateTime } from '@/utils/date-time' @@ -271,6 +272,22 @@ export default function ConsignmentSubmissionsPanel() { ), }, + { + title: '对接客服', + key: 'contactSupporter', + width: 150, + render: (_, record) => { + const contact = formatConsignmentContactSupporter(record) + return ( +
+
{contact.name || contact.text}
+ {contact.contact ? ( +
{contact.contact}
+ ) : null} +
+ ) + }, + }, { title: '提报凭证(游戏主页截图)', key: 'submissionScreenshots', @@ -536,6 +553,10 @@ export default function ConsignmentSubmissionsPanel() { 联系方式:{approveModalItem?.contactInfo || '-'} + + 对接客服: + {formatConsignmentContactSupporter(approveModalItem).text} + 游戏ID:{approveModalItem?.sellerGameId || '-'} @@ -852,6 +873,23 @@ export default function ConsignmentSubmissionsPanel() { 未关联打手账号 )}
+ + {(() => { + const contact = formatConsignmentContactSupporter(viewingSubmission) + return contact.name ? ( + + {contact.name} + {contact.contact ? ( + + {contact.contact} + + ) : null} + + ) : ( + 未指定 + ) + })()} + {viewingSubmission.depositAmountFen ? ( ¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 2e82affe..62341f92 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -92,6 +92,7 @@ import { notifyVoucherConsume } from '@/utils/voucher-consume' import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment' import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match' +import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { isConsignmentOrder, isInternalConsignmentOrder, @@ -1644,10 +1645,15 @@ export default function WorkOrdersPanel() { ) : null} {consignment ? ( - - 寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥ - {((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)}) - + + + 寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥ + {((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)}) + + + 对接客服: {formatConsignmentContactSupporter(consignment).text} + + ) : null} ) @@ -2317,6 +2323,11 @@ export default function WorkOrdersPanel() { ) : null} + {detailConsignment ? ( + + {formatConsignmentContactSupporter(detailConsignment).text} + + ) : null} {formatStatus(detailOrder.status)} @@ -2655,6 +2666,13 @@ export default function WorkOrdersPanel() { ) : null} + {isConsignment ? ( + + + {formatConsignmentContactSupporter(consignmentInfo).text} + + + ) : null} ) : null} + + 对接客服: {formatConsignmentContactSupporter(inv).text} + {inv.depositAmountFen != null ? ( 履约押金: ¥{(inv.depositAmountFen / 100).toFixed(2)} diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 42eb13fe..debaed60 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -31,6 +31,7 @@ import { Pagination, Radio, Row, + Select, Space, Tabs, Tag, @@ -43,6 +44,7 @@ import ImageUpload from '@/components/files/ImageUpload' import { WorkerContactModal } from '@/components/worker/WorkerContactModal' import { confirmWorkerConsignmentFriendAdded, + fetchWorkerConsignmentContacts, fetchWorkerConsignmentPriceSuggestion, fetchWorkerConsignments, fetchWorkerProfile, @@ -56,6 +58,7 @@ import { SURNAN_PUBLISH_SYSTEM_OPTIONS, SURNAN_PUBLISH_ZONE_OPTIONS, } from '@/utils/consignment-spec' +import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown' import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist' import { formatDateTime } from '@/utils/date-time' @@ -199,6 +202,15 @@ export default function WorkerConsignmentPage() { }), }) + // 发布寄卖必填「对接客服」:只取启用名单,默认选第一个 + const consignmentContactsQuery = useQuery({ + queryKey: ['worker-consignment-contacts'], + queryFn: () => fetchWorkerConsignmentContacts(), + staleTime: 60_000, + }) + const contactSupporters = consignmentContactsQuery.data?.data.items || [] + const firstContactSupporterId = contactSupporters[0]?.id ?? null + // 切换标签页时自动刷新最新数据 useEffect(() => { if (activeTab === 'records') { @@ -261,6 +273,14 @@ export default function WorkerConsignmentPage() { } }, [worker, form]) + // 名单异步加载完成后补默认值;用户已经选过则不覆盖 + useEffect(() => { + if (firstContactSupporterId == null) return + if (form.getFieldValue('contactSupporterId') == null) { + form.setFieldsValue({ contactSupporterId: firstContactSupporterId }) + } + }, [firstContactSupporterId, form]) + useEffect(() => { if (profileQuery.isLoading) return if (typeof window === 'undefined') return @@ -368,6 +388,15 @@ export default function WorkerConsignmentPage() { return } + if (contactSupporters.length === 0) { + message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布') + return + } + if (values.contactSupporterId == null) { + message.error('请选择对接客服') + return + } + const urls = screenshots.map((f) => f.url).filter(Boolean) if (urls.length === 0) { message.error('请上传游戏主页截图以供核验') @@ -412,10 +441,14 @@ export default function WorkerConsignmentPage() { screenshotsJson: urls, sellerNote: values.sellerNote, depositAmountFen: publishDepositFen, + contactSupporterId: Number(values.contactSupporterId), }) message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`) form.resetFields() + if (firstContactSupporterId != null) { + form.setFieldsValue({ contactSupporterId: firstContactSupporterId }) + } setPriceState({ status: 'idle' }) lastPriceQueryKeyRef.current = '' setScreenshots([]) @@ -662,6 +695,14 @@ export default function WorkerConsignmentPage() { } /> + {!consignmentContactsQuery.isLoading && contactSupporters.length === 0 ? ( + + ) : null} + 规格与价格 @@ -807,6 +848,35 @@ export default function WorkerConsignmentPage() { + + +