新增打手问题反馈
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
-- 打手问题反馈:关联工单,由客服回复并关闭,不改变工单状态或资金。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_order_feedbacks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||||
|
work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL,
|
||||||
|
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
reply TEXT NOT NULL DEFAULT '',
|
||||||
|
handled_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
handled_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_order_feedbacks_status_created
|
||||||
|
ON worker_order_feedbacks (status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_order_feedbacks_worker_created
|
||||||
|
ON worker_order_feedbacks (worker_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE worker_order_feedbacks IS '打手针对工单提交的客户资料和处理问题反馈';
|
||||||
@@ -87,6 +87,25 @@ export type WorkerCancelRequestRow = {
|
|||||||
share_quantity?: number | null
|
share_quantity?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkerOrderFeedbackRow = {
|
||||||
|
id: number
|
||||||
|
work_order_id: number
|
||||||
|
work_order_share_id: number | null
|
||||||
|
worker_id: number
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
reply: string
|
||||||
|
handled_by: string
|
||||||
|
created_at: string
|
||||||
|
handled_at: string | null
|
||||||
|
updated_at: string
|
||||||
|
work_order_no?: string
|
||||||
|
product_name?: string
|
||||||
|
worker_username?: string
|
||||||
|
worker_display_name?: string
|
||||||
|
share_quantity?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkerDepositUnfreezeRow = {
|
export type WorkerDepositUnfreezeRow = {
|
||||||
id: number
|
id: number
|
||||||
worker_id: number
|
worker_id: number
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
WorkCategoryRow,
|
WorkCategoryRow,
|
||||||
WorkerDepositUnfreezeRow,
|
WorkerDepositUnfreezeRow,
|
||||||
WorkerCancelRequestRow,
|
WorkerCancelRequestRow,
|
||||||
|
WorkerOrderFeedbackRow,
|
||||||
WorkOrderRow,
|
WorkOrderRow,
|
||||||
WorkOrderShareRow,
|
WorkOrderShareRow,
|
||||||
WorkOrderStatistics,
|
WorkOrderStatistics,
|
||||||
@@ -74,6 +75,20 @@ const WORKER_CANCEL_REQUEST_SELECT = `
|
|||||||
LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id
|
LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const WORKER_ORDER_FEEDBACK_SELECT = `
|
||||||
|
SELECT
|
||||||
|
wf.*,
|
||||||
|
COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), wo.work_order_no) AS work_order_no,
|
||||||
|
wo.product_name,
|
||||||
|
wu.username AS worker_username,
|
||||||
|
wu.display_name AS worker_display_name,
|
||||||
|
wos.quantity AS share_quantity
|
||||||
|
FROM worker_order_feedbacks wf
|
||||||
|
INNER JOIN work_orders wo ON wo.id = wf.work_order_id
|
||||||
|
INNER JOIN worker_users wu ON wu.id = wf.worker_id
|
||||||
|
LEFT JOIN work_order_shares wos ON wos.id = wf.work_order_share_id
|
||||||
|
`
|
||||||
|
|
||||||
type WorkerOrderOverviewRow = {
|
type WorkerOrderOverviewRow = {
|
||||||
status: string
|
status: string
|
||||||
order_count: number
|
order_count: number
|
||||||
@@ -1722,6 +1737,142 @@ export async function cancelWorkerWorkOrder(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打手提交工单问题反馈,不改变工单状态、押金或资金流水。 */
|
||||||
|
export async function createWorkerOrderFeedback(input: {
|
||||||
|
workOrderId: number
|
||||||
|
workerId: number
|
||||||
|
content: string
|
||||||
|
now: string
|
||||||
|
}): Promise<{ feedback: WorkerOrderFeedbackRow | null; failureReason: 'owner_required' | null }> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const orderResult = await client.query<WorkOrderRow>(
|
||||||
|
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||||
|
[input.workOrderId],
|
||||||
|
)
|
||||||
|
const order = orderResult.rows[0] || null
|
||||||
|
if (!order) return { feedback: null, failureReason: 'owner_required' }
|
||||||
|
|
||||||
|
const shareResult = await client.query<WorkOrderShareRow>(
|
||||||
|
`SELECT * FROM work_order_shares
|
||||||
|
WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled'
|
||||||
|
FOR UPDATE`,
|
||||||
|
[input.workOrderId, input.workerId],
|
||||||
|
)
|
||||||
|
const share = shareResult.rows[0] || null
|
||||||
|
const isOrderOwner =
|
||||||
|
Number(order.assigned_worker_id || 0) === input.workerId ||
|
||||||
|
Number(order.last_assigned_worker_id || 0) === input.workerId
|
||||||
|
if (!share && !isOrderOwner) return { feedback: null, failureReason: 'owner_required' }
|
||||||
|
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
|
`INSERT INTO worker_order_feedbacks (
|
||||||
|
work_order_id, work_order_share_id, worker_id, content, status, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, 'pending', $5, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
[input.workOrderId, share?.id || null, input.workerId, input.content, input.now],
|
||||||
|
)
|
||||||
|
const feedbackId = Number(result.rows[0]?.id || 0)
|
||||||
|
await createWorkOrderEventWithClient(client, {
|
||||||
|
workOrderId: input.workOrderId,
|
||||||
|
actorType: 'worker',
|
||||||
|
actorId: String(input.workerId),
|
||||||
|
eventType: 'worker_feedback_submitted',
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: order.status,
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
feedbackId,
|
||||||
|
shareId: share?.id || null,
|
||||||
|
content: input.content,
|
||||||
|
}),
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedbackId),
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkerOrderFeedbacks(
|
||||||
|
workerId: number | string,
|
||||||
|
): Promise<WorkerOrderFeedbackRow[]> {
|
||||||
|
const result = await query<WorkerOrderFeedbackRow>(
|
||||||
|
`${WORKER_ORDER_FEEDBACK_SELECT}
|
||||||
|
WHERE wf.worker_id = $1
|
||||||
|
ORDER BY wf.id DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[Number(workerId)],
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkerOrderFeedbacks(
|
||||||
|
status = 'pending',
|
||||||
|
): Promise<WorkerOrderFeedbackRow[]> {
|
||||||
|
const result = await query<WorkerOrderFeedbackRow>(
|
||||||
|
`${WORKER_ORDER_FEEDBACK_SELECT}
|
||||||
|
WHERE ($1 = '' OR wf.status = $1)
|
||||||
|
ORDER BY wf.id DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[status],
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWorkerOrderFeedback(input: {
|
||||||
|
feedbackId: number
|
||||||
|
reply: string
|
||||||
|
actorName: string
|
||||||
|
now: string
|
||||||
|
}): Promise<{
|
||||||
|
feedback: WorkerOrderFeedbackRow | null
|
||||||
|
order: WorkOrderRow | null
|
||||||
|
failureReason: 'not_found' | 'not_pending' | null
|
||||||
|
}> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const feedbackResult = await client.query<WorkerOrderFeedbackRow>(
|
||||||
|
'SELECT * FROM worker_order_feedbacks WHERE id = $1 FOR UPDATE',
|
||||||
|
[input.feedbackId],
|
||||||
|
)
|
||||||
|
const feedback = feedbackResult.rows[0] || null
|
||||||
|
if (!feedback) return { feedback: null, order: null, failureReason: 'not_found' }
|
||||||
|
if (feedback.status !== 'pending') {
|
||||||
|
return {
|
||||||
|
feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedback.id),
|
||||||
|
order: null,
|
||||||
|
failureReason: 'not_pending',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orderResult = await client.query<WorkOrderRow>(
|
||||||
|
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||||
|
[feedback.work_order_id],
|
||||||
|
)
|
||||||
|
const order = orderResult.rows[0] || null
|
||||||
|
if (!order) return { feedback: null, order: null, failureReason: 'not_found' }
|
||||||
|
await client.query(
|
||||||
|
`UPDATE worker_order_feedbacks
|
||||||
|
SET status = 'resolved', reply = $1, handled_by = $2, handled_at = $3, updated_at = $3
|
||||||
|
WHERE id = $4`,
|
||||||
|
[input.reply, input.actorName, input.now, feedback.id],
|
||||||
|
)
|
||||||
|
await createWorkOrderEventWithClient(client, {
|
||||||
|
workOrderId: Number(order.id),
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: input.actorName,
|
||||||
|
eventType: 'worker_feedback_resolved',
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: order.status,
|
||||||
|
payloadJson: JSON.stringify({ feedbackId: feedback.id, reply: input.reply }),
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedback.id),
|
||||||
|
order: await getWorkOrderByIdWithClient(client, order.id),
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */
|
/** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */
|
||||||
export async function createWorkerCancelRequest(input: {
|
export async function createWorkerCancelRequest(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
@@ -3782,6 +3933,17 @@ async function getWorkerCancelRequestByIdWithClient(
|
|||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getWorkerOrderFeedbackByIdWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
feedbackId: number | string,
|
||||||
|
): Promise<WorkerOrderFeedbackRow | null> {
|
||||||
|
const result = await client.query<WorkerOrderFeedbackRow>(
|
||||||
|
`${WORKER_ORDER_FEEDBACK_SELECT} WHERE wf.id = $1 LIMIT 1`,
|
||||||
|
[Number(feedbackId)],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
async function countWorkerAcceptedOrdersWithClient(
|
async function countWorkerAcceptedOrdersWithClient(
|
||||||
client: PoolClient,
|
client: PoolClient,
|
||||||
workerId: number,
|
workerId: number,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
listAdminWorkerUsers,
|
listAdminWorkerUsers,
|
||||||
listAdminWorkOrders,
|
listAdminWorkOrders,
|
||||||
listAdminWorkerCancelRequests,
|
listAdminWorkerCancelRequests,
|
||||||
|
listAdminWorkerOrderFeedbacks,
|
||||||
markAdminWorkOrderProblem,
|
markAdminWorkOrderProblem,
|
||||||
pinAdminWorkOrder,
|
pinAdminWorkOrder,
|
||||||
publishAdminWorkOrder,
|
publishAdminWorkOrder,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
reprocessAdminKuaishouSendCodeWorkOrders,
|
reprocessAdminKuaishouSendCodeWorkOrders,
|
||||||
reviewAdminWorkerFinanceRequest,
|
reviewAdminWorkerFinanceRequest,
|
||||||
reviewAdminWorkerCancelRequest,
|
reviewAdminWorkerCancelRequest,
|
||||||
|
resolveAdminWorkerOrderFeedback,
|
||||||
resolveAdminProblemWorkOrder,
|
resolveAdminProblemWorkOrder,
|
||||||
reviewAdminWorkerUser,
|
reviewAdminWorkerUser,
|
||||||
resetAdminWorkerPassword,
|
resetAdminWorkerPassword,
|
||||||
@@ -495,6 +497,34 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/worker-platform/feedbacks',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler((req) => listAdminWorkerOrderFeedbacks(req.query), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取问题反馈失败',
|
||||||
|
scope: '[admin/worker-platform/feedbacks]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/worker-platform/feedbacks/:feedbackId/resolve',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
resolveAdminWorkerOrderFeedback(
|
||||||
|
String(req.params.feedbackId || ''),
|
||||||
|
req.body || {},
|
||||||
|
req.adminSession?.username || '',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '问题反馈已处理',
|
||||||
|
errorMessage: '处理问题反馈失败',
|
||||||
|
scope: '[admin/worker-platform/feedbacks/:feedbackId/resolve]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/worker-platform/cancel-requests/:requestId/review',
|
'/worker-platform/cancel-requests/:requestId/review',
|
||||||
requireAdminRoles(['admin', 'operator', 'support']),
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ import {
|
|||||||
supplementWorkerAcceptedOrderEvidence,
|
supplementWorkerAcceptedOrderEvidence,
|
||||||
submitWorkerOrderAcceptance,
|
submitWorkerOrderAcceptance,
|
||||||
getWorkerCancelRequestSummary,
|
getWorkerCancelRequestSummary,
|
||||||
|
getWorkerOrderFeedbackSummary,
|
||||||
submitWorkerCancelRequest,
|
submitWorkerCancelRequest,
|
||||||
|
submitWorkerOrderFeedback,
|
||||||
} from '../services/worker-platform/index.js'
|
} from '../services/worker-platform/index.js'
|
||||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||||
import {
|
import {
|
||||||
@@ -452,6 +454,34 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/orders/feedbacks',
|
||||||
|
requireActiveWorker,
|
||||||
|
createRouteHandler((req) => getWorkerOrderFeedbackSummary(getRequiredWorkerSession(req)), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取问题反馈失败',
|
||||||
|
scope: '[worker/orders/feedbacks]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/orders/:workOrderId/feedbacks',
|
||||||
|
requireActiveWorker,
|
||||||
|
createRouteHandler(
|
||||||
|
(req) =>
|
||||||
|
submitWorkerOrderFeedback(
|
||||||
|
String(req.params.workOrderId || ''),
|
||||||
|
req.body || {},
|
||||||
|
getRequiredWorkerSession(req),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '问题反馈已提交,等待客服处理',
|
||||||
|
errorMessage: '提交问题反馈失败',
|
||||||
|
scope: '[worker/orders/:workOrderId/feedbacks]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/orders/:workOrderId/cancel-requests',
|
'/orders/:workOrderId/cancel-requests',
|
||||||
requireActiveWorker,
|
requireActiveWorker,
|
||||||
|
|||||||
@@ -122,6 +122,32 @@ export async function createWorkerCancelRequestAdminNotification(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createWorkerOrderFeedbackAdminNotification(input: {
|
||||||
|
feedbackId: number
|
||||||
|
workOrderId: number
|
||||||
|
workOrderNo: string
|
||||||
|
productName: string
|
||||||
|
workerName: string
|
||||||
|
content: string
|
||||||
|
}) {
|
||||||
|
return createConfiguredWorkerPlatformNotification('feedback_submitted', {
|
||||||
|
notificationType: 'worker_order_feedback_submitted',
|
||||||
|
priority: 'high',
|
||||||
|
entityType: 'worker_order_feedback',
|
||||||
|
entityId: input.feedbackId,
|
||||||
|
dedupeKey: `worker_order_feedback:${input.feedbackId}`,
|
||||||
|
title: '有打手问题反馈待处理',
|
||||||
|
body: [
|
||||||
|
`订单:${input.workOrderNo || input.workOrderId}`,
|
||||||
|
`商品:${input.productName || '-'}`,
|
||||||
|
`打手:${input.workerName || '-'}`,
|
||||||
|
`反馈:${input.content || '-'}`,
|
||||||
|
].join(';'),
|
||||||
|
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=feedbacks&feedbackId=${input.feedbackId}`,
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function remindWorkerAcceptanceAdminNotification(workOrderId: number) {
|
export async function remindWorkerAcceptanceAdminNotification(workOrderId: number) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const eventConfig = getWorkerPlatformNotificationConfig().events.acceptance_reminded
|
const eventConfig = getWorkerPlatformNotificationConfig().events.acceptance_reminded
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import {
|
|||||||
reviewWorkerFinanceRequest,
|
reviewWorkerFinanceRequest,
|
||||||
reopenCancelledWorkOrder,
|
reopenCancelledWorkOrder,
|
||||||
listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin,
|
listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin,
|
||||||
|
listAdminWorkerOrderFeedbacks as listWorkerOrderFeedbacksForAdmin,
|
||||||
|
resolveWorkerOrderFeedback,
|
||||||
reviewWorkerCancelRequest,
|
reviewWorkerCancelRequest,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
updateWorkOrderBasic,
|
updateWorkOrderBasic,
|
||||||
@@ -111,6 +113,7 @@ import {
|
|||||||
mapWorkerLevel,
|
mapWorkerLevel,
|
||||||
mapWorkerUser,
|
mapWorkerUser,
|
||||||
mapWorkerCancelRequest,
|
mapWorkerCancelRequest,
|
||||||
|
mapWorkerOrderFeedback,
|
||||||
normalizeAdminFinanceReviewStatus,
|
normalizeAdminFinanceReviewStatus,
|
||||||
normalizeAmountFen,
|
normalizeAmountFen,
|
||||||
normalizeBoolean,
|
normalizeBoolean,
|
||||||
@@ -1366,6 +1369,66 @@ export async function listAdminWorkerCancelRequests(query: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminWorkerOrderFeedbacks(query: JsonObject = {}) {
|
||||||
|
const status = String(query.status || 'pending').trim()
|
||||||
|
const normalizedStatus = ['pending', 'resolved'].includes(status) ? status : 'pending'
|
||||||
|
return {
|
||||||
|
items: (await listWorkerOrderFeedbacksForAdmin(normalizedStatus)).map(mapWorkerOrderFeedback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAdminWorkerOrderFeedback(
|
||||||
|
feedbackId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
actorName = '',
|
||||||
|
) {
|
||||||
|
const reply = String(payload.reply || '').trim()
|
||||||
|
if (!reply) {
|
||||||
|
throw createHttpError('请填写处理回复', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_order_feedback_reply_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const result = await resolveWorkerOrderFeedback({
|
||||||
|
feedbackId: Number(feedbackId),
|
||||||
|
reply,
|
||||||
|
actorName,
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
if (result.failureReason === 'not_found') {
|
||||||
|
throw createHttpError('问题反馈不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'worker_order_feedback_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'not_pending') {
|
||||||
|
throw createHttpError('该问题反馈已处理,请刷新后重试', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_order_feedback_not_pending',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!result.feedback || !result.order) {
|
||||||
|
throw createHttpError('问题反馈处理失败', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_order_feedback_resolve_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await resolveAdminNotificationEntity(
|
||||||
|
'worker_order_feedback',
|
||||||
|
Number(result.feedback.id),
|
||||||
|
'resolved',
|
||||||
|
)
|
||||||
|
const workerId = Number(result.feedback.worker_id)
|
||||||
|
publishWorkOrderRealtimeChange({
|
||||||
|
workOrderId: Number(result.order.id),
|
||||||
|
workerIds: workerId > 0 ? [workerId] : [],
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
feedback: mapWorkerOrderFeedback(result.feedback),
|
||||||
|
order: mapWorkOrderAdmin(result.order),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function reviewAdminWorkerCancelRequest(
|
export async function reviewAdminWorkerCancelRequest(
|
||||||
requestId: number | string,
|
requestId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
|
|||||||
@@ -292,6 +292,41 @@ export function mapWorkerCancelRequest(request: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapWorkerOrderFeedback(feedback: {
|
||||||
|
id: number
|
||||||
|
work_order_id: number
|
||||||
|
work_order_share_id: number | null
|
||||||
|
worker_id: number
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
reply: string
|
||||||
|
handled_by: string
|
||||||
|
created_at: string
|
||||||
|
handled_at: string | null
|
||||||
|
work_order_no?: string
|
||||||
|
product_name?: string
|
||||||
|
worker_username?: string
|
||||||
|
worker_display_name?: string
|
||||||
|
share_quantity?: number | null
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
feedbackId: Number(feedback.id),
|
||||||
|
workOrderId: Number(feedback.work_order_id),
|
||||||
|
shareId: feedback.work_order_share_id ? Number(feedback.work_order_share_id) : null,
|
||||||
|
workerId: Number(feedback.worker_id),
|
||||||
|
workOrderNo: String(feedback.work_order_no || ''),
|
||||||
|
productName: String(feedback.product_name || ''),
|
||||||
|
workerName: String(feedback.worker_display_name || feedback.worker_username || ''),
|
||||||
|
shareQuantity: feedback.share_quantity ? Number(feedback.share_quantity) : null,
|
||||||
|
content: String(feedback.content || ''),
|
||||||
|
status: String(feedback.status || ''),
|
||||||
|
reply: String(feedback.reply || ''),
|
||||||
|
handledBy: String(feedback.handled_by || ''),
|
||||||
|
createdAt: feedback.created_at,
|
||||||
|
handledAt: feedback.handled_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) {
|
export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) {
|
||||||
if (!ledger) return null
|
if (!ledger) return null
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
@@ -9,6 +9,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [
|
|||||||
'acceptance_reminded',
|
'acceptance_reminded',
|
||||||
'material_required',
|
'material_required',
|
||||||
'cancel_requested',
|
'cancel_requested',
|
||||||
|
'feedback_submitted',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type WorkerPlatformNotificationEventKey =
|
export type WorkerPlatformNotificationEventKey =
|
||||||
@@ -53,6 +54,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN
|
|||||||
externalPushEnabled: false,
|
externalPushEnabled: false,
|
||||||
},
|
},
|
||||||
cancel_requested: createEnabledEventConfig(),
|
cancel_requested: createEnabledEventConfig(),
|
||||||
|
feedback_submitted: createEnabledEventConfig(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
acceptWorkOrderShareAndSettle,
|
acceptWorkOrderShareAndSettle,
|
||||||
countWorkerAcceptedOrders,
|
countWorkerAcceptedOrders,
|
||||||
createWorkerCancelRequest,
|
createWorkerCancelRequest,
|
||||||
|
createWorkerOrderFeedback,
|
||||||
countWorkerActiveOrders,
|
countWorkerActiveOrders,
|
||||||
countWorkerTimeoutEvents,
|
countWorkerTimeoutEvents,
|
||||||
countWorkerWithdrawRequestsOnDay,
|
countWorkerWithdrawRequestsOnDay,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
listWorkerFinanceRequests,
|
listWorkerFinanceRequests,
|
||||||
listWorkerCompletionLeaderboard,
|
listWorkerCompletionLeaderboard,
|
||||||
listWorkerCancelRequests,
|
listWorkerCancelRequests,
|
||||||
|
listWorkerOrderFeedbacks,
|
||||||
listWorkerSharesByWorker,
|
listWorkerSharesByWorker,
|
||||||
listWorkerWalletLedgers,
|
listWorkerWalletLedgers,
|
||||||
listWorkerWorkOrderNotes,
|
listWorkerWorkOrderNotes,
|
||||||
@@ -86,6 +88,7 @@ import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js
|
|||||||
import {
|
import {
|
||||||
createWorkerAcceptanceAdminNotification,
|
createWorkerAcceptanceAdminNotification,
|
||||||
createWorkerCancelRequestAdminNotification,
|
createWorkerCancelRequestAdminNotification,
|
||||||
|
createWorkerOrderFeedbackAdminNotification,
|
||||||
createWorkerRechargeAdminNotification,
|
createWorkerRechargeAdminNotification,
|
||||||
createWorkerWithdrawAdminNotification,
|
createWorkerWithdrawAdminNotification,
|
||||||
getWorkerAcceptanceReminder,
|
getWorkerAcceptanceReminder,
|
||||||
@@ -116,6 +119,7 @@ import {
|
|||||||
mapWorkOrderEvents,
|
mapWorkOrderEvents,
|
||||||
mapWorkOrderShare,
|
mapWorkOrderShare,
|
||||||
mapWorkerCancelRequest,
|
mapWorkerCancelRequest,
|
||||||
|
mapWorkerOrderFeedback,
|
||||||
mapWorkerUser,
|
mapWorkerUser,
|
||||||
normalizeAmountFen,
|
normalizeAmountFen,
|
||||||
assertWithdrawChannelAllowedForAmount,
|
assertWithdrawChannelAllowedForAmount,
|
||||||
@@ -1752,6 +1756,58 @@ export async function getWorkerCancelRequestSummary(session: WorkerSession) {
|
|||||||
return { risk, items: requests.map(mapWorkerCancelRequest) }
|
return { risk, items: requests.map(mapWorkerCancelRequest) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorkerOrderFeedbackSummary(session: WorkerSession) {
|
||||||
|
requireActiveWorkerSession(session)
|
||||||
|
return { items: (await listWorkerOrderFeedbacks(session.workerId)).map(mapWorkerOrderFeedback) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitWorkerOrderFeedback(
|
||||||
|
workOrderId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
session: WorkerSession,
|
||||||
|
) {
|
||||||
|
requireActiveWorkerSession(session)
|
||||||
|
const content = String(payload.content || '').trim()
|
||||||
|
if (!content) {
|
||||||
|
throw createHttpError('请填写问题反馈内容', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_order_feedback_content_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (content.length > 500) {
|
||||||
|
throw createHttpError('问题反馈不能超过 500 个字符', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_order_feedback_content_too_long',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const result = await createWorkerOrderFeedback({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
workerId: Number(session.workerId),
|
||||||
|
content,
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
if (!result.feedback) {
|
||||||
|
throw createHttpError('只能反馈自己的订单问题', {
|
||||||
|
statusCode: 403,
|
||||||
|
errorCode: 'worker_order_feedback_owner_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const worker = await getRequiredWorker(session.workerId)
|
||||||
|
await createWorkerOrderFeedbackAdminNotification({
|
||||||
|
feedbackId: Number(result.feedback.id),
|
||||||
|
workOrderId: Number(result.feedback.work_order_id),
|
||||||
|
workOrderNo: result.feedback.work_order_no || '',
|
||||||
|
productName: result.feedback.product_name || '',
|
||||||
|
workerName: worker.display_name || worker.username,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
publishWorkOrderRealtimeChange({
|
||||||
|
workOrderId: Number(result.feedback.work_order_id),
|
||||||
|
workerIds: [Number(session.workerId)],
|
||||||
|
})
|
||||||
|
return { feedback: mapWorkerOrderFeedback(result.feedback) }
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitWorkerCancelRequest(
|
export async function submitWorkerCancelRequest(
|
||||||
workOrderId: number | string,
|
workOrderId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
|||||||
title: note ? `客服同意撤单申请:${note}` : '客服同意撤单申请,订单已回到大厅',
|
title: note ? `客服同意撤单申请:${note}` : '客服同意撤单申请,订单已回到大厅',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
}
|
}
|
||||||
|
case 'worker_feedback_submitted':
|
||||||
|
return {
|
||||||
|
title: note ? `打手提交问题反馈:${note}` : '打手提交问题反馈,等待客服处理',
|
||||||
|
color: 'orange',
|
||||||
|
}
|
||||||
|
case 'worker_feedback_resolved':
|
||||||
|
return {
|
||||||
|
title: note ? `客服已处理问题反馈:${note}` : '客服已处理问题反馈',
|
||||||
|
color: 'green',
|
||||||
|
}
|
||||||
case 'timeout_reopen':
|
case 'timeout_reopen':
|
||||||
return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' }
|
return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getAdminRole } from '@/utils/admin-auth'
|
|||||||
import CategoriesPanel from './panels/CategoriesPanel'
|
import CategoriesPanel from './panels/CategoriesPanel'
|
||||||
import CancelRequestsPanel from './panels/CancelRequestsPanel'
|
import CancelRequestsPanel from './panels/CancelRequestsPanel'
|
||||||
import FinancePanel from './panels/FinancePanel'
|
import FinancePanel from './panels/FinancePanel'
|
||||||
|
import FeedbacksPanel from './panels/FeedbacksPanel'
|
||||||
import LevelsPanel from './panels/LevelsPanel'
|
import LevelsPanel from './panels/LevelsPanel'
|
||||||
import NotificationsPanel from './panels/NotificationsPanel'
|
import NotificationsPanel from './panels/NotificationsPanel'
|
||||||
import ProductRulesPanel from './panels/ProductRulesPanel'
|
import ProductRulesPanel from './panels/ProductRulesPanel'
|
||||||
@@ -49,10 +50,12 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
? [
|
? [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{ key: 'cancel-requests', label: '撤单审核', children: <CancelRequestsPanel /> },
|
{ key: 'cancel-requests', label: '撤单审核', children: <CancelRequestsPanel /> },
|
||||||
|
{ key: 'feedbacks', label: '问题反馈', children: <FeedbacksPanel /> },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{ key: 'cancel-requests', label: '撤单审核', children: <CancelRequestsPanel /> },
|
{ key: 'cancel-requests', label: '撤单审核', children: <CancelRequestsPanel /> },
|
||||||
|
{ key: 'feedbacks', label: '问题反馈', children: <FeedbacksPanel /> },
|
||||||
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||||
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
|
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
|
||||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||||
@@ -75,6 +78,7 @@ function loadActiveTabPreference(): string {
|
|||||||
const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||||
return saved === 'orders' ||
|
return saved === 'orders' ||
|
||||||
saved === 'cancel-requests' ||
|
saved === 'cancel-requests' ||
|
||||||
|
saved === 'feedbacks' ||
|
||||||
saved === 'rules' ||
|
saved === 'rules' ||
|
||||||
saved === 'product-match' ||
|
saved === 'product-match' ||
|
||||||
saved === 'categories' ||
|
saved === 'categories' ||
|
||||||
@@ -94,6 +98,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string {
|
|||||||
return [
|
return [
|
||||||
'orders',
|
'orders',
|
||||||
'cancel-requests',
|
'cancel-requests',
|
||||||
|
'feedbacks',
|
||||||
'rules',
|
'rules',
|
||||||
'product-match',
|
'product-match',
|
||||||
'categories',
|
'categories',
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { App, Button, Card, Form, Input, Modal, Space, Table, Tag, Typography } from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router'
|
||||||
|
|
||||||
|
import { fetchAdminWorkerOrderFeedbacks, resolveAdminWorkerOrderFeedback } from '@/services/admin'
|
||||||
|
import type { WorkerOrderFeedback } from '@/types/worker-platform'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
type FeedbackReplyFormValues = {
|
||||||
|
reply?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbacksPanel() {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [target, setTarget] = useState<WorkerOrderFeedback | null>(null)
|
||||||
|
const [form] = Form.useForm<FeedbackReplyFormValues>()
|
||||||
|
const feedbacksQuery = useQuery({
|
||||||
|
queryKey: ['admin-worker-order-feedbacks', 'pending'],
|
||||||
|
queryFn: () => fetchAdminWorkerOrderFeedbacks('pending'),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const feedbackId = Number(searchParams.get('feedbackId') || 0)
|
||||||
|
if (!Number.isInteger(feedbackId) || feedbackId <= 0) return
|
||||||
|
const feedback = (feedbacksQuery.data?.data.items || []).find(
|
||||||
|
(item) => item.feedbackId === feedbackId,
|
||||||
|
)
|
||||||
|
if (!feedback) return
|
||||||
|
setTarget(feedback)
|
||||||
|
form.resetFields()
|
||||||
|
setSearchParams(
|
||||||
|
(current) => {
|
||||||
|
current.delete('feedbackId')
|
||||||
|
return current
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}, [feedbacksQuery.data, form, searchParams, setSearchParams])
|
||||||
|
|
||||||
|
async function submitReply(values: FeedbackReplyFormValues) {
|
||||||
|
if (!target) return
|
||||||
|
try {
|
||||||
|
await resolveAdminWorkerOrderFeedback(target.feedbackId, {
|
||||||
|
reply: String(values.reply || '').trim(),
|
||||||
|
})
|
||||||
|
message.success('问题反馈已处理')
|
||||||
|
setTarget(null)
|
||||||
|
form.resetFields()
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-worker-order-feedbacks'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '处理问题反馈失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkerOrderFeedback> = [
|
||||||
|
{
|
||||||
|
title: '订单',
|
||||||
|
width: 260,
|
||||||
|
render: (_, feedback) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong>{feedback.productName || '-'}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">{feedback.workOrderNo || '-'}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '打手',
|
||||||
|
width: 180,
|
||||||
|
render: (_, feedback) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Typography.Text>{feedback.workerName || `打手 #${feedback.workerId}`}</Typography.Text>
|
||||||
|
{feedback.shareQuantity ? (
|
||||||
|
<Tag color="purple">拼单 {feedback.shareQuantity} 份</Tag>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '反馈内容',
|
||||||
|
render: (_, feedback) => (
|
||||||
|
<Typography.Text ellipsis={{ tooltip: feedback.content }}>
|
||||||
|
{feedback.content}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '反馈时间',
|
||||||
|
width: 180,
|
||||||
|
render: (_, feedback) => formatAdminDateTime(feedback.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
render: (_, feedback) => (
|
||||||
|
<Button type="primary" size="small" onClick={() => setTarget(feedback)}>
|
||||||
|
处理
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={`待处理问题反馈(${feedbacksQuery.data?.data.items.length || 0})`}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={feedbacksQuery.isFetching}
|
||||||
|
onClick={() => feedbacksQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Table<WorkerOrderFeedback>
|
||||||
|
rowKey="feedbackId"
|
||||||
|
size="small"
|
||||||
|
loading={feedbacksQuery.isLoading}
|
||||||
|
dataSource={feedbacksQuery.data?.data.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: '暂无待处理问题反馈' }}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Modal
|
||||||
|
title="处理问题反馈"
|
||||||
|
open={Boolean(target)}
|
||||||
|
onCancel={() => {
|
||||||
|
setTarget(null)
|
||||||
|
form.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
okText="完成处理"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{target ? (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Typography.Text>
|
||||||
|
{target.workerName || `打手 #${target.workerId}`}:{target.content}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
完成处理不会更改订单状态、验收或资金,仅向打手记录处理回复。
|
||||||
|
</Typography.Text>
|
||||||
|
<Form form={form} layout="vertical" onFinish={submitReply}>
|
||||||
|
<Form.Item
|
||||||
|
label="处理回复"
|
||||||
|
name="reply"
|
||||||
|
rules={[{ required: true, whitespace: true, message: '请填写处理回复' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
placeholder="请说明已如何处理,或请打手后续操作"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const notificationEventRows: NotificationEventRow[] = [
|
|||||||
{ key: 'acceptance_reminded', name: '催验收', description: '打手在验收后催促处理' },
|
{ key: 'acceptance_reminded', name: '催验收', description: '打手在验收后催促处理' },
|
||||||
{ key: 'material_required', name: '待补资料工单', description: '新工单等待后台补充资料' },
|
{ key: 'material_required', name: '待补资料工单', description: '新工单等待后台补充资料' },
|
||||||
{ key: 'cancel_requested', name: '撤单申请', description: '打手提交新的撤单申请,等待客服审核' },
|
{ key: 'cancel_requested', name: '撤单申请', description: '打手提交新的撤单申请,等待客服审核' },
|
||||||
|
{ key: 'feedback_submitted', name: '问题反馈', description: '打手反馈客户资料或订单处理问题' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function NotificationsPanel() {
|
export default function NotificationsPanel() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
MessageOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -35,6 +36,7 @@ import ImagePreviewList from '@/components/files/ImagePreviewList'
|
|||||||
import ImageUpload from '@/components/files/ImageUpload'
|
import ImageUpload from '@/components/files/ImageUpload'
|
||||||
import {
|
import {
|
||||||
fetchWorkerCancelRequests,
|
fetchWorkerCancelRequests,
|
||||||
|
fetchWorkerOrderFeedbacks,
|
||||||
fetchWorkerMyOrderOverview,
|
fetchWorkerMyOrderOverview,
|
||||||
fetchWorkerMyOrders,
|
fetchWorkerMyOrders,
|
||||||
fetchWorkerProfile,
|
fetchWorkerProfile,
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
supplementWorkerAcceptedOrderEvidence,
|
supplementWorkerAcceptedOrderEvidence,
|
||||||
submitWorkerAcceptance,
|
submitWorkerAcceptance,
|
||||||
submitWorkerCancelRequest,
|
submitWorkerCancelRequest,
|
||||||
|
submitWorkerOrderFeedback,
|
||||||
} from '@/services/worker'
|
} from '@/services/worker'
|
||||||
import type {
|
import type {
|
||||||
CollectField,
|
CollectField,
|
||||||
@@ -79,6 +82,10 @@ type CancelRequestFormValues = {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FeedbackFormValues = {
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorkerOrderQuery(status: string, keyword: string, page: number, pageSize: number) {
|
function buildWorkerOrderQuery(status: string, keyword: string, page: number, pageSize: number) {
|
||||||
return {
|
return {
|
||||||
keyword,
|
keyword,
|
||||||
@@ -201,10 +208,12 @@ export default function WorkerOrdersPage() {
|
|||||||
const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null)
|
const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null)
|
||||||
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
|
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
|
||||||
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
||||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||||
const [form] = Form.useForm<AcceptanceFormValues>()
|
const [form] = Form.useForm<AcceptanceFormValues>()
|
||||||
const [noteForm] = Form.useForm<WorkerNoteFormValues>()
|
const [noteForm] = Form.useForm<WorkerNoteFormValues>()
|
||||||
const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>()
|
const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>()
|
||||||
|
const [feedbackForm] = Form.useForm<FeedbackFormValues>()
|
||||||
|
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
|
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
|
||||||
@@ -234,6 +243,11 @@ export default function WorkerOrdersPage() {
|
|||||||
queryFn: fetchWorkerCancelRequests,
|
queryFn: fetchWorkerCancelRequests,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
const feedbacksQuery = useQuery({
|
||||||
|
queryKey: ['worker-order-feedbacks'],
|
||||||
|
queryFn: fetchWorkerOrderFeedbacks,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
const overviewByStatus = new Map(
|
const overviewByStatus = new Map(
|
||||||
(overviewQuery.data?.data.items || []).map((item) => [item.status, item]),
|
(overviewQuery.data?.data.items || []).map((item) => [item.status, item]),
|
||||||
)
|
)
|
||||||
@@ -243,6 +257,7 @@ export default function WorkerOrdersPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }),
|
queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }),
|
queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['worker-cancel-requests'] }),
|
queryClient.invalidateQueries({ queryKey: ['worker-cancel-requests'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worker-order-feedbacks'] }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +364,27 @@ export default function WorkerOrdersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFeedbackModal(order: WorkOrder) {
|
||||||
|
markOrderAsRead(order)
|
||||||
|
setFeedbackOrder(order)
|
||||||
|
feedbackForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFeedback(values: FeedbackFormValues) {
|
||||||
|
if (!feedbackOrder) return
|
||||||
|
try {
|
||||||
|
await submitWorkerOrderFeedback(feedbackOrder.workOrderId, {
|
||||||
|
content: String(values.content || '').trim(),
|
||||||
|
})
|
||||||
|
message.success('问题反馈已提交,等待客服处理')
|
||||||
|
setFeedbackOrder(null)
|
||||||
|
feedbackForm.resetFields()
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '提交问题反馈失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveOrderNote(values: WorkerNoteFormValues) {
|
async function saveOrderNote(values: WorkerNoteFormValues) {
|
||||||
if (!noteOrder) return
|
if (!noteOrder) return
|
||||||
try {
|
try {
|
||||||
@@ -684,6 +720,13 @@ export default function WorkerOrdersPage() {
|
|||||||
申请取消
|
申请取消
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canSubmitFeedback(row) ? (
|
||||||
|
<Tooltip title="反馈客户资料或订单处理问题">
|
||||||
|
<Button icon={<MessageOutlined />} onClick={() => openFeedbackModal(row)}>
|
||||||
|
反馈
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -995,6 +1038,15 @@ export default function WorkerOrdersPage() {
|
|||||||
申请取消
|
申请取消
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canSubmitFeedback(order) ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<MessageOutlined />}
|
||||||
|
onClick={() => openFeedbackModal(order)}
|
||||||
|
>
|
||||||
|
反馈
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -1163,6 +1215,11 @@ export default function WorkerOrdersPage() {
|
|||||||
申请取消
|
申请取消
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canSubmitFeedback(detailOrder) ? (
|
||||||
|
<Button icon={<MessageOutlined />} onClick={() => openFeedbackModal(detailOrder)}>
|
||||||
|
问题反馈
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -1279,6 +1336,52 @@ export default function WorkerOrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
<Modal
|
||||||
|
title={`问题反馈 · ${getDisplayOrderNo(feedbackOrder)}`}
|
||||||
|
open={Boolean(feedbackOrder)}
|
||||||
|
width={isMobile ? '92%' : 540}
|
||||||
|
onCancel={() => {
|
||||||
|
setFeedbackOrder(null)
|
||||||
|
feedbackForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => feedbackForm.submit()}
|
||||||
|
okText="提交反馈"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
可反馈客户资料错误、账号异常或无法继续处理等问题。客服处理后会在反馈记录中回复。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form form={feedbackForm} layout="vertical" onFinish={submitFeedback}>
|
||||||
|
<Form.Item
|
||||||
|
label="问题说明"
|
||||||
|
name="content"
|
||||||
|
rules={[{ required: true, whitespace: true, message: '请填写问题说明' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={5}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
placeholder="例如:客户提供的游戏账号密码错误,无法登录"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
{(feedbacksQuery.data?.data.items || []).filter(
|
||||||
|
(item) => item.workOrderId === feedbackOrder?.workOrderId,
|
||||||
|
).length > 0 ? (
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Typography.Text type="secondary">该订单最近反馈</Typography.Text>
|
||||||
|
{(feedbacksQuery.data?.data.items || [])
|
||||||
|
.filter((item) => item.workOrderId === feedbackOrder?.workOrderId)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => (
|
||||||
|
<Typography.Text key={item.feedbackId} type="secondary">
|
||||||
|
{item.status === 'resolved' ? '已处理' : '待处理'}:{item.content}
|
||||||
|
{item.reply ? `;客服回复:${item.reply}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
title={`申请取消 · ${getDisplayOrderNo(cancelRequestOrder)}`}
|
title={`申请取消 · ${getDisplayOrderNo(cancelRequestOrder)}`}
|
||||||
open={Boolean(cancelRequestOrder)}
|
open={Boolean(cancelRequestOrder)}
|
||||||
@@ -1482,6 +1585,11 @@ function canRequestCancel(order: WorkOrder) {
|
|||||||
return order.status === 'in_progress'
|
return order.status === 'in_progress'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canSubmitFeedback(order: WorkOrder) {
|
||||||
|
if (order.myShare) return order.myShare.status !== 'cancelled'
|
||||||
|
return Boolean(order.assignedAt || order.worker)
|
||||||
|
}
|
||||||
|
|
||||||
function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) {
|
function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) {
|
||||||
if (!workerCanAutoAcceptWithoutEvidence) return false
|
if (!workerCanAutoAcceptWithoutEvidence) return false
|
||||||
if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted'
|
if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted'
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
WorkerFinanceConfig,
|
WorkerFinanceConfig,
|
||||||
WorkerFinanceRequest,
|
WorkerFinanceRequest,
|
||||||
WorkerCancelRequest,
|
WorkerCancelRequest,
|
||||||
|
WorkerOrderFeedback,
|
||||||
WorkerPlatformNotificationConfig,
|
WorkerPlatformNotificationConfig,
|
||||||
WorkerProductMatchConfig,
|
WorkerProductMatchConfig,
|
||||||
WorkerLevel,
|
WorkerLevel,
|
||||||
@@ -62,6 +63,19 @@ export function reviewAdminWorkerCancelRequest(
|
|||||||
}>(`/api/v1/admin/worker-platform/cancel-requests/${requestId}/review`, payload)
|
}>(`/api/v1/admin/worker-platform/cancel-requests/${requestId}/review`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWorkerOrderFeedbacks(status: 'pending' | 'resolved' = 'pending') {
|
||||||
|
return apiGet<{ items: WorkerOrderFeedback[] }>('/api/v1/admin/worker-platform/feedbacks', {
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAdminWorkerOrderFeedback(feedbackId: number, payload: { reply: string }) {
|
||||||
|
return apiPost<{ feedback: WorkerOrderFeedback; order: WorkOrder }>(
|
||||||
|
`/api/v1/admin/worker-platform/feedbacks/${feedbackId}/resolve`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminWorkerLevels() {
|
export function fetchAdminWorkerLevels() {
|
||||||
return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels')
|
return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
WorkerFinanceRequest,
|
WorkerFinanceRequest,
|
||||||
WorkerCancelRequest,
|
WorkerCancelRequest,
|
||||||
WorkerCancelRequestRisk,
|
WorkerCancelRequestRisk,
|
||||||
|
WorkerOrderFeedback,
|
||||||
WorkerHallOrdersResponse,
|
WorkerHallOrdersResponse,
|
||||||
WorkerLeaderboardResponse,
|
WorkerLeaderboardResponse,
|
||||||
WorkerLeaderboardPeriod,
|
WorkerLeaderboardPeriod,
|
||||||
@@ -226,6 +227,17 @@ export function submitWorkerCancelRequest(workOrderId: number, payload: { reason
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchWorkerOrderFeedbacks() {
|
||||||
|
return apiGet<{ items: WorkerOrderFeedback[] }>('/api/v1/worker/orders/feedbacks')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitWorkerOrderFeedback(workOrderId: number, payload: { content: string }) {
|
||||||
|
return apiPost<{ feedback: WorkerOrderFeedback }>(
|
||||||
|
`/api/v1/worker/orders/${workOrderId}/feedbacks`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function markWorkerOrderRead(workOrderId: number) {
|
export function markWorkerOrderRead(workOrderId: number) {
|
||||||
return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`)
|
return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,23 @@ export type WorkerCancelRequestRisk = {
|
|||||||
remainingToPenalty: number
|
remainingToPenalty: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkerOrderFeedback = {
|
||||||
|
feedbackId: number
|
||||||
|
workOrderId: number
|
||||||
|
shareId: number | null
|
||||||
|
workerId: number
|
||||||
|
workOrderNo: string
|
||||||
|
productName: string
|
||||||
|
workerName: string
|
||||||
|
shareQuantity: number | null
|
||||||
|
content: string
|
||||||
|
status: 'pending' | 'resolved' | string
|
||||||
|
reply: string
|
||||||
|
handledBy: string
|
||||||
|
createdAt: string
|
||||||
|
handledAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkerFinanceConfig = {
|
export type WorkerFinanceConfig = {
|
||||||
depositUnfreezeDays: number
|
depositUnfreezeDays: number
|
||||||
recharge: {
|
recharge: {
|
||||||
@@ -151,6 +168,7 @@ export type WorkerPlatformNotificationEventKey =
|
|||||||
| 'acceptance_reminded'
|
| 'acceptance_reminded'
|
||||||
| 'material_required'
|
| 'material_required'
|
||||||
| 'cancel_requested'
|
| 'cancel_requested'
|
||||||
|
| 'feedback_submitted'
|
||||||
|
|
||||||
export type WorkerPlatformNotificationConfig = {
|
export type WorkerPlatformNotificationConfig = {
|
||||||
events: Record<
|
events: Record<
|
||||||
|
|||||||
Reference in New Issue
Block a user