diff --git a/apps/backend/src/db/migrations/022_work_order_pinned_at.sql b/apps/backend/src/db/migrations/022_work_order_pinned_at.sql new file mode 100644 index 00000000..8c767733 --- /dev/null +++ b/apps/backend/src/db/migrations/022_work_order_pinned_at.sql @@ -0,0 +1,7 @@ +-- 022_work_order_pinned_at.sql —— 工单置顶。 +-- +-- 已发布到抢单大厅(待抢单)的工单可由管理员置顶,置顶后按置顶时间在抢单大厅优先展示。 + +ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN work_orders.pinned_at IS '置顶时间;非空表示该工单在抢单大厅置顶展示'; \ No newline at end of file diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 97256d99..ab335998 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -177,6 +177,7 @@ export type WorkOrderRow = { sharing_total_quantity: number sharing_unit_reward: number published_at: string | null + pinned_at: string | null assigned_at: string | null submitted_at: string | null accepted_at: string | null @@ -218,6 +219,8 @@ export type ListInput = { categoryId?: number workerSharingId?: number visibleAfterIso?: string + /** 置顶订单优先展示(抢单大厅使用) */ + pinnedFirst?: boolean sort?: 'id_desc' | 'worker_claimed_at_desc' } 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 e4c94fc7..f3881cfd 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -744,6 +744,7 @@ export async function listWorkOrders({ categoryId = 0, workerSharingId = 0, visibleAfterIso = '', + pinnedFirst = false, sort = 'id_desc', }: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { const effectiveStatuses = @@ -786,11 +787,14 @@ export async function listWorkOrders({ ) DESC NULLS LAST, wo.id DESC` })() : 'wo.id DESC' + const effectiveOrderBy = pinnedFirst + ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` + : orderBy params.push(pageSize, offset) const itemsResult = await query( `${WORK_ORDER_SELECT} ${whereClause} - ORDER BY ${orderBy} + ORDER BY ${effectiveOrderBy} LIMIT $${params.length - 1} OFFSET $${params.length}`, params, ) @@ -813,6 +817,7 @@ export async function updateWorkOrder( | 'sharing_total_quantity' | 'sharing_unit_reward' | 'published_at' + | 'pinned_at' | 'assigned_at' | 'submitted_at' | 'accepted_at' @@ -838,11 +843,12 @@ export async function updateWorkOrder( sharing_total_quantity = $9, sharing_unit_reward = $10, published_at = $11, - assigned_at = $12, - submitted_at = $13, - accepted_at = $14, - updated_at = $15 - WHERE id = $16 + pinned_at = $12, + assigned_at = $13, + submitted_at = $14, + accepted_at = $15, + updated_at = $16 + WHERE id = $17 `, [ next.status, @@ -856,6 +862,7 @@ export async function updateWorkOrder( toPositiveInteger(next.sharing_total_quantity, 1), toPositiveInteger(next.sharing_unit_reward, 0), next.published_at || null, + next.pinned_at || null, next.assigned_at || null, next.submitted_at || null, next.accepted_at || null, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index d2f8528b..a5cbc4b4 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -23,6 +23,7 @@ import { listAdminWorkerUsers, listAdminWorkOrders, markAdminWorkOrderProblem, + pinAdminWorkOrder, publishAdminWorkOrder, reviewAdminWorkerFinanceRequest, resolveAdminProblemWorkOrder, @@ -450,6 +451,25 @@ router.post( }), ) +router.post( + '/worker-platform/orders/:workOrderId/pin', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => pinAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}), + { + successMessage: '置顶状态已更新', + errorMessage: '更新置顶状态失败', + scope: '[admin/worker-platform/orders/:workOrderId/pin]', + audit: (req, data) => ({ + action: 'work_order_pin_toggled', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.get( '/worker-platform/orders/:workOrderId/sharing', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index cb124423..30aaca94 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -1083,6 +1083,32 @@ export async function unpublishAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated || workOrder) } } +export async function pinAdminWorkOrder(workOrderId: number | string, payload: JsonObject = {}) { + const workOrder = await getRequiredWorkOrder(workOrderId) + const pinned = payload.pinned === true + if (pinned && workOrder.status !== WORK_ORDER_STATUS.OPEN) { + throw createHttpError('只有待抢单订单可以置顶', { + statusCode: 409, + errorCode: 'work_order_pin_status_invalid', + }) + } + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + pinned_at: pinned ? now : null, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: pinned ? 'pinned' : 'unpinned', + fromStatus: workOrder.status, + toStatus: workOrder.status, + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + export async function markAdminWorkOrderProblem( workOrderId: 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 c13a9d0c..1e6be415 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -404,6 +404,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { } : null, publishedAt: workOrder.published_at, + pinnedAt: workOrder.pinned_at, assignedAt: workOrder.assigned_at, submittedAt: workOrder.submitted_at, acceptedAt: workOrder.accepted_at, diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index e9c3422a..56d8310d 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -824,6 +824,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work keyword: String(query.keyword || '').trim(), categoryId: categoryId || 0, visibleAfterIso, + pinnedFirst: true, }) const categories = (await listWorkCategories()).map(mapWorkCategory) const myShares = await listWorkerSharesByWorker(worker.id) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index a751a6ef..3c24634d 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -4,6 +4,7 @@ import { EditOutlined, FormOutlined, PlusOutlined, + PushpinOutlined, ReloadOutlined, SearchOutlined, SendOutlined, @@ -63,6 +64,7 @@ import { fetchAdminWorkerUsers, fetchAdminWorkOrders, markAdminWorkOrderProblem, + pinAdminWorkOrder, publishAdminWorkOrder, reviewAdminWorkerFinanceRequest, resolveAdminProblemWorkOrder, @@ -693,11 +695,16 @@ export default function WorkOrdersPanel() { }, { title: '状态', - width: 130, + width: 150, render: (_, row) => ( - - {formatStatus(row.status)} - + + + {formatStatus(row.status)} + + {row.status === 'open' && row.pinnedAt ? ( + }>置顶 + ) : null} + ), }, { @@ -795,6 +802,20 @@ export default function WorkOrdersPanel() { 发布 ) : null} + {row.status === 'open' ? ( + + ) : null} {row.status === 'open' ? (