From 20b1066ba331623e741c3755f0b335b96ed353aa Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 20 Aug 2026 19:56:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=93=E6=89=8B=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/044_worker_order_feedback.sql | 23 +++ .../src/repositories/worker-platform/types.ts | 19 ++ .../worker-platform/work-order-repo.ts | 162 ++++++++++++++++ .../src/routes/admin/worker-platform.ts | 30 +++ apps/backend/src/routes/worker.ts | 30 +++ .../admin/admin-notification-service.ts | 26 +++ .../services/worker-platform/admin-service.ts | 63 +++++++ .../src/services/worker-platform/mappers.ts | 35 ++++ ...er-platform-notification-config-service.ts | 2 + .../worker-platform/worker-service.ts | 56 ++++++ .../src/components/WorkOrderEventTimeline.tsx | 10 + .../pages/admin/AdminWorkerPlatformPage.tsx | 5 + .../src/pages/admin/panels/FeedbacksPanel.tsx | 174 ++++++++++++++++++ .../pages/admin/panels/NotificationsPanel.tsx | 1 + .../src/pages/worker/WorkerOrdersPage.tsx | 108 +++++++++++ .../src/services/admin/worker-platform.ts | 14 ++ apps/frontend/src/services/worker.ts | 12 ++ apps/frontend/src/types/worker-platform.ts | 18 ++ 18 files changed, 788 insertions(+) create mode 100644 apps/backend/src/db/migrations/044_worker_order_feedback.sql create mode 100644 apps/frontend/src/pages/admin/panels/FeedbacksPanel.tsx diff --git a/apps/backend/src/db/migrations/044_worker_order_feedback.sql b/apps/backend/src/db/migrations/044_worker_order_feedback.sql new file mode 100644 index 00000000..8c02a6f1 --- /dev/null +++ b/apps/backend/src/db/migrations/044_worker_order_feedback.sql @@ -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 '打手针对工单提交的客户资料和处理问题反馈'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 8e805129..87be8de3 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -87,6 +87,25 @@ export type WorkerCancelRequestRow = { 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 = { id: number worker_id: number diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 3c3f8e9d..db7ce8be 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -14,6 +14,7 @@ import type { WorkCategoryRow, WorkerDepositUnfreezeRow, WorkerCancelRequestRow, + WorkerOrderFeedbackRow, WorkOrderRow, WorkOrderShareRow, WorkOrderStatistics, @@ -74,6 +75,20 @@ const WORKER_CANCEL_REQUEST_SELECT = ` 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 = { status: string 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( + '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( + `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 { + const result = await query( + `${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 { + const result = await query( + `${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( + '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( + '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: { workOrderId: number @@ -3782,6 +3933,17 @@ async function getWorkerCancelRequestByIdWithClient( return result.rows[0] || null } +async function getWorkerOrderFeedbackByIdWithClient( + client: PoolClient, + feedbackId: number | string, +): Promise { + const result = await client.query( + `${WORKER_ORDER_FEEDBACK_SELECT} WHERE wf.id = $1 LIMIT 1`, + [Number(feedbackId)], + ) + return result.rows[0] || null +} + async function countWorkerAcceptedOrdersWithClient( client: PoolClient, workerId: number, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index a6a34f09..968e2463 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -32,6 +32,7 @@ import { listAdminWorkerUsers, listAdminWorkOrders, listAdminWorkerCancelRequests, + listAdminWorkerOrderFeedbacks, markAdminWorkOrderProblem, pinAdminWorkOrder, publishAdminWorkOrder, @@ -39,6 +40,7 @@ import { reprocessAdminKuaishouSendCodeWorkOrders, reviewAdminWorkerFinanceRequest, reviewAdminWorkerCancelRequest, + resolveAdminWorkerOrderFeedback, resolveAdminProblemWorkOrder, reviewAdminWorkerUser, 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( '/worker-platform/cancel-requests/:requestId/review', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 1572801f..c78447e4 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -38,7 +38,9 @@ import { supplementWorkerAcceptedOrderEvidence, submitWorkerOrderAcceptance, getWorkerCancelRequestSummary, + getWorkerOrderFeedbackSummary, submitWorkerCancelRequest, + submitWorkerOrderFeedback, } from '../services/worker-platform/index.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' 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( '/orders/:workOrderId/cancel-requests', requireActiveWorker, diff --git a/apps/backend/src/services/admin/admin-notification-service.ts b/apps/backend/src/services/admin/admin-notification-service.ts index 6c3d1805..5c345a0c 100644 --- a/apps/backend/src/services/admin/admin-notification-service.ts +++ b/apps/backend/src/services/admin/admin-notification-service.ts @@ -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) { const now = new Date().toISOString() const eventConfig = getWorkerPlatformNotificationConfig().events.acceptance_reminded diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 90c5cd6f..2a69cea1 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -51,6 +51,8 @@ import { reviewWorkerFinanceRequest, reopenCancelledWorkOrder, listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin, + listAdminWorkerOrderFeedbacks as listWorkerOrderFeedbacksForAdmin, + resolveWorkerOrderFeedback, reviewWorkerCancelRequest, updateWorkOrder, updateWorkOrderBasic, @@ -111,6 +113,7 @@ import { mapWorkerLevel, mapWorkerUser, mapWorkerCancelRequest, + mapWorkerOrderFeedback, normalizeAdminFinanceReviewStatus, normalizeAmountFen, 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( requestId: number | string, payload: JsonObject = {}, diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index bf14f0f5..1277772f 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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) { if (!ledger) return null return { diff --git a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts index 74d5272f..76c7e604 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts @@ -9,6 +9,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [ 'acceptance_reminded', 'material_required', 'cancel_requested', + 'feedback_submitted', ] as const export type WorkerPlatformNotificationEventKey = @@ -53,6 +54,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN externalPushEnabled: false, }, cancel_requested: createEnabledEventConfig(), + feedback_submitted: createEnabledEventConfig(), }, } } diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index a1b9fa0c..94f47575 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -7,6 +7,7 @@ import { acceptWorkOrderShareAndSettle, countWorkerAcceptedOrders, createWorkerCancelRequest, + createWorkerOrderFeedback, countWorkerActiveOrders, countWorkerTimeoutEvents, countWorkerWithdrawRequestsOnDay, @@ -44,6 +45,7 @@ import { listWorkerFinanceRequests, listWorkerCompletionLeaderboard, listWorkerCancelRequests, + listWorkerOrderFeedbacks, listWorkerSharesByWorker, listWorkerWalletLedgers, listWorkerWorkOrderNotes, @@ -86,6 +88,7 @@ import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js import { createWorkerAcceptanceAdminNotification, createWorkerCancelRequestAdminNotification, + createWorkerOrderFeedbackAdminNotification, createWorkerRechargeAdminNotification, createWorkerWithdrawAdminNotification, getWorkerAcceptanceReminder, @@ -116,6 +119,7 @@ import { mapWorkOrderEvents, mapWorkOrderShare, mapWorkerCancelRequest, + mapWorkerOrderFeedback, mapWorkerUser, normalizeAmountFen, assertWithdrawChannelAllowedForAmount, @@ -1752,6 +1756,58 @@ export async function getWorkerCancelRequestSummary(session: WorkerSession) { 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( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index f5712b8c..c023e336 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -110,6 +110,16 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { title: note ? `客服同意撤单申请:${note}` : '客服同意撤单申请,订单已回到大厅', 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': return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' } diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 89de0025..27464360 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -9,6 +9,7 @@ import { getAdminRole } from '@/utils/admin-auth' import CategoriesPanel from './panels/CategoriesPanel' import CancelRequestsPanel from './panels/CancelRequestsPanel' import FinancePanel from './panels/FinancePanel' +import FeedbacksPanel from './panels/FeedbacksPanel' import LevelsPanel from './panels/LevelsPanel' import NotificationsPanel from './panels/NotificationsPanel' import ProductRulesPanel from './panels/ProductRulesPanel' @@ -49,10 +50,12 @@ export default function AdminWorkerPlatformPage() { ? [ { key: 'orders', label: '接单工单', children: }, { key: 'cancel-requests', label: '撤单审核', children: }, + { key: 'feedbacks', label: '问题反馈', children: }, ] : [ { key: 'orders', label: '接单工单', children: }, { key: 'cancel-requests', label: '撤单审核', children: }, + { key: 'feedbacks', label: '问题反馈', children: }, { key: 'rules', label: '接单模板', children: }, { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, @@ -75,6 +78,7 @@ function loadActiveTabPreference(): string { const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) return saved === 'orders' || saved === 'cancel-requests' || + saved === 'feedbacks' || saved === 'rules' || saved === 'product-match' || saved === 'categories' || @@ -94,6 +98,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string { return [ 'orders', 'cancel-requests', + 'feedbacks', 'rules', 'product-match', 'categories', diff --git a/apps/frontend/src/pages/admin/panels/FeedbacksPanel.tsx b/apps/frontend/src/pages/admin/panels/FeedbacksPanel.tsx new file mode 100644 index 00000000..ebe85322 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/FeedbacksPanel.tsx @@ -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(null) + const [form] = Form.useForm() + 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 = [ + { + title: '订单', + width: 260, + render: (_, feedback) => ( +
+ {feedback.productName || '-'} + {feedback.workOrderNo || '-'} +
+ ), + }, + { + title: '打手', + width: 180, + render: (_, feedback) => ( + + {feedback.workerName || `打手 #${feedback.workerId}`} + {feedback.shareQuantity ? ( + 拼单 {feedback.shareQuantity} 份 + ) : null} + + ), + }, + { + title: '反馈内容', + render: (_, feedback) => ( + + {feedback.content} + + ), + }, + { + title: '反馈时间', + width: 180, + render: (_, feedback) => formatAdminDateTime(feedback.createdAt), + }, + { + title: '操作', + width: 120, + render: (_, feedback) => ( + + ), + }, + ] + + return ( + <> + } + loading={feedbacksQuery.isFetching} + onClick={() => feedbacksQuery.refetch()} + > + 刷新 + + } + bordered={false} + > + + rowKey="feedbackId" + size="small" + loading={feedbacksQuery.isLoading} + dataSource={feedbacksQuery.data?.data.items || []} + columns={columns} + pagination={false} + locale={{ emptyText: '暂无待处理问题反馈' }} + scroll={{ x: 900 }} + /> + + { + setTarget(null) + form.resetFields() + }} + onOk={() => form.submit()} + okText="完成处理" + destroyOnHidden + > + {target ? ( + + + {target.workerName || `打手 #${target.workerId}`}:{target.content} + + + 完成处理不会更改订单状态、验收或资金,仅向打手记录处理回复。 + +
+ + + +
+
+ ) : null} +
+ + ) +} diff --git a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx index 5eafa4c2..5aa55b26 100644 --- a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx @@ -25,6 +25,7 @@ const notificationEventRows: NotificationEventRow[] = [ { key: 'acceptance_reminded', name: '催验收', description: '打手在验收后催促处理' }, { key: 'material_required', name: '待补资料工单', description: '新工单等待后台补充资料' }, { key: 'cancel_requested', name: '撤单申请', description: '打手提交新的撤单申请,等待客服审核' }, + { key: 'feedback_submitted', name: '问题反馈', description: '打手反馈客户资料或订单处理问题' }, ] export default function NotificationsPanel() { diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index f6315546..8206fbcc 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -2,6 +2,7 @@ import { CopyOutlined, EditOutlined, EyeOutlined, + MessageOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, @@ -35,6 +36,7 @@ import ImagePreviewList from '@/components/files/ImagePreviewList' import ImageUpload from '@/components/files/ImageUpload' import { fetchWorkerCancelRequests, + fetchWorkerOrderFeedbacks, fetchWorkerMyOrderOverview, fetchWorkerMyOrders, fetchWorkerProfile, @@ -45,6 +47,7 @@ import { supplementWorkerAcceptedOrderEvidence, submitWorkerAcceptance, submitWorkerCancelRequest, + submitWorkerOrderFeedback, } from '@/services/worker' import type { CollectField, @@ -79,6 +82,10 @@ type CancelRequestFormValues = { reason?: string } +type FeedbackFormValues = { + content?: string +} + function buildWorkerOrderQuery(status: string, keyword: string, page: number, pageSize: number) { return { keyword, @@ -201,10 +208,12 @@ export default function WorkerOrdersPage() { const [editingOrder, setEditingOrder] = useState(null) const [noteOrder, setNoteOrder] = useState(null) const [cancelRequestOrder, setCancelRequestOrder] = useState(null) + const [feedbackOrder, setFeedbackOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) const [form] = Form.useForm() const [noteForm] = Form.useForm() const [cancelRequestForm] = Form.useForm() + const [feedbackForm] = Form.useForm() const ordersQuery = useQuery({ queryKey: ['worker-my-orders', status, keyword, page, pageSize], @@ -234,6 +243,11 @@ export default function WorkerOrdersPage() { queryFn: fetchWorkerCancelRequests, retry: false, }) + const feedbacksQuery = useQuery({ + queryKey: ['worker-order-feedbacks'], + queryFn: fetchWorkerOrderFeedbacks, + retry: false, + }) const overviewByStatus = new Map( (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-overview'] }), 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) { if (!noteOrder) return try { @@ -684,6 +720,13 @@ export default function WorkerOrdersPage() { 申请取消 ) : null} + {canSubmitFeedback(row) ? ( + + + + ) : null} ), @@ -995,6 +1038,15 @@ export default function WorkerOrdersPage() { 申请取消 ) : null} + {canSubmitFeedback(order) ? ( + + ) : null} @@ -1163,6 +1215,11 @@ export default function WorkerOrdersPage() { 申请取消 ) : null} + {canSubmitFeedback(detailOrder) ? ( + + ) : null} ) : null } @@ -1279,6 +1336,52 @@ export default function WorkerOrdersPage() { ) : null} + { + setFeedbackOrder(null) + feedbackForm.resetFields() + }} + onOk={() => feedbackForm.submit()} + okText="提交反馈" + destroyOnHidden + > + + 可反馈客户资料错误、账号异常或无法继续处理等问题。客服处理后会在反馈记录中回复。 + +
+ + + +
+ {(feedbacksQuery.data?.data.items || []).filter( + (item) => item.workOrderId === feedbackOrder?.workOrderId, + ).length > 0 ? ( + + 该订单最近反馈 + {(feedbacksQuery.data?.data.items || []) + .filter((item) => item.workOrderId === feedbackOrder?.workOrderId) + .slice(0, 3) + .map((item) => ( + + {item.status === 'resolved' ? '已处理' : '待处理'}:{item.content} + {item.reply ? `;客服回复:${item.reply}` : ''} + + ))} + + ) : null} +
(`/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() { return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels') } diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 67323675..9330fa54 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -8,6 +8,7 @@ import type { WorkerFinanceRequest, WorkerCancelRequest, WorkerCancelRequestRisk, + WorkerOrderFeedback, WorkerHallOrdersResponse, WorkerLeaderboardResponse, 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) { return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`) } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 0eb2e286..3ad64a19 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -125,6 +125,23 @@ export type WorkerCancelRequestRisk = { 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 = { depositUnfreezeDays: number recharge: { @@ -151,6 +168,7 @@ export type WorkerPlatformNotificationEventKey = | 'acceptance_reminded' | 'material_required' | 'cancel_requested' + | 'feedback_submitted' export type WorkerPlatformNotificationConfig = { events: Record<