接单工单支持置顶,抢单大厅优先展示

- 新增 work_orders.pinned_at 字段与迁移
- 管理端对已发布(待抢单)订单提供置顶/取消置顶操作,置顶订单带标记
- 抢单大厅按置顶时间优先排序,打手端卡片展示置顶标签
This commit is contained in:
yml2213
2026-08-16 15:57:25 +08:00
parent 737f9ddcbe
commit 8d07b06d52
12 changed files with 121 additions and 11 deletions
@@ -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 '置顶时间;非空表示该工单在抢单大厅置顶展示';
@@ -177,6 +177,7 @@ export type WorkOrderRow = {
sharing_total_quantity: number sharing_total_quantity: number
sharing_unit_reward: number sharing_unit_reward: number
published_at: string | null published_at: string | null
pinned_at: string | null
assigned_at: string | null assigned_at: string | null
submitted_at: string | null submitted_at: string | null
accepted_at: string | null accepted_at: string | null
@@ -218,6 +219,8 @@ export type ListInput = {
categoryId?: number categoryId?: number
workerSharingId?: number workerSharingId?: number
visibleAfterIso?: string visibleAfterIso?: string
/** 置顶订单优先展示(抢单大厅使用) */
pinnedFirst?: boolean
sort?: 'id_desc' | 'worker_claimed_at_desc' sort?: 'id_desc' | 'worker_claimed_at_desc'
} }
@@ -744,6 +744,7 @@ export async function listWorkOrders({
categoryId = 0, categoryId = 0,
workerSharingId = 0, workerSharingId = 0,
visibleAfterIso = '', visibleAfterIso = '',
pinnedFirst = false,
sort = 'id_desc', sort = 'id_desc',
}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { }: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> {
const effectiveStatuses = const effectiveStatuses =
@@ -786,11 +787,14 @@ export async function listWorkOrders({
) DESC NULLS LAST, wo.id DESC` ) DESC NULLS LAST, wo.id DESC`
})() })()
: 'wo.id DESC' : 'wo.id DESC'
const effectiveOrderBy = pinnedFirst
? `wo.pinned_at DESC NULLS LAST, ${orderBy}`
: orderBy
params.push(pageSize, offset) params.push(pageSize, offset)
const itemsResult = await query<WorkOrderRow>( const itemsResult = await query<WorkOrderRow>(
`${WORK_ORDER_SELECT} `${WORK_ORDER_SELECT}
${whereClause} ${whereClause}
ORDER BY ${orderBy} ORDER BY ${effectiveOrderBy}
LIMIT $${params.length - 1} OFFSET $${params.length}`, LIMIT $${params.length - 1} OFFSET $${params.length}`,
params, params,
) )
@@ -813,6 +817,7 @@ export async function updateWorkOrder(
| 'sharing_total_quantity' | 'sharing_total_quantity'
| 'sharing_unit_reward' | 'sharing_unit_reward'
| 'published_at' | 'published_at'
| 'pinned_at'
| 'assigned_at' | 'assigned_at'
| 'submitted_at' | 'submitted_at'
| 'accepted_at' | 'accepted_at'
@@ -838,11 +843,12 @@ export async function updateWorkOrder(
sharing_total_quantity = $9, sharing_total_quantity = $9,
sharing_unit_reward = $10, sharing_unit_reward = $10,
published_at = $11, published_at = $11,
assigned_at = $12, pinned_at = $12,
submitted_at = $13, assigned_at = $13,
accepted_at = $14, submitted_at = $14,
updated_at = $15 accepted_at = $15,
WHERE id = $16 updated_at = $16
WHERE id = $17
`, `,
[ [
next.status, next.status,
@@ -856,6 +862,7 @@ export async function updateWorkOrder(
toPositiveInteger(next.sharing_total_quantity, 1), toPositiveInteger(next.sharing_total_quantity, 1),
toPositiveInteger(next.sharing_unit_reward, 0), toPositiveInteger(next.sharing_unit_reward, 0),
next.published_at || null, next.published_at || null,
next.pinned_at || null,
next.assigned_at || null, next.assigned_at || null,
next.submitted_at || null, next.submitted_at || null,
next.accepted_at || null, next.accepted_at || null,
@@ -23,6 +23,7 @@ import {
listAdminWorkerUsers, listAdminWorkerUsers,
listAdminWorkOrders, listAdminWorkOrders,
markAdminWorkOrderProblem, markAdminWorkOrderProblem,
pinAdminWorkOrder,
publishAdminWorkOrder, publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest, reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder, 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<string, unknown>) : {},
}),
},
),
)
router.get( router.get(
'/worker-platform/orders/:workOrderId/sharing', '/worker-platform/orders/:workOrderId/sharing',
requireAdminRoles(['admin', 'operator', 'support']), requireAdminRoles(['admin', 'operator', 'support']),
@@ -1083,6 +1083,32 @@ export async function unpublishAdminWorkOrder(workOrderId: number | string) {
return { order: mapWorkOrderAdmin(updated || workOrder) } 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( export async function markAdminWorkOrderProblem(
workOrderId: number | string, workOrderId: number | string,
payload: JsonObject = {}, payload: JsonObject = {},
@@ -404,6 +404,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
} }
: null, : null,
publishedAt: workOrder.published_at, publishedAt: workOrder.published_at,
pinnedAt: workOrder.pinned_at,
assignedAt: workOrder.assigned_at, assignedAt: workOrder.assigned_at,
submittedAt: workOrder.submitted_at, submittedAt: workOrder.submitted_at,
acceptedAt: workOrder.accepted_at, acceptedAt: workOrder.accepted_at,
@@ -824,6 +824,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
keyword: String(query.keyword || '').trim(), keyword: String(query.keyword || '').trim(),
categoryId: categoryId || 0, categoryId: categoryId || 0,
visibleAfterIso, visibleAfterIso,
pinnedFirst: true,
}) })
const categories = (await listWorkCategories()).map(mapWorkCategory) const categories = (await listWorkCategories()).map(mapWorkCategory)
const myShares = await listWorkerSharesByWorker(worker.id) const myShares = await listWorkerSharesByWorker(worker.id)
@@ -4,6 +4,7 @@ import {
EditOutlined, EditOutlined,
FormOutlined, FormOutlined,
PlusOutlined, PlusOutlined,
PushpinOutlined,
ReloadOutlined, ReloadOutlined,
SearchOutlined, SearchOutlined,
SendOutlined, SendOutlined,
@@ -63,6 +64,7 @@ import {
fetchAdminWorkerUsers, fetchAdminWorkerUsers,
fetchAdminWorkOrders, fetchAdminWorkOrders,
markAdminWorkOrderProblem, markAdminWorkOrderProblem,
pinAdminWorkOrder,
publishAdminWorkOrder, publishAdminWorkOrder,
reviewAdminWorkerFinanceRequest, reviewAdminWorkerFinanceRequest,
resolveAdminProblemWorkOrder, resolveAdminProblemWorkOrder,
@@ -693,11 +695,16 @@ export default function WorkOrdersPanel() {
}, },
{ {
title: '状态', title: '状态',
width: 130, width: 150,
render: (_, row) => ( render: (_, row) => (
<Tag color={resolveStatusColor(row.status)}> <Space wrap size={4}>
{formatStatus(row.status)} <Tag color={resolveStatusColor(row.status)}>
</Tag> {formatStatus(row.status)}
</Tag>
{row.status === 'open' && row.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />}></Tag>
) : null}
</Space>
), ),
}, },
{ {
@@ -795,6 +802,20 @@ export default function WorkOrdersPanel() {
</Button> </Button>
) : null} ) : null}
{row.status === 'open' ? (
<Button
icon={<PushpinOutlined />}
title={row.pinnedAt ? '取消置顶' : '置顶后抢单大厅优先展示'}
onClick={() =>
runAction(
() => pinAdminWorkOrder(row.workOrderId, !row.pinnedAt),
row.pinnedAt ? '已取消置顶' : '已置顶,抢单大厅优先展示',
)
}
>
{row.pinnedAt ? '取消置顶' : '置顶'}
</Button>
) : null}
{row.status === 'open' ? ( {row.status === 'open' ? (
<Button <Button
danger danger
@@ -1,4 +1,4 @@
import { ClockCircleOutlined, ReloadOutlined, SearchOutlined, TrophyOutlined } from '@ant-design/icons' import { ClockCircleOutlined, PushpinOutlined, ReloadOutlined, SearchOutlined, TrophyOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
Alert, Alert,
@@ -367,6 +367,11 @@ export default function WorkerHallPage() {
{/* 卡片头部: 分类 + 拼单/时限标签 + 订单号 */} {/* 卡片头部: 分类 + 拼单/时限标签 + 订单号 */}
<div className="worker-hall-mobile-card-top"> <div className="worker-hall-mobile-card-top">
<div className="worker-hall-mobile-card-tags"> <div className="worker-hall-mobile-card-tags">
{order.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />} className="worker-hall-mobile-tag">
</Tag>
) : null}
{order.categoryName ? ( {order.categoryName ? (
<Tag color="blue" className="worker-hall-mobile-tag"> <Tag color="blue" className="worker-hall-mobile-tag">
{order.categoryName} {order.categoryName}
@@ -610,6 +615,11 @@ export default function WorkerHallPage() {
return ( return (
<article key={order.workOrderId} className="worker-hall-card"> <article key={order.workOrderId} className="worker-hall-card">
<div className="worker-hall-card-title"> <div className="worker-hall-card-title">
{order.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />} className="worker-hall-pinned-tag">
</Tag>
) : null}
<strong <strong
className="worker-hall-card-title-text" className="worker-hall-card-title-text"
title={order.productName || '未命名商品'} title={order.productName || '未命名商品'}
@@ -283,6 +283,13 @@ export function unpublishAdminWorkOrder(workOrderId: number) {
) )
} }
export function pinAdminWorkOrder(workOrderId: number, pinned: boolean) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/pin`,
{ pinned },
)
}
export function fetchAdminWorkOrderSharing(workOrderId: number) { export function fetchAdminWorkOrderSharing(workOrderId: number) {
return apiGet<{ order: WorkOrder; shares: WorkOrderShare[] }>( return apiGet<{ order: WorkOrder; shares: WorkOrderShare[] }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing`, `/api/v1/admin/worker-platform/orders/${workOrderId}/sharing`,
+5
View File
@@ -580,6 +580,11 @@
min-width: 0; min-width: 0;
} }
.worker-hall-pinned-tag {
width: fit-content;
margin: 0 0 2px;
}
.worker-hall-card-title .ant-typography { .worker-hall-card-title .ant-typography {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
@@ -260,6 +260,8 @@ export type WorkOrder = {
displayName: string displayName: string
} }
publishedAt: string | null publishedAt: string | null
/** 置顶时间;非空表示该工单在抢单大厅置顶展示 */
pinnedAt?: string | null
assignedAt: string | null assignedAt: string | null
submittedAt: string | null submittedAt: string | null
acceptedAt: string | null acceptedAt: string | null