feat:寄卖增加对接客服,db脚本迁移修复

This commit is contained in:
2026-10-27 13:39:48 +08:00
parent 0de0fd7b58
commit 622bd779dd
25 changed files with 1515 additions and 106 deletions
@@ -1,4 +1,4 @@
-- 115_work_order_share_cancel_reason_comment.sql
-- 111_work_order_share_cancel_reason_comment.sql
-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。
-- 纯注释变更,无结构/数据改动;重复执行无副作用。
@@ -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);
@@ -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<ConsignmentContactRow[]> {
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<ConsignmentContactRow>(
`SELECT * FROM consignment_contact_supporters ${whereClause} ORDER BY sort_order ASC, id ASC`,
values,
)
return res.rows
}
/** 打手端发布下拉:只返回启用中的客服。 */
export async function listEnabledConsignmentContacts(): Promise<ConsignmentContactRow[]> {
return listConsignmentContacts({ enabled: true })
}
export async function getConsignmentContactById(
id: number | string,
): Promise<ConsignmentContactRow | null> {
const res = await query<ConsignmentContactRow>(
'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<ConsignmentContactRow | null> {
const res = await query<ConsignmentContactRow>(
`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<ConsignmentContactRow> {
const res = await query<ConsignmentContactRow>(
`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<ConsignmentContactRow | null> {
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<ConsignmentContactRow>(
`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<ConsignmentContactRow | null> {
const res = await query<ConsignmentContactRow>(
`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<number> {
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<boolean> {
const res = await query<{ id: string | number }>(
'DELETE FROM consignment_contact_supporters WHERE id = $1 RETURNING id',
[id],
)
return res.rows.length > 0
}
@@ -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<ConsignmentInventoryRow>(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<ConsignmentInventoryRow | null> {
@@ -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<ConsignmentInventoryRow>(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<ConsignmentSubmissionRow> {
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<ConsignmentSubmissionRow> {
@@ -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<ConsignmentInventoryRow>(invSql, invValues)
@@ -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'
@@ -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'),
@@ -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
* 打手在接单平台直接提报苏尔南手枪寄卖
@@ -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),
}
}
@@ -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<ConsignmentContactRow> {
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
}
@@ -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 || '',
@@ -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<string, unknown> | 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,
@@ -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'
+4
View File
@@ -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() {
</Route>
<Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<AdminUsersPage />} />
<Route path="consignment-contacts" element={<AdminConsignmentContactsPage />} />
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
<Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} />
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
@@ -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<Selected | null>(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({
<Tag color="default">内部库存现货(无打手关联)</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(selected).text}
</Descriptions.Item>
<Descriptions.Item label="匹配判定">
<Tag color={describeManualMatchType(selected.matchType).color}>
{describeManualMatchType(selected.matchType).label}
@@ -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: <TeamOutlined />, label: '后台用户' },
{
key: '/admin/consignment-contacts',
icon: <CustomerServiceOutlined />,
label: '对接客服',
},
{ key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' },
{ key: '/admin/platform-fulfillment', icon: <ShopOutlined />, 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
@@ -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<ContactForm>()
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [enabledFilter, setEnabledFilter] = useState('')
const [editingContact, setEditingContact] = useState<ConsignmentContactSupporter | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [actionId, setActionId] = useState<number | null>(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<ConsignmentContactSupporter> = [
{
title: '姓名',
key: 'name',
width: 160,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text strong>{record.name}</Text>
{record.note ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.note}
</Text>
) : null}
</Space>
),
},
{
title: '手机号',
dataIndex: 'phone',
width: 150,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '微信号',
dataIndex: 'wechat',
width: 160,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 80,
render: (value: number) => value || 0,
},
{
title: '状态',
key: 'enabled',
width: 110,
render: (_, record) => (
<Space size={6}>
<Switch
size="small"
checked={record.enabled}
loading={actionId === record.id}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => void handleToggleEnabled(record, checked)}
/>
<Tag color={record.enabled ? 'green' : 'default'} style={{ marginInlineEnd: 0 }}>
{record.enabled ? '启用中' : '已禁用'}
</Tag>
</Space>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (value: string) => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="link" onClick={() => openEdit(record)}>
编辑
</Button>
<Button size="small" type="link" danger onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
]
return (
<div>
<PageHeader
title="对接客服"
description="打手发布寄卖时必须选择对接客服;本名单独立于后台账号,用于提成归属与联系。"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新增对接客服
</Button>
}
/>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size={12}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索姓名 / 手机 / 微信 / 备注"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
style={{ width: 280 }}
/>
<Select
value={enabledFilter}
options={ENABLED_FILTER_OPTIONS}
onChange={setEnabledFilter}
style={{ width: 140 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Text type="secondary">
共 {total} 个,本页启用 {enabledCount} 个
</Text>
</Space>
</Card>
<PercentTable<ConsignmentContactSupporter>
rowKey="id"
loading={query.isLoading}
columns={columns}
dataSource={items}
pagination={false}
size="small"
/>
<Modal
title={editingContact ? `编辑对接客服 · ${editingContact.name}` : '新增对接客服'}
open={formOpen}
onCancel={() => {
setFormOpen(false)
setEditingContact(null)
}}
onOk={() => void handleSubmit()}
confirmLoading={submitting}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请填写对接客服姓名' }]}
>
<Input placeholder="如:小张" maxLength={60} />
</Form.Item>
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item name="phone" label="手机号" style={{ width: 240 }}>
<Input placeholder="选填" maxLength={30} />
</Form.Item>
<Form.Item name="wechat" label="微信号" style={{ width: 240 }}>
<Input placeholder="选填" maxLength={60} />
</Form.Item>
</Space>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} placeholder="如:负责苏尔南寄卖 / 白班" maxLength={200} />
</Form.Item>
<Space size={24} align="start">
<Form.Item
name="sortOrder"
label="排序"
tooltip="数字越小越靠前;打手端默认选中第一个启用项"
>
<InputNumber min={0} max={9999} style={{ width: 140 }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Space>
</Form>
</Modal>
</div>
)
}
@@ -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() {
</div>
),
},
{
title: '对接客服',
key: 'contactSupporter',
width: 150,
render: (_, record) => {
const contact = formatConsignmentContactSupporter(record)
return (
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{contact.name || contact.text}</div>
{contact.contact ? (
<div style={{ color: '#8c8c8c', marginTop: 2 }}>{contact.contact}</div>
) : null}
</div>
)
},
},
{
title: '凭证',
key: 'evidence',
@@ -990,6 +1021,24 @@ export default function ConsignmentInventoryPanel() {
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="contactSupporterId"
label="对接客服"
tooltip="用于提成归属与联系;发布寄卖时由卖家必选"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择对接客服(选填)"
options={contactSupporterOptions}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="sellerPaymentInfo" label="卖家收款信息">
<Input />
</Form.Item>
@@ -1082,6 +1131,23 @@ export default function ConsignmentInventoryPanel() {
<Text type="secondary">内部自营现货</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{(() => {
const contact = formatConsignmentContactSupporter(viewingItem)
return contact.name ? (
<Space direction="vertical" size={2}>
<Text strong>{contact.name}</Text>
{contact.contact ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{contact.contact}
</Text>
) : null}
</Space>
) : (
<Text type="secondary">未指定</Text>
)
})()}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingItem.depositAmountFen ? (
<Tag color="gold">¥{(viewingItem.depositAmountFen / 100).toFixed(0)}</Tag>
@@ -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() {
</div>
),
},
{
title: '对接客服',
key: 'contactSupporter',
width: 150,
render: (_, record) => {
const contact = formatConsignmentContactSupporter(record)
return (
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{contact.name || contact.text}</div>
{contact.contact ? (
<div style={{ color: '#8c8c8c', marginTop: 2 }}>{contact.contact}</div>
) : null}
</div>
)
},
},
{
title: '提报凭证(游戏主页截图)',
key: 'submissionScreenshots',
@@ -536,6 +553,10 @@ export default function ConsignmentSubmissionsPanel() {
<span>
联系方式:<Text strong>{approveModalItem?.contactInfo || '-'}</Text>
</span>
<span>
对接客服:
<Text strong>{formatConsignmentContactSupporter(approveModalItem).text}</Text>
</span>
<span>
游戏ID:<Text strong>{approveModalItem?.sellerGameId || '-'}</Text>
</span>
@@ -852,6 +873,23 @@ export default function ConsignmentSubmissionsPanel() {
<Text type="secondary">未关联打手账号</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{(() => {
const contact = formatConsignmentContactSupporter(viewingSubmission)
return contact.name ? (
<Space direction="vertical" size={2}>
<Text strong>{contact.name}</Text>
{contact.contact ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{contact.contact}
</Text>
) : null}
</Space>
) : (
<Text type="secondary">未指定</Text>
)
})()}
</Descriptions.Item>
<Descriptions.Item label="履约押金要求">
{viewingSubmission.depositAmountFen ? (
<Tag color="gold">¥{(viewingSubmission.depositAmountFen / 100).toFixed(0)}</Tag>
@@ -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() {
</Typography.Text>
) : null}
{consignment ? (
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
<Space size={[4, 4]} wrap>
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
<Tag color="cyan" style={{ marginTop: 2, width: 'fit-content' }}>
对接客服: {formatConsignmentContactSupporter(consignment).text}
</Tag>
</Space>
) : null}
</div>
)
@@ -2317,6 +2323,11 @@ export default function WorkOrdersPanel() {
</Typography.Text>
</Descriptions.Item>
) : null}
{detailConsignment ? (
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(detailConsignment).text}
</Descriptions.Item>
) : null}
<Descriptions.Item label="状态">
<Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)}
@@ -2655,6 +2666,13 @@ export default function WorkOrdersPanel() {
</Space>
</Descriptions.Item>
) : null}
{isConsignment ? (
<Descriptions.Item label="对接客服" span={2}>
<span className="worker-order-detail-value">
{formatConsignmentContactSupporter(consignmentInfo).text}
</span>
</Descriptions.Item>
) : null}
<Descriptions.Item
label={isConsignment ? '卖家备注' : '打手备注'}
span={2}
@@ -4095,6 +4113,9 @@ export default function WorkOrdersPanel() {
供货打手: {inv.workerName} ({inv.workerPhone || '无手机号'})
</Tag>
) : null}
<Tag color="cyan">
对接客服: {formatConsignmentContactSupporter(inv).text}
</Tag>
{inv.depositAmountFen != null ? (
<Tag color="warning">
履约押金: ¥{(inv.depositAmountFen / 100).toFixed(2)}
@@ -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 ? (
<Alert
type="warning"
showIcon
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
/>
) : null}
<Divider plain className="worker-consignment-divider">
规格与价格
</Divider>
@@ -807,6 +848,35 @@ export default function WorkerConsignmentPage() {
</Divider>
<Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="contactSupporterId"
label="对接客服"
rules={[{ required: true, message: '请选择对接客服' }]}
extra={
contactSupporters.length === 0
? '暂无可选客服,请联系管理员配置'
: '用于后续提成归属与联系'
}
>
<Select
placeholder="请选择对接客服"
options={contactSupporters.map((item) => ({
value: item.id,
label: `${item.name}${
item.phone
? ` · ${item.phone}`
: item.wechat
? ` · ${item.wechat}`
: ''
}`,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerContact"
@@ -1334,6 +1404,9 @@ function ConsignmentRecordCard({
<span>
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
</span>
<span>
对接客服 <strong>{formatConsignmentContactSupporter(record).text}</strong>
</span>
{record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null}
</div>
{record.reviewNote ? (
@@ -1,5 +1,6 @@
import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/http'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from '@/lib/http'
import type {
ConsignmentContactSupporter,
ConsignmentInventory,
ConsignmentStatsSummary,
ConsignmentSubmission,
@@ -8,6 +9,43 @@ import type {
WorkOrderInventoryMatchOptions,
} from '@/types/consignment'
// --- 对接客服名单 ---
export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) {
return apiGet<{ items: ConsignmentContactSupporter[]; total: number }>(
'/api/v1/admin/worker-platform/consignment/contacts',
params,
)
}
export function createAdminConsignmentContact(payload: Record<string, unknown>) {
return apiPost<ConsignmentContactSupporter>(
'/api/v1/admin/worker-platform/consignment/contacts',
payload,
)
}
export function updateAdminConsignmentContact(
id: number | string,
payload: Record<string, unknown>,
) {
return apiPut<ConsignmentContactSupporter>(
`/api/v1/admin/worker-platform/consignment/contacts/${id}`,
payload,
)
}
export function setAdminConsignmentContactEnabled(id: number | string, enabled: boolean) {
return apiPatch<ConsignmentContactSupporter>(
`/api/v1/admin/worker-platform/consignment/contacts/${id}/enabled`,
{ enabled },
)
}
export function deleteAdminConsignmentContact(id: number | string) {
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/consignment/contacts/${id}`)
}
// --- 库存管理 ---
export function fetchAdminConsignmentInventories(params?: Record<string, unknown>) {
+20 -1
View File
@@ -25,7 +25,11 @@ import type {
WorkerUser,
WorkerWalletLedger,
} from '@/types/worker-platform'
import type { ConsignmentBuyerInfo, ConsignmentSellerInfo } from '@/types/consignment'
import type {
ConsignmentBuyerInfo,
ConsignmentContactSupporter,
ConsignmentSellerInfo,
} from '@/types/consignment'
import { getWorkerDeviceId } from '@/utils/worker-auth'
@@ -392,6 +396,8 @@ export type WorkerConsignmentPublishPayload = {
screenshotsJson: string[]
sellerNote?: string
depositAmountFen?: number
/** 对接客服名单 id(必填,发布时选定) */
contactSupporterId: number
}
export type WorkerConsignmentItem = {
@@ -409,6 +415,10 @@ export type WorkerConsignmentItem = {
resultInventoryId?: number | string | null
screenshotsJson: string[]
contentJson: Record<string, unknown>
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
createdAt: string
}
@@ -450,6 +460,10 @@ export type WorkerConsignmentRecord = {
brillianceCount: number
askingPriceFen: number
depositAmountFen: number
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
reviewNote: string
screenshotsJson: string[]
giftEvidenceImages?: string[]
@@ -507,6 +521,11 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa
return apiPost<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload)
}
/** 发布寄卖时的「对接客服」下拉(仅启用中的名单)。 */
export function fetchWorkerConsignmentContacts() {
return apiGet<{ items: ConsignmentContactSupporter[] }>('/api/v1/worker/consignment/contacts')
}
export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}) {
return apiGet<WorkerConsignmentListResponse>(
'/api/v1/worker/consignment/my-items',
+108 -76
View File
@@ -7,6 +7,31 @@
*/
export type GiftEvidenceReviewStatus = 'none' | 'pending' | 'accepted' | 'submitted'
/**
* 寄卖「对接客服」名单项。
*
* 独立名单(不是后台账号):发布寄卖时必选,后台用于统计提成与联系。
*/
export type ConsignmentContactSupporter = {
id: number
name: string
phone: string
wechat: string
note: string
enabled: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
/** 提报单 / 现货 / 工单物料上的对接客服快照字段。 */
export type ConsignmentContactFields = {
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
}
/**
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
*
@@ -34,51 +59,52 @@ export type ConsignmentEvidenceFields = {
giftEvidenceReviewStatus?: GiftEvidenceReviewStatus
}
export type ConsignmentInventory = ConsignmentEvidenceFields & {
id: number
inventoryNo: string
gameName: string
categoryId: number | null
categoryName?: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
costPriceFen: number
buyerName: string
shopName: string
inboundBatch: string
sellerContact: string
sellerGameId: string
sellerPaymentInfo: string
sellerPhone: string
credentialsJson: Record<string, unknown>
screenshotsJson: string[]
remark: string
allowMatching: boolean
status: 'in_stock' | 'consumed' | 'archived'
allocationStatus: string
boundWorkOrderId: number | null
boundPlatformOrderId: string
sourceType: 'manual' | 'worker_portal' | 'recycle'
sourceSubmissionId: number | null
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
sourceSubmissionNo?: string
workerId?: number | null
workerName?: string
workerPhone?: string
/** 被指派执行本次履约的打手(卖家之外的第三人);workerId 始终是卖家。 */
assignedWorkerId?: number | null
depositAmountFen?: number
inboundAt: string
consumedAt: string | null
createdAt: string
updatedAt: string
/** 关联履约工单的买家资料(加好友及主页截图) */
buyerInfo?: ConsignmentBuyerInfo | null
}
export type ConsignmentInventory = ConsignmentEvidenceFields &
ConsignmentContactFields & {
id: number
inventoryNo: string
gameName: string
categoryId: number | null
categoryName?: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
costPriceFen: number
buyerName: string
shopName: string
inboundBatch: string
sellerContact: string
sellerGameId: string
sellerPaymentInfo: string
sellerPhone: string
credentialsJson: Record<string, unknown>
screenshotsJson: string[]
remark: string
allowMatching: boolean
status: 'in_stock' | 'consumed' | 'archived'
allocationStatus: string
boundWorkOrderId: number | null
boundPlatformOrderId: string
sourceType: 'manual' | 'worker_portal' | 'recycle'
sourceSubmissionId: number | null
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
sourceSubmissionNo?: string
workerId?: number | null
workerName?: string
workerPhone?: string
/** 被指派执行本次履约的打手(卖家之外的第三人);workerId 始终是卖家。 */
assignedWorkerId?: number | null
depositAmountFen?: number
inboundAt: string
consumedAt: string | null
createdAt: string
updatedAt: string
/** 关联履约工单的买家资料(加好友及主页截图) */
buyerInfo?: ConsignmentBuyerInfo | null
}
export type ConsignmentSellerInfo = {
characterName: string
@@ -112,40 +138,46 @@ export type ConsignmentFulfillmentInfo = {
costPriceFen?: number
fulfillmentType?: string
workerId?: number | null
/** 对接客服(履约时从现货复制到工单物料,用于工单侧联系) */
contactSupporterId?: number | null
contactSupporterName?: string
contactSupporterPhone?: string
contactSupporterWechat?: string
[key: string]: unknown
}
export type ConsignmentSubmission = ConsignmentEvidenceFields & {
id: number
submissionNo: string
gameName: string
categoryId: number | null
categoryName?: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
sellerGameId: string
sellerPaymentInfo: string
sellerPhone: string
contactInfo: string
contentJson: Record<string, unknown>
screenshotsJson: string[]
askingPriceFen: number
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
status: 'pending_review' | 'approved' | 'rejected'
reviewNote: string
reviewerName: string
reviewedAt: string | null
resultInventoryId: number | null
createdAt: string
updatedAt: string
}
export type ConsignmentSubmission = ConsignmentEvidenceFields &
ConsignmentContactFields & {
id: number
submissionNo: string
gameName: string
categoryId: number | null
categoryName?: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
sellerGameId: string
sellerPaymentInfo: string
sellerPhone: string
contactInfo: string
contentJson: Record<string, unknown>
screenshotsJson: string[]
askingPriceFen: number
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
status: 'pending_review' | 'approved' | 'rejected'
reviewNote: string
reviewerName: string
reviewedAt: string | null
resultInventoryId: number | null
createdAt: string
updatedAt: string
}
export type ConsignmentStatsSummary = {
totalInStockCount: number
@@ -0,0 +1,37 @@
/** 寄卖「对接客服」展示工具:后台列表、详情、工单与打手端统一口径。 */
export type ConsignmentContactDisplay = {
/** 姓名;没有对接客服时为空串 */
name: string
/** 联系方式(电话 / 微信),没有时为空串 */
contact: string
/** 姓名 + 联系方式,列表里直接展示 */
text: string
}
type ConsignmentContactLike =
| {
contactSupporterName?: string | null
contactSupporterPhone?: string | null
contactSupporterWechat?: string | null
}
| null
| undefined
export function formatConsignmentContactSupporter(
source: ConsignmentContactLike,
emptyText = '未指定',
): ConsignmentContactDisplay {
const name = String(source?.contactSupporterName || '').trim()
const phone = String(source?.contactSupporterPhone || '').trim()
const wechat = String(source?.contactSupporterWechat || '').trim()
const parts: string[] = []
if (phone) parts.push(`电话 ${phone}`)
if (wechat) parts.push(`微信 ${wechat}`)
const contact = parts.join(' · ')
if (!name) {
return { name: '', contact, text: contact || emptyText }
}
return { name, contact, text: contact ? `${name} · ${contact}` : name }
}
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatConsignmentContactSupporter } from '../src/utils/consignment-contact.ts'
test('对接客服:姓名 + 电话/微信一起展示', () => {
const display = formatConsignmentContactSupporter({
contactSupporterName: '小张',
contactSupporterPhone: '13800001111',
contactSupporterWechat: 'wx_zhang',
})
assert.equal(display.name, '小张')
assert.equal(display.contact, '电话 13800001111 · 微信 wx_zhang')
assert.equal(display.text, '小张 · 电话 13800001111 · 微信 wx_zhang')
})
test('对接客服:只有姓名时不拼联系方式', () => {
const display = formatConsignmentContactSupporter({ contactSupporterName: '小李' })
assert.equal(display.text, '小李')
})
test('对接客服:历史数据没有姓名时回退为未指定', () => {
assert.equal(formatConsignmentContactSupporter(null).text, '未指定')
assert.equal(formatConsignmentContactSupporter({}).text, '未指定')
})
test('对接客服:自定义空值文案', () => {
assert.equal(formatConsignmentContactSupporter(undefined, '-').text, '-')
})