From 5330b55b75909de4f9ff7fd412f066ddd641cc57 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 17 Aug 2026 13:54:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8E=A5=E5=8D=95=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E9=80=9A=E7=9F=A5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/config/app-config-keys.ts | 1 + .../db/migrations/027_admin_notifications.sql | 29 ++ .../028_admin_notification_visibility.sql | 13 + .../repositories/admin-notification-repo.ts | 157 +++++++++++ .../src/repositories/worker-platform/types.ts | 4 + .../worker-platform/work-order-repo.ts | 7 + .../worker-platform/worker-repo.ts | 7 + apps/backend/src/routes/admin.ts | 2 + .../backend/src/routes/admin/notifications.ts | 18 ++ .../src/routes/admin/worker-platform.ts | 28 ++ apps/backend/src/routes/worker.ts | 18 ++ .../admin/admin-notification-service.ts | 211 ++++++++++++++ .../services/worker-platform/admin-service.ts | 40 +++ ...er-platform-notification-config-service.ts | 100 +++++++ .../worker-platform/worker-service.ts | 114 ++++++++ apps/frontend/src/layouts/AdminLayout.tsx | 259 +++++++++++++++++- .../pages/admin/AdminWorkerPlatformPage.tsx | 41 ++- .../src/pages/admin/panels/FinancePanel.tsx | 35 +++ .../pages/admin/panels/NotificationsPanel.tsx | 162 +++++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 39 ++- .../src/pages/worker/WorkerOrdersPage.tsx | 23 ++ apps/frontend/src/services/admin/index.ts | 1 + .../src/services/admin/notifications.ts | 21 ++ .../src/services/admin/worker-platform.ts | 16 ++ apps/frontend/src/services/worker.ts | 6 + apps/frontend/src/styles/admin.css | 30 ++ apps/frontend/src/types/worker-platform.ts | 18 ++ 27 files changed, 1390 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/db/migrations/027_admin_notifications.sql create mode 100644 apps/backend/src/db/migrations/028_admin_notification_visibility.sql create mode 100644 apps/backend/src/repositories/admin-notification-repo.ts create mode 100644 apps/backend/src/routes/admin/notifications.ts create mode 100644 apps/backend/src/services/admin/admin-notification-service.ts create mode 100644 apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts create mode 100644 apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx create mode 100644 apps/frontend/src/services/admin/notifications.ts diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index a706bf3b..de2959eb 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -3,6 +3,7 @@ export const APP_CONFIG_KEYS = { scheduledJobs: 'scheduled_jobs', fulfillmentRouting: 'fulfillment_routing', workerFinance: 'worker_finance', + workerPlatformNotifications: 'worker_platform_notifications', cloudtentaclesSources: 'cloudtentacles_sources', cloudtentaclesSession: 'cloudtentacles_session', cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', diff --git a/apps/backend/src/db/migrations/027_admin_notifications.sql b/apps/backend/src/db/migrations/027_admin_notifications.sql new file mode 100644 index 00000000..2d8dec75 --- /dev/null +++ b/apps/backend/src/db/migrations/027_admin_notifications.sql @@ -0,0 +1,29 @@ +-- 后台待办通知:业务状态驱动,多个后台账号共享处理状态。 +CREATE TABLE IF NOT EXISTS admin_notifications ( + id BIGSERIAL PRIMARY KEY, + notification_type TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'normal', + entity_type TEXT NOT NULL, + entity_id BIGINT NOT NULL, + dedupe_key TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + action_path TEXT NOT NULL DEFAULT '', + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + reminder_count INTEGER NOT NULL DEFAULT 0, + last_reminded_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'pending', + resolved_at TIMESTAMPTZ, + resolution_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_admin_notifications_pending + ON admin_notifications (status, priority, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_admin_notifications_entity + ON admin_notifications (entity_type, entity_id); + +COMMENT ON TABLE admin_notifications IS '后台共享业务待办通知,不使用个人已读状态'; +COMMENT ON COLUMN admin_notifications.dedupe_key IS '同一业务待办的唯一标识'; diff --git a/apps/backend/src/db/migrations/028_admin_notification_visibility.sql b/apps/backend/src/db/migrations/028_admin_notification_visibility.sql new file mode 100644 index 00000000..a284ac44 --- /dev/null +++ b/apps/backend/src/db/migrations/028_admin_notification_visibility.sql @@ -0,0 +1,13 @@ +-- 隐藏待办用于保留业务催办状态,不展示在后台铃铛中。 +ALTER TABLE admin_notifications + ADD COLUMN IF NOT EXISTS visible BOOLEAN NOT NULL DEFAULT TRUE; + +-- 每条待办保存创建或更新当时的声音开关,配置调整不影响历史待办。 +ALTER TABLE admin_notifications + ADD COLUMN IF NOT EXISTS sound_enabled BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE INDEX IF NOT EXISTS idx_admin_notifications_visible_pending + ON admin_notifications (visible, status, priority, updated_at DESC); + +COMMENT ON COLUMN admin_notifications.visible IS '是否展示在后台待办铃铛中'; +COMMENT ON COLUMN admin_notifications.sound_enabled IS '该次待办更新是否允许浏览器声音播报'; diff --git a/apps/backend/src/repositories/admin-notification-repo.ts b/apps/backend/src/repositories/admin-notification-repo.ts new file mode 100644 index 00000000..a8d9ac5d --- /dev/null +++ b/apps/backend/src/repositories/admin-notification-repo.ts @@ -0,0 +1,157 @@ +import { query } from '../db/client.js' + +export type AdminNotificationRow = { + id: number + notification_type: string + priority: string + entity_type: string + entity_id: number + dedupe_key: string + title: string + body: string + action_path: string + payload_json: string | Record + visible: boolean + sound_enabled: boolean + reminder_count: number + last_reminded_at: string | null + status: string + resolved_at: string | null + resolution_reason: string + created_at: string + updated_at: string +} + +export async function createAdminNotification(input: { + notificationType: string + priority: string + entityType: string + entityId: number + dedupeKey: string + title: string + body: string + actionPath: string + payloadJson?: string + visible?: boolean + soundEnabled?: boolean + now: string +}): Promise { + const result = await query( + ` + INSERT INTO admin_notifications ( + notification_type, priority, entity_type, entity_id, dedupe_key, + title, body, action_path, payload_json, visible, sound_enabled, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $12) + ON CONFLICT (dedupe_key) DO NOTHING + RETURNING * + `, + [ + input.notificationType, + input.priority, + input.entityType, + input.entityId, + input.dedupeKey, + input.title, + input.body, + input.actionPath, + input.payloadJson || '{}', + input.visible !== false, + input.soundEnabled !== false, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function getPendingAdminNotificationByDedupeKey( + dedupeKey: string, +): Promise { + const result = await query( + `SELECT * FROM admin_notifications WHERE dedupe_key = $1 AND status = 'pending' LIMIT 1`, + [dedupeKey], + ) + return result.rows[0] || null +} + +export async function bumpAdminNotificationReminder(input: { + dedupeKey: string + notificationType: string + title: string + visible: boolean + soundEnabled: boolean + now: string +}): Promise { + const result = await query( + ` + UPDATE admin_notifications + SET + notification_type = $1, + title = $2, + priority = 'high', + visible = visible OR $3, + sound_enabled = $4, + reminder_count = reminder_count + 1, + last_reminded_at = $5, + updated_at = $5 + WHERE dedupe_key = $6 + AND status = 'pending' + AND reminder_count < 3 + AND ( + last_reminded_at IS NULL + OR last_reminded_at <= $5::timestamptz - INTERVAL '30 minutes' + ) + RETURNING * + `, + [ + input.notificationType, + input.title, + input.visible, + input.soundEnabled, + input.now, + input.dedupeKey, + ], + ) + return result.rows[0] || null +} + +export async function resolveAdminNotificationsByEntity(input: { + entityType: string + entityId: number + reason: string + now: string +}): Promise { + await query( + ` + UPDATE admin_notifications + SET status = 'resolved', resolved_at = $1, resolution_reason = $2, updated_at = $1 + WHERE entity_type = $3 AND entity_id = $4 AND status = 'pending' + `, + [input.now, input.reason, input.entityType, input.entityId], + ) +} + +export async function listPendingAdminNotifications( + limit: number, +): Promise { + const result = await query( + ` + SELECT * + FROM admin_notifications + WHERE status = 'pending' AND visible = TRUE + ORDER BY + CASE priority WHEN 'urgent' THEN 3 WHEN 'high' THEN 2 ELSE 1 END DESC, + updated_at DESC, + id DESC + LIMIT $1 + `, + [limit], + ) + return result.rows +} + +export async function countPendingAdminNotifications(): Promise { + const result = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM admin_notifications WHERE status = 'pending' AND visible = TRUE`, + ) + return Number(result.rows[0]?.total || 0) +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 0a286f30..4a649164 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -221,6 +221,8 @@ export type ListInput = { status?: string /** 支持多个状态同时筛选(空数组表示全部状态) */ statuses?: string[] + /** 后台待办跳转时按工单主键精确定位 */ + workOrderId?: number keyword?: string workerId?: number categoryId?: number @@ -248,6 +250,8 @@ export type WalletLedgerListInput = { export type FinanceRequestListInput = { page?: number pageSize?: number + /** 后台待办跳转时按资金申请主键精确定位 */ + requestId?: number workerId?: number status?: string requestType?: string 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 e48c212c..e1d07648 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -767,6 +767,7 @@ export async function listWorkOrders({ pageSize = 20, statuses = [], status = '', + workOrderId = 0, keyword = '', workerId = 0, categoryId = 0, @@ -779,6 +780,7 @@ export async function listWorkOrders({ const effectiveStatuses = statuses.length > 0 ? statuses : status.trim() ? [status.trim()] : [] const { whereClause, params } = buildWorkOrderWhere({ statuses: effectiveStatuses, + workOrderId, keyword, workerId, categoryId, @@ -2665,6 +2667,7 @@ async function getWorkOrderByIdWithClient( function buildWorkOrderWhere({ statuses = [], + workOrderId = 0, keyword = '', workerId = 0, categoryId = 0, @@ -2694,6 +2697,10 @@ function buildWorkOrderWhere({ params.push(normalizedStatuses) filters.push(`wo.status = ANY($${params.length}::text[])`) } + if (workOrderId) { + params.push(workOrderId) + filters.push(`wo.id = $${params.length}`) + } if (keyword) { params.push(`%${keyword}%`) filters.push( diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index c8d08c46..8cc9b0a8 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -562,12 +562,14 @@ export async function countWorkerWithdrawRequestsOnDay( export async function listWorkerFinanceRequests({ page = 1, pageSize = 20, + requestId = 0, workerId = 0, status = '', requestType = '', keyword = '', }: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> { const { whereClause, params } = buildWorkerFinanceRequestWhere({ + requestId, workerId, status, requestType, @@ -1018,6 +1020,7 @@ function buildWorkerWalletLedgerWhere({ workerId = 0, ledgerType = '' }: WalletL } function buildWorkerFinanceRequestWhere({ + requestId = 0, workerId = 0, status = '', requestType = '', @@ -1025,6 +1028,10 @@ function buildWorkerFinanceRequestWhere({ }: FinanceRequestListInput) { const filters: string[] = [] const params: unknown[] = [] + if (requestId) { + params.push(requestId) + filters.push(`wfr.id = $${params.length}`) + } if (workerId) { params.push(workerId) filters.push(`wfr.worker_id = $${params.length}`) diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 68ac2bc3..22b96d89 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -8,6 +8,7 @@ import devMockRouter from './admin/dev-mock.js' import filesRouter from './admin/files.js' import kuaishouIndustryRouter from './admin/kuaishou-industry.js' import ordersRouter from './admin/orders.js' +import notificationsRouter from './admin/notifications.js' import platformConfigRouter from './admin/platform-config.js' import { requireAdminSession } from './admin/session.js' import tasksRouter from './admin/tasks.js' @@ -26,6 +27,7 @@ router.use(auditLogsRouter) router.use(platformConfigRouter) router.use(kuaishouIndustryRouter) router.use(ordersRouter) +router.use(notificationsRouter) router.use(tasksRouter) router.use(cloudtentaclesRecordsRouter) router.use(workerPlatformRouter) diff --git a/apps/backend/src/routes/admin/notifications.ts b/apps/backend/src/routes/admin/notifications.ts new file mode 100644 index 00000000..f3237e85 --- /dev/null +++ b/apps/backend/src/routes/admin/notifications.ts @@ -0,0 +1,18 @@ +import { Router } from 'express' + +import { listAdminPendingNotifications } from '../../services/admin/admin-notification-service.js' +import { createJsonHandler, requireAdminRoles } from './session.js' + +const router = Router() + +router.get( + '/notifications', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => listAdminPendingNotifications(Number(req.query.limit || 20)), { + successMessage: 'ok', + errorMessage: '读取后台待办失败', + scope: '[admin/notifications]', + }), +) + +export default router diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index d7dcb68d..381623cf 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -14,6 +14,7 @@ import { deleteAdminWorkOrder, deleteAdminWorkerLevel, getAdminWorkerFinanceConfig, + getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkOrderEvents, getAdminWorkOrderSharing, @@ -31,6 +32,7 @@ import { resolveAdminProblemWorkOrder, reviewAdminWorkerUser, saveAdminWorkerFinanceConfig, + saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerWithdrawalAccount, saveAdminWorkCategory, saveAdminWorkProductRule, @@ -289,6 +291,32 @@ router.post( }), ) +router.get( + '/worker-platform/notification-config', + requireAdminRoles(['admin', 'operator']), + createJsonHandler(() => getAdminWorkerPlatformNotificationConfig(), { + successMessage: 'ok', + errorMessage: '读取通知配置失败', + scope: '[admin/worker-platform/notification-config]', + }), +) + +router.post( + '/worker-platform/notification-config', + requireAdminRoles(['admin']), + createJsonHandler((req) => saveAdminWorkerPlatformNotificationConfig(req.body || {}), { + successMessage: '通知配置已保存', + errorMessage: '保存通知配置失败', + scope: '[admin/worker-platform/notification-config]', + audit: (_req, data) => ({ + action: 'worker_platform_notification_config_saved', + targetType: 'worker_platform_notification_config', + targetId: 'worker_platform_notifications', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.get( '/worker-platform/finance-requests', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 3a87518c..89925cfa 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -25,6 +25,7 @@ import { sendWorkerSmsCode, saveWorkerOrderAcceptanceDraft, saveWorkerOrderNote, + remindWorkerOrderAcceptance, supplementWorkerAcceptedOrderEvidence, submitWorkerOrderAcceptance, } from '../services/worker-platform/index.js' @@ -329,6 +330,23 @@ router.post( ), ) +router.post( + '/orders/:workOrderId/remind-acceptance', + requireActiveWorker, + createRouteHandler( + (req) => + remindWorkerOrderAcceptance( + String(req.params.workOrderId || ''), + getRequiredWorkerSession(req), + ), + { + successMessage: '已催促管理员验收', + errorMessage: '催验收失败', + scope: '[worker/orders/:workOrderId/remind-acceptance]', + }, + ), +) + router.post( '/orders/:workOrderId/acceptance-draft', requireActiveWorker, diff --git a/apps/backend/src/services/admin/admin-notification-service.ts b/apps/backend/src/services/admin/admin-notification-service.ts new file mode 100644 index 00000000..0cf21752 --- /dev/null +++ b/apps/backend/src/services/admin/admin-notification-service.ts @@ -0,0 +1,211 @@ +import { + bumpAdminNotificationReminder, + countPendingAdminNotifications, + createAdminNotification, + getPendingAdminNotificationByDedupeKey, + listPendingAdminNotifications, + resolveAdminNotificationsByEntity, + type AdminNotificationRow, +} from '../../repositories/admin-notification-repo.js' +import { notifyInternalSafely } from '../notification/domain-notifications.js' +import { + getWorkerPlatformNotificationConfig, + type WorkerPlatformNotificationEventKey, +} from '../worker-platform/worker-platform-notification-config-service.js' + +const ADMIN_WORKER_PLATFORM_PATH = '/admin/worker-platform' + +export async function createWorkerWithdrawAdminNotification(input: { + requestId: number + workerName: string + amountFen: number + channel: string +}) { + return createConfiguredWorkerPlatformNotification('withdraw_requested', { + notificationType: 'worker_withdraw_requested', + priority: 'high', + entityType: 'worker_finance_request', + entityId: input.requestId, + dedupeKey: `worker_withdraw_request:${input.requestId}`, + title: '有新的提现申请', + body: `打手:${input.workerName || '-'};金额:${formatAmount(input.amountFen)};渠道:${formatChannel(input.channel)}`, + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=finance`, + now: new Date().toISOString(), + }) +} + +export async function createWorkerRechargeAdminNotification(input: { + requestId: number + workerName: string + amountFen: number +}) { + return createConfiguredWorkerPlatformNotification('recharge_requested', { + notificationType: 'worker_recharge_requested', + priority: 'normal', + entityType: 'worker_finance_request', + entityId: input.requestId, + dedupeKey: `worker_recharge_request:${input.requestId}`, + title: '有新的充值申请', + body: `打手:${input.workerName || '-'};金额:${formatAmount(input.amountFen)}`, + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=finance`, + now: new Date().toISOString(), + }) +} + +export async function createWorkOrderMaterialAdminNotification(input: { + workOrderId: number + workOrderNo: string + productName: string +}) { + return createConfiguredWorkerPlatformNotification('material_required', { + notificationType: 'work_order_material_required', + priority: 'normal', + entityType: 'work_order', + entityId: input.workOrderId, + dedupeKey: `work_order_material:${input.workOrderId}`, + title: '有工单待补资料', + body: `订单:${input.workOrderNo || input.workOrderId};商品:${input.productName || '-'}`, + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`, + now: new Date().toISOString(), + }) +} + +export async function createWorkerAcceptanceAdminNotification(input: { + workOrderId: number + workOrderNo: string + productName: string + workerName: string + imageCount: number +}) { + return createConfiguredWorkerPlatformNotification('acceptance_submitted', { + notificationType: 'worker_acceptance_submitted', + priority: 'normal', + entityType: 'work_order', + entityId: input.workOrderId, + dedupeKey: acceptanceNotificationKey(input.workOrderId), + title: '有订单待验收', + body: [ + `订单:${input.workOrderNo || input.workOrderId}`, + `商品:${input.productName || '-'}`, + `打手:${input.workerName || '-'}`, + `验收图片:${input.imageCount} 张`, + ].join(';'), + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`, + now: new Date().toISOString(), + }) +} + +export async function remindWorkerAcceptanceAdminNotification(workOrderId: number) { + const now = new Date().toISOString() + const eventConfig = getWorkerPlatformNotificationConfig().events.acceptance_reminded + const notification = await bumpAdminNotificationReminder({ + dedupeKey: acceptanceNotificationKey(workOrderId), + notificationType: 'worker_acceptance_reminded', + title: '有打手催验收', + visible: eventConfig.todoEnabled, + soundEnabled: eventConfig.todoEnabled && eventConfig.soundEnabled, + now, + }) + if (notification && eventConfig.externalPushEnabled) { + void notifyInternalSafely({ + title: '打手催促验收', + body: `${notification.body};第 ${notification.reminder_count} 次催验收`, + category: 'worker_acceptance_reminded', + url: notification.action_path, + cooldownKey: `worker_acceptance_reminded:${workOrderId}:${notification.reminder_count}`, + cooldownMs: 30 * 60 * 1000, + }) + } + return notification +} + +export async function getWorkerAcceptanceReminder(workOrderId: number) { + return getPendingAdminNotificationByDedupeKey(acceptanceNotificationKey(workOrderId)) +} + +export async function resolveAdminNotificationEntity( + entityType: string, + entityId: number, + reason: string, +) { + await resolveAdminNotificationsByEntity({ + entityType, + entityId, + reason, + now: new Date().toISOString(), + }) +} + +export async function listAdminPendingNotifications(limit = 20) { + const normalizedLimit = Math.min(50, Math.max(1, Math.floor(Number(limit) || 20))) + const [items, pendingCount] = await Promise.all([ + listPendingAdminNotifications(normalizedLimit), + countPendingAdminNotifications(), + ]) + return { + pendingCount, + items: items.map(mapAdminNotification), + } +} + +function mapAdminNotification(row: AdminNotificationRow) { + return { + notificationId: Number(row.id), + notificationType: row.notification_type, + priority: row.priority, + entityType: row.entity_type, + entityId: Number(row.entity_id), + title: row.title, + body: row.body, + actionPath: row.action_path, + reminderCount: Number(row.reminder_count || 0), + soundEnabled: row.sound_enabled !== false, + lastRemindedAt: row.last_reminded_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +async function createConfiguredWorkerPlatformNotification( + eventKey: WorkerPlatformNotificationEventKey, + input: { + notificationType: string + priority: string + entityType: string + entityId: number + dedupeKey: string + title: string + body: string + actionPath: string + now: string + }, +) { + const eventConfig = getWorkerPlatformNotificationConfig().events[eventKey] + const notification = await createAdminNotification({ + ...input, + visible: eventConfig.todoEnabled, + soundEnabled: eventConfig.todoEnabled && eventConfig.soundEnabled, + }) + if (notification && eventConfig.externalPushEnabled) { + void notifyInternalSafely({ + title: notification.title, + body: notification.body, + category: notification.notification_type, + url: notification.action_path, + cooldownKey: notification.dedupe_key, + }) + } + return notification +} + +function acceptanceNotificationKey(workOrderId: number) { + return `work_order_acceptance:${workOrderId}` +} + +function formatAmount(amountFen: number) { + return `¥${(Math.max(0, Number(amountFen) || 0) / 100).toFixed(2)}` +} + +function formatChannel(channel: string) { + return channel === 'wechat' ? '微信' : channel === 'alipay' ? '支付宝' : channel || '-' +} diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 0113194c..227bc848 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -56,7 +56,15 @@ import { randomId } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' import { getWorkerFinanceConfig, saveWorkerFinanceConfig } from './worker-finance-config-service.js' +import { + getWorkerPlatformNotificationConfig, + saveWorkerPlatformNotificationConfig, +} from './worker-platform-notification-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' +import { + createWorkOrderMaterialAdminNotification, + resolveAdminNotificationEntity, +} from '../admin/admin-notification-service.js' import { consumeIndustryVouchersBeforeWorkOrderAssign, consumeIndustryVouchersBeforeWorkOrderPublish, @@ -609,15 +617,25 @@ export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) { return config } +export async function getAdminWorkerPlatformNotificationConfig() { + return getWorkerPlatformNotificationConfig() +} + +export async function saveAdminWorkerPlatformNotificationConfig(payload: JsonObject = {}) { + return saveWorkerPlatformNotificationConfig(payload) +} + export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) + const requestId = normalizeOptionalId(query.requestId ?? query.request_id) || 0 const status = normalizeFinanceRequestStatus(query.status) const requestType = normalizeFinanceRequestType(query.requestType) const keyword = String(query.keyword || '').trim() const { items, total } = await listWorkerFinanceRequests({ page, pageSize, + requestId, status, requestType, keyword, @@ -670,6 +688,12 @@ export async function reviewAdminWorkerFinanceRequest( }) } + await resolveAdminNotificationEntity( + 'worker_finance_request', + Number(reviewed.request.id), + 'finance_request_reviewed', + ) + return { request: mapFinanceRequest(reviewed.request), } @@ -695,6 +719,7 @@ export async function getAdminWorkOrderEvents(workOrderId: number | string) { export async function listAdminWorkOrders(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) + const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id) || 0 const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0 const statuses = normalizeStatuses(query.status) const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false) @@ -702,6 +727,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { page, pageSize, statuses, + workOrderId, keyword: String(query.keyword || '').trim(), workerSharingId: workerId, vipEvidencePending, @@ -954,6 +980,9 @@ export async function submitAdminWorkOrderMaterial( payloadJson: JSON.stringify({ fields: submittedFields, complete, rewardAmount }), now, }) + if (nextStatus !== WORK_ORDER_STATUS.PENDING_MATERIAL) { + await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'material_completed') + } return { order: mapWorkOrderAdmin(updated || workOrder), complete } } @@ -1319,6 +1348,9 @@ export async function markAdminWorkOrderProblem( payloadJson: JSON.stringify({ note }), now, }) + if (workOrder.status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { + await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'marked_problem') + } return { order: mapWorkOrderAdmin(updated || workOrder) } } @@ -1388,6 +1420,7 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa errorCode: 'work_order_accept_conflict', }) } + await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'accepted') return { order: mapWorkOrderAdmin(updated || workOrder) } } @@ -1633,6 +1666,13 @@ export async function syncWorkerOrdersForSourceOrder( }), now, }) + if (workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL) { + await createWorkOrderMaterialAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + }) + } } } 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 new file mode 100644 index 00000000..2fd90adc --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts @@ -0,0 +1,100 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' + +export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [ + 'withdraw_requested', + 'recharge_requested', + 'acceptance_submitted', + 'acceptance_reminded', + 'material_required', +] as const + +export type WorkerPlatformNotificationEventKey = + (typeof WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS)[number] + +export type WorkerPlatformNotificationEventConfig = { + todoEnabled: boolean + soundEnabled: boolean + externalPushEnabled: boolean +} + +export type WorkerPlatformNotificationConfig = { + events: Record +} + +export function getWorkerPlatformNotificationConfig(): WorkerPlatformNotificationConfig { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerPlatformNotifications, + fallback: createDefaultWorkerPlatformNotificationConfig, + normalize: normalizeWorkerPlatformNotificationConfig, + }) +} + +export async function saveWorkerPlatformNotificationConfig(rawValue: unknown) { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerPlatformNotifications, + value: rawValue, + normalize: normalizeWorkerPlatformNotificationConfig, + }) +} + +export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformNotificationConfig { + return { + events: { + withdraw_requested: createEnabledEventConfig(), + recharge_requested: createEnabledEventConfig(), + acceptance_submitted: createEnabledEventConfig(), + acceptance_reminded: createEnabledEventConfig(), + material_required: { + todoEnabled: false, + soundEnabled: false, + externalPushEnabled: false, + }, + }, + } +} + +export function normalizeWorkerPlatformNotificationConfig( + rawValue: unknown, +): WorkerPlatformNotificationConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const rawEvents = isPlainObject(source.events) ? source.events : {} + const defaults = createDefaultWorkerPlatformNotificationConfig() + + return { + events: Object.fromEntries( + WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS.map((key) => [ + key, + normalizeEventConfig(rawEvents[key], defaults.events[key]), + ]), + ) as WorkerPlatformNotificationConfig['events'], + } +} + +function createEnabledEventConfig(): WorkerPlatformNotificationEventConfig { + return { todoEnabled: true, soundEnabled: true, externalPushEnabled: true } +} + +function normalizeEventConfig( + value: unknown, + fallback: WorkerPlatformNotificationEventConfig, +): WorkerPlatformNotificationEventConfig { + const source = isPlainObject(value) ? value : {} + const todoEnabled = + typeof source.todoEnabled === 'boolean' ? source.todoEnabled : fallback.todoEnabled + return { + todoEnabled, + soundEnabled: + todoEnabled && + (typeof source.soundEnabled === 'boolean' ? source.soundEnabled : fallback.soundEnabled), + externalPushEnabled: + typeof source.externalPushEnabled === 'boolean' + ? source.externalPushEnabled + : fallback.externalPushEnabled, + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 3bdf9a5b..aba1aeee 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -68,6 +68,13 @@ import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin- import { getSmsProvider } from '../sms/index.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' +import { + createWorkerAcceptanceAdminNotification, + createWorkerRechargeAdminNotification, + createWorkerWithdrawAdminNotification, + getWorkerAcceptanceReminder, + remindWorkerAcceptanceAdminNotification, +} from '../admin/admin-notification-service.js' import { DEFAULT_CATEGORY_KEY, @@ -689,6 +696,11 @@ export async function createWorkerRechargeRequest( errorCode: 'worker_recharge_request_create_failed', }) } + await createWorkerRechargeAdminNotification({ + requestId: Number(created.id), + workerName: worker.display_name || worker.username, + amountFen: Number(created.amount || amount), + }) return { request: mapFinanceRequest(created), } @@ -779,6 +791,12 @@ export async function createWorkerWithdrawRequest( errorCode: 'worker_withdraw_request_create_failed', }) } + await createWorkerWithdrawAdminNotification({ + requestId: Number(created.id), + workerName: worker.display_name || worker.username, + amountFen: Number(created.amount || amount), + channel: created.account_channel, + }) return { request: mapFinanceRequest(created), availableForWithdraw, @@ -1426,11 +1444,107 @@ export async function submitWorkerOrderAcceptance( order: mapWorkOrderForWorker(acceptedOrder, resolveWorkerPermissions(worker)), } } + await createWorkerAcceptanceAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + workerName: worker.display_name || worker.username, + imageCount: imageUrls.length, + }) return { order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)), } } +/** 打手在提交验收后催促后台处理;频率和次数均由服务端控制。 */ +export async function remindWorkerOrderAcceptance( + workOrderId: number | string, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const workOrder = await getRequiredWorkOrder(workOrderId) + if ( + workOrder.sharing_enabled || + Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId) + ) { + throw createHttpError('只能催促自己提交的整单验收', { + statusCode: 403, + errorCode: 'work_order_acceptance_reminder_owner_required', + }) + } + if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { + throw createHttpError('当前订单无需催验收', { + statusCode: 409, + errorCode: 'work_order_acceptance_reminder_status_invalid', + }) + } + const submittedAt = Date.parse(String(workOrder.submitted_at || '')) + const now = Date.now() + const firstReminderAt = submittedAt + 10 * 60 * 1000 + if (!Number.isFinite(submittedAt) || now < firstReminderAt) { + throw createHttpError('提交验收满 10 分钟后才可催验收', { + statusCode: 409, + errorCode: 'work_order_acceptance_reminder_too_early', + }) + } + + let notification = await getWorkerAcceptanceReminder(Number(workOrder.id)) + if (!notification) { + const worker = await getRequiredWorker(session.workerId) + await createWorkerAcceptanceAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + workerName: worker.display_name || worker.username, + imageCount: normalizeStringArray(safeParseJson(workOrder.acceptance_json).imageUrls).length, + }) + notification = await getWorkerAcceptanceReminder(Number(workOrder.id)) + } + if (!notification) { + throw createHttpError('催验收待办创建失败,请稍后重试', { + statusCode: 409, + errorCode: 'work_order_acceptance_reminder_notification_missing', + }) + } + if (Number(notification.reminder_count || 0) >= 3) { + throw createHttpError('该订单最多催验收 3 次', { + statusCode: 409, + errorCode: 'work_order_acceptance_reminder_limit_reached', + }) + } + const lastRemindedAt = Date.parse(String(notification.last_reminded_at || '')) + const nextRemindAt = lastRemindedAt + 30 * 60 * 1000 + if (Number.isFinite(lastRemindedAt) && now < nextRemindAt) { + throw createHttpError('每 30 分钟可催验收一次,请稍后再试', { + statusCode: 409, + errorCode: 'work_order_acceptance_reminder_cooldown', + }) + } + + const reminded = await remindWorkerAcceptanceAdminNotification(Number(workOrder.id)) + if (!reminded) { + throw createHttpError('订单状态已变化,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_acceptance_reminder_conflict', + }) + } + const remindedAt = new Date(reminded.last_reminded_at || new Date().toISOString()).getTime() + await createWorkOrderEvent({ + workOrderId: Number(workOrder.id), + actorType: 'worker', + actorId: String(session.workerId), + eventType: 'acceptance_reminded', + fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + payloadJson: JSON.stringify({ reminderCount: reminded.reminder_count }), + now: new Date(reminded.last_reminded_at || new Date().toISOString()).toISOString(), + }) + return { + reminderCount: Number(reminded.reminder_count || 0), + nextRemindAt: new Date(remindedAt + 30 * 60 * 1000).toISOString(), + } +} + /** VIP 自动验收订单在 24 小时内追加验收图片,不改变订单状态和结算结果。 */ export async function supplementWorkerAcceptedOrderEvidence( workOrderId: number | string, diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 2f80f0e5..b6adacd5 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -1,5 +1,6 @@ import { AuditOutlined, + BellOutlined, BugOutlined, DashboardOutlined, FileSearchOutlined, @@ -10,18 +11,36 @@ import { SafetyCertificateOutlined, SettingOutlined, ShopOutlined, + SoundOutlined, TeamOutlined, TrophyOutlined, UnorderedListOutlined, UserOutlined, } from '@ant-design/icons' -import { App, Button, Dropdown, Layout, Menu, Space, Typography } from 'antd' +import { + App, + Badge, + Button, + Dropdown, + Layout, + List, + Menu, + Popover, + Space, + Tooltip, + Typography, +} from 'antd' import type { MenuProps } from 'antd' -import { useQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useRef, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router' -import { fetchDevMockStatus, logoutAdmin } from '@/services/admin' +import { + fetchAdminNotifications, + fetchDevMockStatus, + logoutAdmin, + type AdminNotification, +} from '@/services/admin' import { clearAdminSession, getAdminRole, @@ -31,17 +50,25 @@ import { import { formatAdminDateTime } from '@/utils/admin-time' const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed' +const NOTIFICATION_SOUND_KEY = 'admin-notification-sound-enabled' +const NOTIFICATION_SOUND_COOLDOWN_MS = 30_000 export default function AdminLayout() { const navigate = useNavigate() const location = useLocation() const { message } = App.useApp() + const queryClient = useQueryClient() const [collapsed, setCollapsed] = useState( () => window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1', ) const role = getAdminRole() const username = getAdminUsername() || 'admin' const expiresAt = getAdminTokenExpiresAt() + const [soundEnabled, setSoundEnabled] = useState( + () => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1', + ) + const seenNotificationVersions = useRef | null>(null) + const lastSoundAt = useRef(0) const devMockStatusQuery = useQuery({ queryKey: ['admin-dev-mock-status'], @@ -50,6 +77,43 @@ export default function AdminLayout() { retry: false, }) const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled) + const notificationsQuery = useQuery({ + queryKey: ['admin-notifications'], + queryFn: fetchAdminNotifications, + refetchInterval: 10_000, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + retry: false, + }) + const notifications = notificationsQuery.data?.data.items || [] + const pendingNotificationCount = Number(notificationsQuery.data?.data.pendingCount || 0) + + useEffect(() => { + const currentVersions = new Map( + notifications.map((notification) => [notification.notificationId, notification.updatedAt]), + ) + if (seenNotificationVersions.current === null) { + seenNotificationVersions.current = currentVersions + return + } + const changedNotifications = notifications.filter( + (notification) => + seenNotificationVersions.current?.get(notification.notificationId) !== + notification.updatedAt, + ) + seenNotificationVersions.current = currentVersions + if (changedNotifications.length === 0) return + + void Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-finance-requests'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + ]) + if (soundEnabled && Date.now() - lastSoundAt.current >= NOTIFICATION_SOUND_COOLDOWN_MS) { + lastSoundAt.current = Date.now() + announceNotification(selectNotificationToAnnounce(changedNotifications)) + } + }, [notifications, queryClient, soundEnabled]) const menuItems = useMemo(() => { const operationItems: MenuProps['items'] = [ @@ -121,6 +185,64 @@ export default function AdminLayout() { window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0') } + function toggleNotificationSound() { + const next = !soundEnabled + setSoundEnabled(next) + window.localStorage.setItem(NOTIFICATION_SOUND_KEY, next ? '1' : '0') + if (next) { + playNotificationSound('default') + message.success('通知声音已开启') + } else { + message.info('通知声音已关闭') + } + } + + function openNotification(notification: AdminNotification) { + const actionPath = buildNotificationActionPath(notification) + if (actionPath) { + void navigate(actionPath) + } + } + + const notificationContent = ( +
+ {notifications.length === 0 ? ( + 暂无待处理事项 + ) : ( + ( + openNotification(notification)} + > +
+ + {notification.title} + {notification.priority === 'high' || notification.priority === 'urgent' ? ( + 紧急 + ) : null} + {notification.reminderCount > 0 ? ( + + 已催 {notification.reminderCount} 次 + + ) : null} + + + {notification.body} + + + {formatAdminDateTime(notification.updatedAt)} + +
+
+ )} + /> + )} +
+ ) + const selectedKey = resolveSelectedKey(location.pathname) return ( @@ -182,6 +304,24 @@ export default function AdminLayout() { + + + } + /> + ) : null} (null) + const [saving, setSaving] = useState(false) + const configQuery = useQuery({ + queryKey: ['admin-worker-platform-notification-config'], + queryFn: fetchAdminWorkerPlatformNotificationConfig, + }) + + useEffect(() => { + const config = configQuery.data?.data + if (config) { + setDraft(config) + } + }, [configQuery.data]) + + function updateEvent( + eventKey: WorkerPlatformNotificationEventKey, + field: 'todoEnabled' | 'soundEnabled' | 'externalPushEnabled', + value: boolean, + ) { + setDraft((current) => { + if (!current) return current + const event = current.events[eventKey] + const nextEvent = { ...event, [field]: value } + if (field === 'todoEnabled' && !value) { + nextEvent.soundEnabled = false + } + return { + ...current, + events: { ...current.events, [eventKey]: nextEvent }, + } + }) + } + + async function saveConfig() { + if (!draft) return + setSaving(true) + try { + await saveAdminWorkerPlatformNotificationConfig(draft) + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-notification-config'], + }) + message.success('通知配置已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : '保存通知配置失败') + } finally { + setSaving(false) + } + } + + const columns: TableColumnsType = [ + { + title: '通知事件', + dataIndex: 'name', + minWidth: 220, + render: (_, row) => ( +
+ {row.name} + {row.description} +
+ ), + }, + { + title: '后台待办', + width: 130, + align: 'center', + render: (_, row) => ( + updateEvent(row.key, 'todoEnabled', checked)} + /> + ), + }, + { + title: '声音播报', + width: 130, + align: 'center', + render: (_, row) => ( + updateEvent(row.key, 'soundEnabled', checked)} + /> + ), + }, + { + title: 'Bark/WPush', + width: 140, + align: 'center', + render: (_, row) => ( + updateEvent(row.key, 'externalPushEnabled', checked)} + /> + ), + }, + ] + + return ( + + + + + + + 浏览器总声音开关仍可在后台右上角控制。 + + + + ) +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 3a41e3ae..087bf354 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -37,7 +37,7 @@ import { } from 'antd' import type { TableColumnsType } from 'antd' import { useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router' +import { useNavigate, useSearchParams } from 'react-router' import JsonPreview from '@/components/admin/JsonPreview' import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' @@ -116,9 +116,11 @@ type CancelOrderFormValues = { export default function WorkOrdersPanel() { const { message, modal } = App.useApp() const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const queryClient = useQueryClient() const canEditRequirementFields = hasAdminRole('operator') const [statuses, setStatuses] = useState([]) + const [targetWorkOrderId, setTargetWorkOrderId] = useState() const [keyword, setKeyword] = useState('') const [keywordInput, setKeywordInput] = useState('') const [workerId, setWorkerId] = useState() @@ -165,12 +167,31 @@ export default function WorkOrdersPanel() { ) const vipEvidencePending = statuses.includes(VIP_EVIDENCE_PENDING_STATUS) + useEffect(() => { + const workOrderId = Number(searchParams.get('workOrderId') || 0) + if (!Number.isInteger(workOrderId) || workOrderId <= 0) return + setTargetWorkOrderId(workOrderId) + setStatuses([]) + setKeyword('') + setKeywordInput('') + setWorkerId(undefined) + setPage(1) + setSearchParams( + (current) => { + current.delete('workOrderId') + return current + }, + { replace: true }, + ) + }, [searchParams, setSearchParams]) + const ordersQuery = useQuery({ queryKey: [ 'admin-worker-platform-orders', statuses.join(','), keyword, workerId, + targetWorkOrderId, page, pageSize, ], @@ -179,6 +200,7 @@ export default function WorkOrdersPanel() { status: selectedWorkOrderStatuses.length > 0 ? selectedWorkOrderStatuses.join(',') : undefined, vipEvidencePending: vipEvidencePending || undefined, + workOrderId: targetWorkOrderId, keyword, workerId, page, @@ -207,7 +229,7 @@ export default function WorkOrdersPanel() { useEffect(() => { setSelectedAcceptanceOrderIds([]) - }, [statuses, keyword, workerId, page, pageSize]) + }, [statuses, keyword, workerId, targetWorkOrderId, page, pageSize]) function openAssignModal(row: WorkOrder) { setAssignOrder(row) @@ -482,6 +504,7 @@ export default function WorkOrdersPanel() { } function clearOrderFilters() { + setTargetWorkOrderId(undefined) setKeywordInput('') setKeyword('') setStatuses([]) @@ -865,6 +888,18 @@ export default function WorkOrdersPanel() { return (
+ {targetWorkOrderId ? ( + setTargetWorkOrderId(undefined)}> + 取消定位 + + } + /> + ) : null}
diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 0b1fae57..c9586904 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -37,6 +37,7 @@ import { fetchWorkerMyOrderOverview, fetchWorkerMyOrders, fetchWorkerProfile, + remindWorkerAcceptance, saveWorkerAcceptanceDraft, saveWorkerOrderNote, supplementWorkerAcceptedOrderEvidence, @@ -300,6 +301,16 @@ export default function WorkerOrdersPage() { } } + async function submitAcceptanceReminder(order: WorkOrder) { + try { + await remindWorkerAcceptance(order.workOrderId) + message.success('已催促管理员验收') + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '催验收失败') + } + } + async function submitAcceptance(values: AcceptanceFormValues) { if (!editingOrder) return const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder) @@ -597,6 +608,9 @@ export default function WorkerOrdersPage() { {hasSubmittedAcceptance(row) ? '补充图片' : '验收'} ) : null} + {canRemindAcceptance(row) ? ( + + ) : null}
), @@ -893,6 +907,11 @@ export default function WorkerOrdersPage() { {hasSubmittedAcceptance(order) ? '补充图片' : '提交验收'} ) : null} + {canRemindAcceptance(order) ? ( + + ) : null} @@ -1292,6 +1311,10 @@ function canSubmitAcceptance(order: WorkOrder) { return ['in_progress', 'problem'].includes(order.status) } +function canRemindAcceptance(order: WorkOrder) { + return !order.myShare && order.status === 'pending_acceptance' +} + function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) { return ( workerCanAutoAcceptWithoutEvidence && diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index e1f204f1..ae8abbb6 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -7,4 +7,5 @@ export * from './kuaishou-industry' export * from './orders' export * from './tasks' export * from './dev-mock' +export * from './notifications' export * from './worker-platform' diff --git a/apps/frontend/src/services/admin/notifications.ts b/apps/frontend/src/services/admin/notifications.ts new file mode 100644 index 00000000..6160ae12 --- /dev/null +++ b/apps/frontend/src/services/admin/notifications.ts @@ -0,0 +1,21 @@ +import { apiGet } from '@/lib/http' + +export type AdminNotification = { + notificationId: number + notificationType: string + priority: 'normal' | 'high' | 'urgent' | string + entityType: string + entityId: number + title: string + body: string + actionPath: string + reminderCount: number + soundEnabled: boolean + lastRemindedAt: string | null + createdAt: string + updatedAt: string +} + +export function fetchAdminNotifications() { + return apiGet<{ pendingCount: number; items: AdminNotification[] }>('/api/v1/admin/notifications') +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index fac7deb3..dc4bde57 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -9,6 +9,7 @@ import type { WorkProductRule, WorkerFinanceConfig, WorkerFinanceRequest, + WorkerPlatformNotificationConfig, WorkerLevel, WorkerListResponse, WorkerUser, @@ -203,6 +204,21 @@ export function saveAdminWorkerFinanceConfig(payload: { return apiPost('/api/v1/admin/worker-platform/finance-config', payload) } +export function fetchAdminWorkerPlatformNotificationConfig() { + return apiGet( + '/api/v1/admin/worker-platform/notification-config', + ) +} + +export function saveAdminWorkerPlatformNotificationConfig( + payload: WorkerPlatformNotificationConfig, +) { + return apiPost( + '/api/v1/admin/worker-platform/notification-config', + payload, + ) +} + export function fetchAdminWorkerFinanceRequests(params?: Record) { return apiGet>( '/api/v1/admin/worker-platform/finance-requests', diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index a6d441c7..541f6888 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -154,6 +154,12 @@ export function submitWorkerAcceptance( ) } +export function remindWorkerAcceptance(workOrderId: number) { + return apiPost<{ reminderCount: number; nextRemindAt: string }>( + `/api/v1/worker/orders/${workOrderId}/remind-acceptance`, + ) +} + export function saveWorkerAcceptanceDraft( workOrderId: number, payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index 5fe8db98..2e9dc3c4 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -449,6 +449,36 @@ margin-left: auto; } +.admin-sound-muted { + opacity: 0.45; +} + +.admin-notification-popover { + width: min(380px, calc(100vw - 32px)); +} + +.admin-notification-item { + cursor: pointer; +} + +.admin-notification-item .ant-list-item-meta, +.admin-notification-item > div { + width: 100%; +} + +.admin-notification-item .ant-typography { + margin-bottom: 0; +} + +.admin-notification-priority { + color: #cf1322; + font-size: 12px; +} + +.admin-notification-time { + font-size: 12px; +} + .admin-user-copy { display: grid; justify-items: end; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index d4b2e938..b95a9653 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -106,6 +106,24 @@ export type WorkerFinanceConfig = { } } +export type WorkerPlatformNotificationEventKey = + | 'withdraw_requested' + | 'recharge_requested' + | 'acceptance_submitted' + | 'acceptance_reminded' + | 'material_required' + +export type WorkerPlatformNotificationConfig = { + events: Record< + WorkerPlatformNotificationEventKey, + { + todoEnabled: boolean + soundEnabled: boolean + externalPushEnabled: boolean + } + > +} + export type WorkerWithdrawalAccount = { accountChannel: string accountName: string