From a7a8634a96d85f415e578e0bd66f94adae64539a Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 25 Oct 2026 12:27:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=8A=A0=E4=B8=80=E4=B8=AA=E7=BD=91?= =?UTF-8?q?=E7=AB=99=E5=8F=91=E5=B8=83=E5=85=AC=E5=91=8A=E4=BC=9A=E5=BC=B9?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E7=82=B9=E7=A1=AE=E5=AE=9A=E6=88=91=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E6=99=93=E7=9A=84=E5=BC=B9=E7=AA=97;mock=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF;=E6=9C=AC=E5=9C=B0=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-mock-work-order-service.ts | 14 +++ .../mock-work-order-rule-match.test.ts | 99 +++++++++++++++++++ .../mock-work-order-rule-match.ts | 76 ++++++++++++++ .../worker/WorkerAnnouncementPopup.tsx | 76 ++++++++++++++ apps/frontend/src/layouts/WorkerLayout.tsx | 3 + .../pages/admin/panels/AnnouncementPanel.tsx | 2 +- apps/frontend/src/styles/worker.css | 18 ++++ apps/frontend/src/utils/announcement-ack.ts | 60 +++++++++++ apps/frontend/src/utils/camera-capture.ts | 2 +- apps/frontend/tests/announcement-ack.test.ts | 54 ++++++++++ deploy/caddy/Caddyfile.dev | 3 +- docker-compose.dev.yml | 2 - 12 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts create mode 100644 apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts create mode 100644 apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx create mode 100644 apps/frontend/src/utils/announcement-ack.ts create mode 100644 apps/frontend/tests/announcement-ack.test.ts diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index 1a48e5ec..25fc980d 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -9,6 +9,7 @@ import { createWorkOrderEvent, getWorkCategoryById, listWorkOrdersByPlatformOrderId, + listWorkProductRules, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -27,6 +28,7 @@ import { normalizeRequirementFields, } from './mappers.js' import { rememberAdminMockCustomShop } from './admin-mock-custom-shop-service.js' +import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -64,6 +66,14 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' const fields = normalizeRequirementFields(payload.fields) const { platformOrderId, productName, shop } = input + // Mock 工单同样要挂到接单模板上:后台「同步模板价格」按 product_rule_id 精确匹配, + // 不绑定的话改模板价永远命中不到 Mock 单(真实单在 sync-worker-orders-service 已绑定)。 + // 这里只做商品名文本匹配,命中多条同分视为歧义、宁可不绑定。 + const productRuleMatch = resolveMockWorkOrderProductRule({ + rules: await listWorkProductRules({ enabled: true }), + productName, + categoryId, + }) // 订单号全局唯一:与自动同步的真实工单也不能撞号,否则打手按单号回填资料会命中多条。 const existingOrders = await listWorkOrdersByPlatformOrderId(platformOrderId) if (existingOrders.length > 0) { @@ -104,6 +114,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { } const workOrder = await createWorkOrder({ workOrderNo: generateWorkOrderNo(), + productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : null, platformOrderId, productName, categoryId, @@ -143,6 +154,9 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { rewardAmount, sellerId: String(shop.sellerId || '').trim(), shopName: String(shop.shopName || '').trim(), + productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : 0, + productRuleKey: productRuleMatch.rule ? String(productRuleMatch.rule.rule_key || '') : '', + productRuleMatch: productRuleMatch.reason, }), now, }) diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts new file mode 100644 index 00000000..5a44b115 --- /dev/null +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' +import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js' + +function rule( + input: Partial & { id: number; product_name: string }, +): WorkProductRuleRow { + return { + id: input.id, + rule_key: input.rule_key ?? `rule-${input.id}`, + product_name: input.product_name, + category_id: input.category_id ?? null, + enabled: input.enabled ?? true, + sort_order: input.sort_order ?? 100, + unit_price_fen: input.unit_price_fen ?? 0, + reward_amount: input.reward_amount ?? 0, + sharing_enabled: input.sharing_enabled ?? false, + sharing_total_quantity: input.sharing_total_quantity ?? 1, + sharing_unit_reward: input.sharing_unit_reward ?? 0, + sharing_auto_from_order: input.sharing_auto_from_order ?? false, + timeout_minutes: input.timeout_minutes ?? 0, + timeout_policy: input.timeout_policy ?? 'reopen', + } as WorkProductRuleRow +} + +test('Mock 模板匹配:商品名互相包含即命中(忽略空白)', () => { + const rules = [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, unit_price_fen: 1100 })] + const result = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(result.reason, 'matched') + assert.equal(result.rule?.id, 9) + + const withSpaces = resolveMockWorkOrderProductRule({ + rules, + productName: ' 指挥官 密钥 3个 ', + categoryId: 1, + }) + assert.equal(withSpaces.rule?.id, 9) +}) + +test('Mock 模板匹配:同分类优先,其次更具体的商品名', () => { + const rules = [ + rule({ id: 1, product_name: '指挥官密钥', category_id: 5, sort_order: 1 }), + rule({ id: 2, product_name: '指挥官密钥', category_id: 1, sort_order: 9 }), + rule({ id: 3, product_name: '指挥官密钥-皮肤链接', category_id: 1, sort_order: 9 }), + ] + const base = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(base.rule?.id, 2, '同分类的模板必须优先于其他分类') + + const specific = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥-皮肤链接1个', + categoryId: 1, + }) + assert.equal(specific.rule?.id, 3, '更长的商品名(更具体)优先') +}) + +test('Mock 模板匹配:歧义或未命中不绑定', () => { + const ambiguous = resolveMockWorkOrderProductRule({ + rules: [ + rule({ id: 1, product_name: '指挥官密钥', category_id: 1 }), + rule({ id: 2, product_name: '指挥官密钥', category_id: 1 }), + ], + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(ambiguous.rule, null) + assert.equal(ambiguous.reason, 'ambiguous') + + const unmatched = resolveMockWorkOrderProductRule({ + rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1 })], + productName: '指挥官9999', + categoryId: 1, + }) + assert.equal(unmatched.rule, null) + assert.equal(unmatched.reason, 'unmatched') + + const empty = resolveMockWorkOrderProductRule({ rules: [], productName: ' ', categoryId: 1 }) + assert.equal(empty.reason, 'unmatched') +}) + +test('Mock 模板匹配:停用的模板不参与匹配', () => { + const result = resolveMockWorkOrderProductRule({ + rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, enabled: false })], + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(result.rule, null) + assert.equal(result.reason, 'unmatched') +}) diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts new file mode 100644 index 00000000..12cb44a7 --- /dev/null +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts @@ -0,0 +1,76 @@ +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' + +/** + * Mock 工单的接单模板匹配(文本匹配)。 + * + * 快手真实单走 `resolveMatchingProductRule`(依赖 sellerId/itemId/skuId 等平台字段), + * 而本地 Mock 单只有商品名和分类,所以这里用**商品名文本**做确定性匹配, + * 让 Mock 单也能挂上 `product_rule_id`,从而被「同步模板价格」命中。 + * + * 规则: + * - 只考虑启用中的模板; + * - 模板商品名与 Mock 商品名互相包含即视为命中(忽略大小写/空白); + * - 同分类优先,其次更长的商品名(更具体)优先,最后按 sort_order; + * - 同分类且同长度的多条模板属于歧义,宁可不绑定,避免把 Mock 单挂到错误模板上。 + */ +export type MockWorkOrderRuleMatchReason = 'matched' | 'unmatched' | 'ambiguous' + +export type MockWorkOrderRuleMatch = { + rule: WorkProductRuleRow | null + reason: MockWorkOrderRuleMatchReason + candidates: string[] +} + +export function normalizeMockProductMatchText(value: unknown): string { + return String(value || '') + .replace(/[\s\u3000]+/g, '') + .trim() + .toLowerCase() +} + +export function resolveMockWorkOrderProductRule(input: { + rules: WorkProductRuleRow[] + productName: string + categoryId?: number | null +}): MockWorkOrderRuleMatch { + const productName = normalizeMockProductMatchText(input.productName) + if (!productName) return { rule: null, reason: 'unmatched', candidates: [] } + const categoryId = Number(input.categoryId || 0) + + const scored = input.rules + .filter((rule) => rule.enabled !== false) + .map((rule) => { + const ruleName = normalizeMockProductMatchText(rule.product_name) + if (!ruleName) return null + if (!productName.includes(ruleName) && !ruleName.includes(productName)) return null + return { + rule, + categoryMatch: categoryId > 0 && Number(rule.category_id || 0) === categoryId ? 1 : 0, + nameLength: ruleName.length, + sortOrder: Number(rule.sort_order || 100), + } + }) + .filter((item): item is NonNullable => item !== null) + + if (scored.length === 0) return { rule: null, reason: 'unmatched', candidates: [] } + + scored.sort( + (a, b) => + b.categoryMatch - a.categoryMatch || + b.nameLength - a.nameLength || + a.sortOrder - b.sortOrder || + Number(a.rule.id) - Number(b.rule.id), + ) + const [best] = scored + if (!best) return { rule: null, reason: 'unmatched', candidates: [] } + const candidates = scored.map((item) => String(item.rule.rule_key || item.rule.id)) + + const equallySpecific = scored.filter( + (item) => item.categoryMatch === best.categoryMatch && item.nameLength === best.nameLength, + ) + if (new Set(equallySpecific.map((item) => Number(item.rule.id))).size > 1) { + return { rule: null, reason: 'ambiguous', candidates } + } + + return { rule: best.rule, reason: 'matched', candidates } +} diff --git a/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx b/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx new file mode 100644 index 00000000..c64d0e2d --- /dev/null +++ b/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx @@ -0,0 +1,76 @@ +import { SoundOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { Button, Modal, Typography } from 'antd' +import { useEffect, useState } from 'react' + +import { fetchWorkerAnnouncement } from '@/services/worker' +import { + readAnnouncementAck, + resolveAnnouncementVersion, + shouldShowAnnouncementPopup, + writeAnnouncementAck, +} from '@/utils/announcement-ack' + +/** + * 打手端公告弹窗。 + * + * 后台「打手端滚动公告」一改动正文,就会在打手端下次进入时弹出;必须点「我已知晓」 + * 才能关闭(遮罩/ESC 不生效)。确认结果按公告内容指纹记在 localStorage, + * 同一份公告只弹一次,改了正文会重新弹。 + */ +export default function WorkerAnnouncementPopup() { + const [acknowledgedVersion, setAcknowledgedVersion] = useState(() => readAnnouncementAck()) + const [open, setOpen] = useState(false) + + const announcementQuery = useQuery({ + queryKey: ['worker-announcement'], + queryFn: fetchWorkerAnnouncement, + retry: false, + // 全局默认 staleTime: Infinity,公告需要在下一次进入页面时重新拉取 + staleTime: 5 * 60_000, + }) + const announcement = announcementQuery.data?.data + const content = String(announcement?.content || '').trim() + + useEffect(() => { + const shouldShow = shouldShowAnnouncementPopup({ + enabled: announcement?.enabled, + content, + acknowledgedVersion, + }) + if (shouldShow) setOpen(true) + }, [announcement?.enabled, content, acknowledgedVersion]) + + function acknowledge() { + const version = resolveAnnouncementVersion(content) + writeAnnouncementAck(version) + setAcknowledgedVersion(version) + setOpen(false) + } + + return ( + + 公告 + + } + closable={false} + maskClosable={false} + keyboard={false} + centered + width={460} + className="worker-announcement-popup" + footer={ + + } + > + + {content} + + + ) +} diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 9afdf8cc..26c34b48 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -34,6 +34,7 @@ import { } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' +import WorkerAnnouncementPopup from '@/components/worker/WorkerAnnouncementPopup' import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' const menuItems: MenuProps['items'] = [ @@ -191,6 +192,7 @@ export default function WorkerLayout() {
+
@@ -285,6 +287,7 @@ export default function WorkerLayout() { + diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx index c4258f0c..9cb5a206 100644 --- a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -226,7 +226,7 @@ function AnnouncementCard() { name="content" label="公告内容" rules={[{ required: true, whitespace: true, message: '请填写公告内容' }]} - extra="公告会显示在打手端各页面顶部并横向滚动。" + extra="公告会显示在打手端各页面顶部并横向滚动;正文一改动,打手端下次进入会弹出公告弹窗,需点「我已知晓」才关闭。" > diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 49d5ac0f..8403f5c8 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -73,6 +73,24 @@ } } +/* 公告弹窗:正文保留换行,长公告可滚动 */ +.worker-announcement-popup-title { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.worker-announcement-popup-content { + margin: 0 !important; + max-height: 50vh; + overflow-y: auto; + color: #1f2937; + font-size: 14px; + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; +} + .worker-page-head h3, .worker-page-head > .ant-typography { margin: 0 !important; diff --git a/apps/frontend/src/utils/announcement-ack.ts b/apps/frontend/src/utils/announcement-ack.ts new file mode 100644 index 00000000..d0442ec7 --- /dev/null +++ b/apps/frontend/src/utils/announcement-ack.ts @@ -0,0 +1,60 @@ +/** + * 打手端公告弹窗的「已确认」记忆。 + * + * 后台公告只有一条全局配置(`worker_announcement = { enabled, content }`),没有公告 ID, + * 因此这里用**内容指纹**充当版本号:只要运营改了公告正文,指纹就变,打手端会重新弹一次。 + * 确认记录存在 localStorage(方案 B:服务端不落库),换设备或清缓存后会再弹一次。 + */ + +const ANNOUNCEMENT_ACK_STORAGE_KEY = 'order-site-worker-announcement-ack' + +type AnnouncementPopupState = { + enabled?: boolean + content?: string + /** 本地已确认过的公告版本(来自 localStorage) */ + acknowledgedVersion?: string +} + +/** 公告正文的内容指纹(FNV-1a 32 位),空内容返回空串表示「没有可确认的公告」。 */ +export function resolveAnnouncementVersion(content: string): string { + const normalized = String(content || '').trim() + if (!normalized) return '' + let hash = 0x811c9dc5 + for (let index = 0; index < normalized.length; index += 1) { + hash ^= normalized.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return `v1-${hash.toString(16).padStart(8, '0')}` +} + +/** + * 是否需要弹窗:公告开启 + 有正文 + 当前版本还没被这台设备确认过。 + * 已确认过的版本不再弹;运营改了正文(版本变化)后会重新弹。 + */ +export function shouldShowAnnouncementPopup(state: AnnouncementPopupState): boolean { + if (state.enabled !== true) return false + const version = resolveAnnouncementVersion(state.content || '') + if (!version) return false + return state.acknowledgedVersion !== version +} + +/** 读取本机已确认的公告版本(隐私模式/禁用存储时返回空串,页面照常可用)。 */ +export function readAnnouncementAck(): string { + try { + if (typeof window === 'undefined') return '' + return window.localStorage.getItem(ANNOUNCEMENT_ACK_STORAGE_KEY) || '' + } catch { + return '' + } +} + +/** 记录本机已确认的公告版本。 */ +export function writeAnnouncementAck(version: string): void { + if (!version) return + try { + if (typeof window === 'undefined') return + window.localStorage.setItem(ANNOUNCEMENT_ACK_STORAGE_KEY, version) + } catch { + // 存储不可用(隐私模式/配额满)时忽略,下一次打开会再弹一次。 + } +} diff --git a/apps/frontend/src/utils/camera-capture.ts b/apps/frontend/src/utils/camera-capture.ts index 7d046737..e64c47c0 100644 --- a/apps/frontend/src/utils/camera-capture.ts +++ b/apps/frontend/src/utils/camera-capture.ts @@ -20,7 +20,7 @@ type ResolveCameraEntryOptions = { * 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。 * * 说明:这里用 `` 调起系统相机, - * 不依赖 getUserMedia,因此在局域网 HTTP(如 http://192.168.10.8:8080)下同样可用。 + * 不依赖 getUserMedia,因此在纯 HTTP(如 http://worker.localhost/)下同样可用。 */ export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): boolean { if (typeof options.allowCamera === 'boolean') return options.allowCamera diff --git a/apps/frontend/tests/announcement-ack.test.ts b/apps/frontend/tests/announcement-ack.test.ts new file mode 100644 index 00000000..ec086374 --- /dev/null +++ b/apps/frontend/tests/announcement-ack.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + resolveAnnouncementVersion, + shouldShowAnnouncementPopup, +} from '../src/utils/announcement-ack.ts' + +test('公告版本:内容不变版本不变,忽略首尾空白', () => { + const version = resolveAnnouncementVersion('平台担保交易,拒绝私下转账') + assert.ok(version.startsWith('v1-')) + assert.equal(resolveAnnouncementVersion(' 平台担保交易,拒绝私下转账 '), version) +}) + +test('公告版本:内容变化即新版本,空内容没有版本', () => { + const before = resolveAnnouncementVersion('旧公告') + const after = resolveAnnouncementVersion('旧公告(已更新)') + assert.notEqual(before, after) + assert.equal(resolveAnnouncementVersion(''), '') + assert.equal(resolveAnnouncementVersion(' '), '') +}) + +test('弹窗判定:关闭/空正文不弹', () => { + assert.equal( + shouldShowAnnouncementPopup({ enabled: false, content: '公告内容', acknowledgedVersion: '' }), + false, + ) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content: '', acknowledgedVersion: '' }), + false, + ) + assert.equal(shouldShowAnnouncementPopup({ content: '公告内容' }), false) +}) + +test('弹窗判定:未确认真弹,确认过同一版本不再弹,改内容后重新弹', () => { + const content = '严禁私下交易' + const version = resolveAnnouncementVersion(content) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content, acknowledgedVersion: '' }), + true, + ) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content, acknowledgedVersion: version }), + false, + ) + assert.equal( + shouldShowAnnouncementPopup({ + enabled: true, + content: '严禁私下交易(2026-10-25 更新)', + acknowledgedVersion: version, + }), + true, + ) +}) diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev index b0766c0f..5bc55e7c 100644 --- a/deploy/caddy/Caddyfile.dev +++ b/deploy/caddy/Caddyfile.dev @@ -1,5 +1,4 @@ -# 局域网联调:8080 端口给打手端用(无需改客户端 hosts/DNS),80 仍给后台/管理端。 -:80, :8080 { +:80 { route { handle /api/* { reverse_proxy backend:3000 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d52ff921..382dd9e7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -164,8 +164,6 @@ services: ports: - "80:80" - "443:443" - # 局域网联调:打手端用 8080 端口区分(无需改客户端 hosts/DNS) - - "${WORKER_LAN_PORT:-8080}:8080" environment: TZ: ${TZ:-Asia/Shanghai} volumes: