增加工单首次查看标识

This commit is contained in:
yml2213
2026-08-17 20:11:35 +08:00
parent 1b4bdec56d
commit 370acdba60
12 changed files with 228 additions and 18 deletions
@@ -0,0 +1,14 @@
-- 032_worker_work_order_views.sql —— 打手个人工单首次查看状态。
CREATE TABLE IF NOT EXISTS worker_work_order_views (
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
viewed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (work_order_id, worker_id)
);
CREATE INDEX IF NOT EXISTS idx_worker_work_order_views_worker_order
ON worker_work_order_views(worker_id, work_order_id);
COMMENT ON TABLE worker_work_order_views IS '打手个人工单首次查看记录,用于展示未读“新”标识';
COMMENT ON COLUMN worker_work_order_views.viewed_at IS '首次点击订单详情、复制资料、编辑备注或验收时写入';
@@ -472,6 +472,51 @@ export async function upsertWorkerWorkOrderNote(input: {
return String(result.rows[0]?.note || '') return String(result.rows[0]?.note || '')
} }
/** 查询打手已查看过的工单,用于计算“新”标识。 */
export async function listWorkerWorkOrderViews(
workerId: number | string,
workOrderIds: number[],
): Promise<Array<{ work_order_id: number; viewed_at: string }>> {
if (workOrderIds.length === 0) return []
const result = await query<{ work_order_id: number; viewed_at: string }>(
`
SELECT work_order_id, viewed_at
FROM worker_work_order_views
WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[])
`,
[Number(workerId), workOrderIds],
)
return result.rows
}
/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */
export async function markWorkerWorkOrderViewed(input: {
workOrderId: number
workerId: number
now: string
}): Promise<string> {
const inserted = await query<{ viewed_at: string }>(
`
INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at)
VALUES ($1, $2, $3)
ON CONFLICT (work_order_id, worker_id) DO NOTHING
RETURNING viewed_at
`,
[input.workOrderId, input.workerId, input.now],
)
if (inserted.rows[0]?.viewed_at) return String(inserted.rows[0].viewed_at)
const existing = await query<{ viewed_at: string }>(
`
SELECT viewed_at
FROM worker_work_order_views
WHERE work_order_id = $1 AND worker_id = $2
LIMIT 1
`,
[input.workOrderId, input.workerId],
)
return String(existing.rows[0]?.viewed_at || input.now)
}
async function getWorkOrderShareById(shareId: number): Promise<WorkOrderShareRow | null> { async function getWorkOrderShareById(shareId: number): Promise<WorkOrderShareRow | null> {
const result = await query<WorkOrderShareRow>( const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
+15
View File
@@ -18,6 +18,7 @@ import {
listWorkerProfileWalletLedgers, listWorkerProfileWalletLedgers,
listWorkerHallOrders, listWorkerHallOrders,
listWorkerMyOrders, listWorkerMyOrders,
markWorkerOrderRead,
getWorkerMyOrderEvents, getWorkerMyOrderEvents,
loginWorker, loginWorker,
getWorkerSessions, getWorkerSessions,
@@ -376,6 +377,20 @@ router.get(
), ),
) )
router.post(
'/orders/:workOrderId/read',
requireActiveWorker,
createRouteHandler(
(req) =>
markWorkerOrderRead(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)),
{
successMessage: '订单已标记为已读',
errorMessage: '标记订单已读失败',
scope: '[worker/orders/:workOrderId/read]',
},
),
)
router.post( router.post(
'/orders/:workOrderId/note', '/orders/:workOrderId/note',
requireActiveWorker, requireActiveWorker,
@@ -5,6 +5,7 @@ import {
getWorkerUserById, getWorkerUserById,
getWorkOrderById, getWorkOrderById,
listWorkerWorkOrderNotes, listWorkerWorkOrderNotes,
listWorkerWorkOrderViews,
listWorkOrderShares, listWorkOrderShares,
sumPendingUnfreezeByOrderIds, sumPendingUnfreezeByOrderIds,
} from '../../repositories/worker-platform/index.js' } from '../../repositories/worker-platform/index.js'
@@ -283,15 +284,26 @@ async function publishWorkOrderSnapshots(input: {
)) { )) {
const worker = await getWorkerUserById(workerId) const worker = await getWorkerUserById(workerId)
if (!worker) continue if (!worker) continue
const notes = await listWorkerWorkOrderNotes(workerId, [workOrderId]) const [notes, views] = await Promise.all([
listWorkerWorkOrderNotes(workerId, [workOrderId]),
listWorkerWorkOrderViews(workerId, [workOrderId]),
])
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
const viewedAt =
views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null
const share = shares.find((item) => Number(item.worker_id) === workerId) const share = shares.find((item) => Number(item.worker_id) === workerId)
publishRealtimeEvent({ publishRealtimeEvent({
type: 'work_order.changed', type: 'work_order.changed',
entityId: workOrderId, entityId: workOrderId,
scopes: ['worker_my_orders'], scopes: ['worker_my_orders'],
operation: 'upsert', operation: 'upsert',
data: mapWorkOrderForWorker(workOrder, resolveWorkerPermissions(worker), share, note), data: mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(worker),
share,
note,
viewedAt,
),
workerIds: [workerId], workerIds: [workerId],
}) })
} }
@@ -365,6 +365,7 @@ export function mapWorkOrderForWorker(
permissions: { depositFreeAmount: number }, permissions: { depositFreeAmount: number },
myShare?: WorkOrderShareRow | null, myShare?: WorkOrderShareRow | null,
workerNote = '', workerNote = '',
viewedAt?: string | null,
) { ) {
const mapped = mapWorkOrderAdmin(workOrder) const mapped = mapWorkOrderAdmin(workOrder)
const { const {
@@ -380,6 +381,7 @@ export function mapWorkOrderForWorker(
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
workerNote, workerNote,
myShare: myShare ? mapWorkOrderShare(myShare) : null, myShare: myShare ? mapWorkOrderShare(myShare) : null,
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
} }
} }
@@ -8,6 +8,7 @@ import {
isWorkerPasswordStrong, isWorkerPasswordStrong,
normalizeWithdrawChannel, normalizeWithdrawChannel,
normalizeRequirementFields, normalizeRequirementFields,
mapWorkOrderForWorker,
resolveCollectSubmitTargetWorkOrder, resolveCollectSubmitTargetWorkOrder,
resolveWorkerPermissions, resolveWorkerPermissions,
resolveSkuNameQuantity, resolveSkuNameQuantity,
@@ -197,6 +198,17 @@ function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow
} }
} }
test('mapWorkOrderForWorker reflects the personal first-view state', () => {
const workOrder = buildWorkOrderRow({ assigned_worker_id: 1 })
const permissions = resolveWorkerPermissions(buildWorkerUserRow())
assert.equal(mapWorkOrderForWorker(workOrder, permissions, null, '', null).isUnread, true)
assert.equal(
mapWorkOrderForWorker(workOrder, permissions, null, '', '2026-08-17T00:00:00.000Z').isUnread,
false,
)
assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined)
})
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => { test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
const workOrder = buildWorkOrderRow() const workOrder = buildWorkOrderRow()
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '') const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
@@ -43,6 +43,7 @@ import {
listWorkerSharesByWorker, listWorkerSharesByWorker,
listWorkerWalletLedgers, listWorkerWalletLedgers,
listWorkerWorkOrderNotes, listWorkerWorkOrderNotes,
listWorkerWorkOrderViews,
listWorkerWithdrawalAccounts, listWorkerWithdrawalAccounts,
listWorkCategories, listWorkCategories,
listWorkOrders, listWorkOrders,
@@ -60,6 +61,7 @@ import {
updateWorkOrderShareAcceptanceRecord, updateWorkOrderShareAcceptanceRecord,
updateWorkOrderShareDraftAcceptance, updateWorkOrderShareDraftAcceptance,
upsertWorkerWorkOrderNote, upsertWorkerWorkOrderNote,
markWorkerWorkOrderViewed,
updateWorkerPassword, updateWorkerPassword,
upsertWorkCategory, upsertWorkCategory,
upsertWorkerLevel, upsertWorkerLevel,
@@ -1335,13 +1337,17 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const permissions = resolveWorkerPermissions(worker) const permissions = resolveWorkerPermissions(worker)
const myShares = await listWorkerSharesByWorker(worker.id) const myShares = await listWorkerSharesByWorker(worker.id)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share])) const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const notes = await listWorkerWorkOrderNotes( const workOrderIds = items.map((item) => Number(item.id))
worker.id, const [notes, views] = await Promise.all([
items.map((item) => Number(item.id)), listWorkerWorkOrderNotes(worker.id, workOrderIds),
) listWorkerWorkOrderViews(worker.id, workOrderIds),
])
const noteByOrderId = new Map( const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]), notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
) )
const viewedAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.viewed_at || '')]),
)
return { return {
items: items.map((item) => items: items.map((item) =>
mapWorkOrderForWorker( mapWorkOrderForWorker(
@@ -1349,6 +1355,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
permissions, permissions,
myShareByOrderId.get(Number(item.id)), myShareByOrderId.get(Number(item.id)),
noteByOrderId.get(Number(item.id)) || '', noteByOrderId.get(Number(item.id)) || '',
viewedAtByOrderId.get(Number(item.id)) || null,
), ),
), ),
pagination: { page, pageSize, total }, pagination: { page, pageSize, total },
@@ -1456,6 +1463,34 @@ export async function saveWorkerOrderNote(
} }
} }
/** 标记打手本人已查看工单,首次操作后不再展示“新”标识。 */
export async function markWorkerOrderRead(workOrderId: number | string, session: WorkerSession) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
const isOrderOwner =
Number(workOrder.assigned_worker_id || 0) === workerId ||
Number(workOrder.last_assigned_worker_id || 0) === workerId
if (!sharingShare && !isOrderOwner) {
throw createHttpError('只能标记自己的订单为已读', {
statusCode: 403,
errorCode: 'work_order_read_owner_required',
})
}
const viewedAt = await markWorkerWorkOrderViewed({
workOrderId: Number(workOrder.id),
workerId,
now: nowIso(),
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [workerId],
})
return { viewedAt, isUnread: false }
}
export async function joinWorkerSharingOrder( export async function joinWorkerSharingOrder(
workOrderId: number | string, workOrderId: number | string,
payload: JsonObject = {}, payload: JsonObject = {},
+14 -2
View File
@@ -269,12 +269,24 @@ function applyWorkerDepositRule(queryClient: ReturnType<typeof useQueryClient>,
} }
function matchesWorkOrderKeyword( function matchesWorkOrderKeyword(
item: { productName?: unknown; platformOrderId?: unknown }, item: { productName?: unknown; platformOrderId?: unknown; material?: unknown },
keyword: string, keyword: string,
) { ) {
const normalizedKeyword = keyword.trim().toLowerCase() const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return true if (!normalizedKeyword) return true
return [item.productName, item.platformOrderId] const material = isRecord(item.material) ? item.material : {}
const collect = isRecord(material.collect) ? material.collect : {}
const fields = isRecord(collect.fields) ? collect.fields : {}
return [
item.productName,
item.platformOrderId,
fields.gameId,
fields.gameNickname,
fields.gameAccount,
fields.roleName,
material.gameId,
material.gameNickname,
]
.map((value) => String(value || '').toLowerCase()) .map((value) => String(value || '').toLowerCase())
.some((value) => value.includes(normalizedKeyword)) .some((value) => value.includes(normalizedKeyword))
} }
@@ -37,6 +37,7 @@ import {
fetchWorkerMyOrderOverview, fetchWorkerMyOrderOverview,
fetchWorkerMyOrders, fetchWorkerMyOrders,
fetchWorkerProfile, fetchWorkerProfile,
markWorkerOrderRead,
remindWorkerAcceptance, remindWorkerAcceptance,
saveWorkerAcceptanceDraft, saveWorkerAcceptanceDraft,
saveWorkerOrderNote, saveWorkerOrderNote,
@@ -231,7 +232,23 @@ export default function WorkerOrdersPage() {
]) ])
} }
function markOrderAsRead(order: WorkOrder) {
if (!order.isUnread) return
queryClient.setQueriesData({ queryKey: ['worker-my-orders'] }, (current: unknown) =>
patchWorkOrderReadState(current, order.workOrderId),
)
void markWorkerOrderRead(order.workOrderId).catch(() => {
void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
})
}
function openOrderDetail(order: WorkOrder) {
markOrderAsRead(order)
setDetailOrder(order)
}
function copyOrderInfo(order: WorkOrder) { function copyOrderInfo(order: WorkOrder) {
markOrderAsRead(order)
const items = getCopyOrderInfoItems(order) const items = getCopyOrderInfoItems(order)
if (items.length === 0) { if (items.length === 0) {
message.warning('该订单暂无可复制信息') message.warning('该订单暂无可复制信息')
@@ -273,6 +290,7 @@ export default function WorkerOrdersPage() {
} }
function openAcceptanceModal(order: WorkOrder) { function openAcceptanceModal(order: WorkOrder) {
markOrderAsRead(order)
setEditingOrder(order) setEditingOrder(order)
// 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容 // 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容
const source = hasSubmittedAcceptance(order) const source = hasSubmittedAcceptance(order)
@@ -284,6 +302,7 @@ export default function WorkerOrdersPage() {
} }
function openNoteModal(order: WorkOrder) { function openNoteModal(order: WorkOrder) {
markOrderAsRead(order)
setNoteOrder(order) setNoteOrder(order)
noteForm.setFieldsValue({ note: String(order.workerNote || '') }) noteForm.setFieldsValue({ note: String(order.workerNote || '') })
} }
@@ -388,14 +407,21 @@ export default function WorkerOrdersPage() {
width: 270, width: 270,
render: (_, row) => ( render: (_, row) => (
<div className="cell-stack"> <div className="cell-stack">
<Space size={4} align="center">
{row.isUnread ? (
<Tag color="red" className="worker-order-unread-tag">
</Tag>
) : null}
<Typography.Link <Typography.Link
strong strong
onClick={() => setDetailOrder(row)} onClick={() => openOrderDetail(row)}
title={row.productName || '未命名商品'} title={row.productName || '未命名商品'}
ellipsis ellipsis
> >
{row.productName || '未命名商品'} {row.productName || '未命名商品'}
</Typography.Link> </Typography.Link>
</Space>
<Space size={[4, 4]} wrap> <Space size={[4, 4]} wrap>
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null} {row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
{row.myShare ? <Tag color="purple"></Tag> : null} {row.myShare ? <Tag color="purple"></Tag> : null}
@@ -596,7 +622,7 @@ export default function WorkerOrdersPage() {
<Button <Button
aria-label="查看详情" aria-label="查看详情"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => setDetailOrder(row)} onClick={() => openOrderDetail(row)}
/> />
</Tooltip> </Tooltip>
{canEditAcceptanceImages(row) ? ( {canEditAcceptanceImages(row) ? (
@@ -724,6 +750,11 @@ export default function WorkerOrdersPage() {
{/* 卡片顶栏:分类 + 订单号 + 状态/倒计时 */} {/* 卡片顶栏:分类 + 订单号 + 状态/倒计时 */}
<div className="worker-order-mobile-header"> <div className="worker-order-mobile-header">
<div className="worker-order-mobile-header-left"> <div className="worker-order-mobile-header-left">
{order.isUnread ? (
<Tag color="red" className="worker-order-unread-tag">
</Tag>
) : null}
{order.categoryName ? ( {order.categoryName ? (
<Tag color="blue" className="worker-order-mobile-tag"> <Tag color="blue" className="worker-order-mobile-tag">
{order.categoryName} {order.categoryName}
@@ -892,7 +923,7 @@ export default function WorkerOrdersPage() {
<Button <Button
size="small" size="small"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => setDetailOrder(order)} onClick={() => openOrderDetail(order)}
> >
</Button> </Button>
@@ -1304,6 +1335,27 @@ export default function WorkerOrdersPage() {
) )
} }
function patchWorkOrderReadState(current: unknown, workOrderId: number) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
return {
...current,
data: {
...current.data,
items: current.data.items.map((item) =>
isRecord(item) && Number(item.workOrderId) === workOrderId
? { ...item, isUnread: false }
: item,
),
},
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function canSubmitAcceptance(order: WorkOrder) { function canSubmitAcceptance(order: WorkOrder) {
if (order.myShare) { if (order.myShare) {
return order.myShare.status === 'joined' && order.status !== 'accepted' return order.myShare.status === 'joined' && order.status !== 'accepted'
+4
View File
@@ -172,6 +172,10 @@ export function fetchWorkerMyOrderEvents(workOrderId: number) {
return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`) return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`)
} }
export function markWorkerOrderRead(workOrderId: number) {
return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`)
}
export function saveWorkerOrderNote(workOrderId: number, note: string) { export function saveWorkerOrderNote(workOrderId: number, note: string) {
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note }) return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note })
} }
+5
View File
@@ -1008,6 +1008,11 @@ body {
line-height: 18px; line-height: 18px;
} }
.worker-order-unread-tag {
margin: 0;
font-weight: 700;
}
.worker-order-mobile-orderno-text { .worker-order-mobile-orderno-text {
font-size: 12px; font-size: 12px;
color: #64748b; color: #64748b;
@@ -309,6 +309,8 @@ export type WorkOrder = {
/** 暂存的验收草稿(未提交时保存于此,刷新不丢失;提交后清空) */ /** 暂存的验收草稿(未提交时保存于此,刷新不丢失;提交后清空) */
draftAcceptance: WorkerAcceptance draftAcceptance: WorkerAcceptance
workerNote?: string workerNote?: string
/** 打手本人尚未点击查看或操作过该工单。 */
isUnread?: boolean
problemNote: string problemNote: string
worker: null | { worker: null | {
workerId: number workerId: number