feat:加一个网站发布公告会弹出来点确定我已知晓的弹窗;mock数据自动匹配接单模板;本地访问模式修改

This commit is contained in:
2026-10-25 12:27:32 +08:00
parent 0d3dd3eacd
commit a7a8634a96
12 changed files with 403 additions and 6 deletions
@@ -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,
})
@@ -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<WorkProductRuleRow> & { 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')
})
@@ -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<typeof item> => 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 }
}
@@ -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 (
<Modal
open={open}
title={
<span className="worker-announcement-popup-title">
<SoundOutlined /> 公告
</span>
}
closable={false}
maskClosable={false}
keyboard={false}
centered
width={460}
className="worker-announcement-popup"
footer={
<Button type="primary" block onClick={acknowledge}>
我已知晓
</Button>
}
>
<Typography.Paragraph className="worker-announcement-popup-content">
{content}
</Typography.Paragraph>
</Modal>
)
}
@@ -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() {
<main className="worker-mobile-content">
<WorkerAnnouncementBanner />
<WorkerAnnouncementPopup />
<Outlet />
</main>
@@ -285,6 +287,7 @@ export default function WorkerLayout() {
<Layout className="admin-main-shell">
<Layout.Content className="admin-content">
<WorkerAnnouncementBanner />
<WorkerAnnouncementPopup />
<Outlet />
</Layout.Content>
</Layout>
@@ -226,7 +226,7 @@ function AnnouncementCard() {
name="content"
label="公告内容"
rules={[{ required: true, whitespace: true, message: '请填写公告内容' }]}
extra="公告会显示在打手端各页面顶部并横向滚动。"
extra="公告会显示在打手端各页面顶部并横向滚动;正文一改动,打手端下次进入会弹出公告弹窗,需点「我已知晓」才关闭。"
>
<Input.TextArea rows={4} maxLength={300} showCount placeholder="请输入打手端公告内容" />
</Form.Item>
+18
View File
@@ -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;
@@ -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 {
// 存储不可用(隐私模式/配额满)时忽略,下一次打开会再弹一次。
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ type ResolveCameraEntryOptions = {
* 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。
*
* 说明:这里用 `<input type="file" capture="environment">` 调起系统相机,
* 不依赖 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
@@ -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,
)
})
+1 -2
View File
@@ -1,5 +1,4 @@
# 局域网联调:8080 端口给打手端用(无需改客户端 hosts/DNS),80 仍给后台/管理端。
:80, :8080 {
:80 {
route {
handle /api/* {
reverse_proxy backend:3000
-2
View File
@@ -164,8 +164,6 @@ services:
ports:
- "80:80"
- "443:443"
# 局域网联调:打手端用 8080 端口区分(无需改客户端 hosts/DNS)
- "${WORKER_LAN_PORT:-8080}:8080"
environment:
TZ: ${TZ:-Asia/Shanghai}
volumes: