From 112a3272d2ea900aaebafb529e53c782c460e553 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 15 Aug 2026 14:22:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=89=93=E6=89=8B?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/020_worker_order_notes.sql | 12 ++++ .../021_worker_order_notes_by_worker.sql | 14 ++++ .../worker-platform/work-order-repo.ts | 44 +++++++++++++ apps/backend/src/routes/worker.ts | 19 ++++++ .../src/services/worker-platform/mappers.ts | 2 + .../worker-platform/worker-service.ts | 59 ++++++++++++++++- .../src/pages/worker/WorkerOrdersPage.tsx | 65 ++++++++++++++++++- apps/frontend/src/services/worker.ts | 7 ++ apps/frontend/src/types/worker-platform.ts | 1 + 9 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/db/migrations/020_worker_order_notes.sql create mode 100644 apps/backend/src/db/migrations/021_worker_order_notes_by_worker.sql diff --git a/apps/backend/src/db/migrations/020_worker_order_notes.sql b/apps/backend/src/db/migrations/020_worker_order_notes.sql new file mode 100644 index 00000000..b824da36 --- /dev/null +++ b/apps/backend/src/db/migrations/020_worker_order_notes.sql @@ -0,0 +1,12 @@ +-- 020_worker_order_notes.sql —— 打手工单备注。 +-- +-- 整单与拼单份额分别保存,确保拼单打手只能查看和修改自己的备注。 + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS worker_note TEXT NOT NULL DEFAULT ''; + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS worker_note TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN work_orders.worker_note IS '整单打手维护的个人备注'; +COMMENT ON COLUMN work_order_shares.worker_note IS '拼单打手维护的个人备注'; diff --git a/apps/backend/src/db/migrations/021_worker_order_notes_by_worker.sql b/apps/backend/src/db/migrations/021_worker_order_notes_by_worker.sql new file mode 100644 index 00000000..5e6c2fe1 --- /dev/null +++ b/apps/backend/src/db/migrations/021_worker_order_notes_by_worker.sql @@ -0,0 +1,14 @@ +-- 021_worker_order_notes_by_worker.sql —— 按打手保存工单备注。 +-- +-- 工单可重新分配,拼单也有多个打手;备注必须按工单和打手独立存储。 + +CREATE TABLE IF NOT EXISTS worker_work_order_notes ( + 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, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (work_order_id, worker_id) +); + +COMMENT ON TABLE worker_work_order_notes IS '打手维护的个人工单备注'; 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 0d57517e..ed1cc0e3 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -384,6 +384,50 @@ export async function updateWorkOrderShareAcceptanceRecord(input: { return getWorkOrderShareById(share.id) } +export async function listWorkerWorkOrderNotes( + workerId: number | string, + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return [] + const result = await query<{ work_order_id: number; note: string }>( + ` + SELECT work_order_id, note + FROM worker_work_order_notes + WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) + `, + [Number(workerId), workOrderIds], + ) + return result.rows +} + +export async function upsertWorkerWorkOrderNote(input: { + workOrderId: number + workerId: number + note: string + now: string +}): Promise { + const result = await query<{ note: string }>( + ` + INSERT INTO worker_work_order_notes ( + work_order_id, worker_id, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $4) + ON CONFLICT (work_order_id, worker_id) DO UPDATE + SET note = EXCLUDED.note, updated_at = EXCLUDED.updated_at + RETURNING note + `, + [input.workOrderId, input.workerId, input.note, input.now], + ) + return String(result.rows[0]?.note || '') +} + +async function getWorkOrderShareById(shareId: number): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [shareId], + ) + return result.rows[0] || null +} + export async function getWorkCategoryByKey(categoryKey: string): Promise { const result = await query( 'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1', diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 455ef8cf..afd8a880 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -21,6 +21,7 @@ import { registerWorker, sendWorkerSmsCode, saveWorkerOrderAcceptanceDraft, + saveWorkerOrderNote, submitWorkerOrderAcceptance, } from '../services/worker-platform/index.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' @@ -229,6 +230,24 @@ router.get( }), ) +router.post( + '/orders/:workOrderId/note', + requireActiveWorker, + createRouteHandler( + (req) => + saveWorkerOrderNote( + String(req.params.workOrderId || ''), + req.body || {}, + getRequiredWorkerSession(req), + ), + { + successMessage: '备注已保存', + errorMessage: '保存备注失败', + scope: '[worker/orders/:workOrderId/note]', + }, + ), +) + router.post( '/orders/:workOrderId/submit-acceptance', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index c73bc4ff..052dcd10 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -416,6 +416,7 @@ export function mapWorkOrderForWorker( workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }, myShare?: WorkOrderShareRow | null, + workerNote = '', ) { const mapped = mapWorkOrderAdmin(workOrder) const { @@ -429,6 +430,7 @@ export function mapWorkOrderForWorker( return { ...visibleOrder, freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), + workerNote, myShare: myShare ? mapWorkOrderShare(myShare) : null, } } diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 1cbdfc7a..86e76237 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -48,6 +48,7 @@ import { listWorkerFinanceRequests, listWorkerSharesByWorker, listWorkerWalletLedgers, + listWorkerWorkOrderNotes, listWorkCategories, listWorkOrders, listWorkProductRules, @@ -67,6 +68,7 @@ import { updateWorkOrderDraftAcceptance, updateWorkOrderShareAcceptanceRecord, updateWorkOrderShareDraftAcceptance, + upsertWorkerWorkOrderNote, updateWorkerPassword, updateWorkerUser, upsertWorkCategory, @@ -981,14 +983,69 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker const myShareByOrderId = new Map( myShares.map((share) => [Number(share.work_order_id), share]), ) + const notes = await listWorkerWorkOrderNotes( + worker.id, + items.map((item) => Number(item.id)), + ) + const noteByOrderId = new Map( + notes.map((item) => [Number(item.work_order_id), String(item.note || '')]), + ) return { items: items.map((item) => - mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), + mapWorkOrderForWorker( + item, + permissions, + myShareByOrderId.get(Number(item.id)), + noteByOrderId.get(Number(item.id)) || '', + ), ), pagination: { page, pageSize, total }, } } +export async function saveWorkerOrderNote( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const workOrder = await getRequiredWorkOrder(workOrderId) + const workerId = Number(session.workerId) + const note = String(payload.note || '').trim() + if (note.length > 100) { + throw createHttpError('备注不能超过 100 个字符', { + statusCode: 400, + errorCode: 'work_order_note_too_long', + }) + } + + const worker = await getRequiredWorker(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_note_owner_required', + }) + } + const savedNote = await upsertWorkerWorkOrderNote({ + workOrderId: Number(workOrder.id), + workerId, + note, + now: nowIso(), + }) + return { + order: mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(worker), + sharingShare, + savedNote, + ), + } +} + export async function joinWorkerSharingOrder( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index c34a48d4..fc35c2d4 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,5 +1,6 @@ import { CopyOutlined, + EditOutlined, EyeOutlined, PictureOutlined, ReloadOutlined, @@ -7,6 +8,7 @@ import { } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { + AutoComplete, App, Button, Card, @@ -32,6 +34,7 @@ import { cancelWorkerOrder, fetchWorkerMyOrders, saveWorkerAcceptanceDraft, + saveWorkerOrderNote, submitWorkerAcceptance, } from '@/services/worker' import type { @@ -56,6 +59,10 @@ type AcceptanceFormValues = { note?: string } +type WorkerNoteFormValues = { + note?: string +} + type DetailFieldItem = { key: string label: string @@ -73,8 +80,10 @@ export default function WorkerOrdersPage() { const [pageSize, setPageSize] = useState(20) const [detailOrder, setDetailOrder] = useState(null) const [editingOrder, setEditingOrder] = useState(null) + const [noteOrder, setNoteOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) const [form] = Form.useForm() + const [noteForm] = Form.useForm() const ordersQuery = useQuery({ queryKey: ['worker-my-orders', status, keyword, page, pageSize], @@ -176,6 +185,24 @@ export default function WorkerOrdersPage() { setDetailOrder(null) } + function openNoteModal(order: WorkOrder) { + setNoteOrder(order) + noteForm.setFieldsValue({ note: String(order.workerNote || '') }) + } + + async function saveOrderNote(values: WorkerNoteFormValues) { + if (!noteOrder) return + try { + await saveWorkerOrderNote(noteOrder.workOrderId, String(values.note || '').trim()) + message.success('备注已保存') + setNoteOrder(null) + noteForm.resetFields() + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存备注失败') + } + } + async function submitAcceptance(values: AcceptanceFormValues) { if (!editingOrder) return if (acceptanceFiles.length === 0) { @@ -392,6 +419,23 @@ export default function WorkerOrdersPage() { ) }, }, + { + title: '备注', + width: 190, + render: (_, row) => ( +
+ + {row.workerNote || '未填写'} + + +
+ ), + }, { title: '操作', width: 260, @@ -520,7 +564,7 @@ export default function WorkerOrdersPage() { setPageSize(nextPageSize) }, }} - scroll={{ x: 1060 }} + scroll={{ x: 1440 }} /> @@ -672,6 +716,25 @@ export default function WorkerOrdersPage() { ) : null} + { + setNoteOrder(null) + noteForm.resetFields() + }} + onOk={() => noteForm.submit()} + destroyOnHidden + > +
+ + + + + +
+
+ ) { return apiGet>('/api/v1/worker/orders', params) } +export function saveWorkerOrderNote(workOrderId: number, note: string) { + return apiPost<{ order: WorkOrder }>( + `/api/v1/worker/orders/${workOrderId}/note`, + { note }, + ) +} + export function submitWorkerAcceptance( workOrderId: number, payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 2782fb08..ae55c48b 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -248,6 +248,7 @@ export type WorkOrder = { acceptance: WorkerAcceptance /** 暂存的验收草稿(未提交时保存于此,刷新不丢失;提交后清空) */ draftAcceptance: WorkerAcceptance + workerNote?: string problemNote: string worker: null | { workerId: number