Merge branch 'dev1023' into dev

解决 9 个冲突文件,并修复合并后遗留问题:

- 大厅候选集:采用 FIFO 窗口 + 全部置顶单 UNION(列表与抢单/拼单复核同源)
- 拼单份额:采用多次参与模型(每次参与 INSERT 新份额、requestId 幂等),
  取消后可重新参与;移除 HEAD 已废弃的单行 rejoin 逻辑与对应测试
- 工单迁移与成本中心 103–108 编号撞车:合并重写为
  109_work_order_share_participation / 110_work_order_hall_and_shop_indexes;
  临时库全量执行与重跑幂等验证通过(10 列、唯一键已删、7 个索引)
- 实时补丁:保留个性化字段、置顶重排、分页总数同步;补回 orderTimelineVisible 类型
- 打手大厅:保留 memo 组件化,移植「再拼一次」多份额按钮逻辑
- 全项目表格统一 PercentTable:WorkerOrderShareTable 改造

验证:backend typecheck/lint/check:sql/test(833 pass)、
frontend typecheck/lint/test(172 pass)/build 全绿。
This commit is contained in:
yml2213
2026-10-26 12:10:53 +08:00
94 changed files with 8991 additions and 744 deletions
@@ -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 {
// 存储不可用(隐私模式/配额满)时忽略,下一次打开会再弹一次。
}
}
+48
View File
@@ -0,0 +1,48 @@
/** 上传场景:只有打手端(worker)在移动设备上需要直接调起系统相机。 */
export type CameraEntryScope = 'admin' | 'worker' | 'collect'
/** 相机捕获类型:图片走拍照,视频走拍摄。 */
export type CameraCaptureKind = 'image' | 'video'
type ResolveCameraEntryOptions = {
/** 当前是否为移动端布局(useIsMobile,宽度 <= 768) */
isMobile: boolean
/** 上传作用域 */
scope?: CameraEntryScope | (string & {})
/** 显式开关:传了就以此为准(true 强制显示,false 强制隐藏) */
allowCamera?: boolean
}
/**
* 是否显示「拍照 / 拍摄」入口。
*
* 规则:默认仅「打手端 + 移动端」显示。管理端/采集端仍只保留相册选择;
* 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。
*
* 说明:这里用 `<input type="file" capture="environment">` 调起系统相机,
* 不依赖 getUserMedia,因此在纯 HTTP(如 http://worker.localhost/)下同样可用。
*/
export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): boolean {
if (typeof options.allowCamera === 'boolean') return options.allowCamera
return options.isMobile && options.scope === 'worker'
}
/**
* 系统相机 file input 的属性。
*
* `capture="environment"` 让移动浏览器优先打开后置摄像头;iOS Safari 的部分版本会
* 忽略该属性并弹出「拍照 / 从相册选择」菜单,属于预期内的降级行为。
*
* 用法约束(安卓兼容性):input 必须**透明铺满按钮**、由用户手指直接点到它
* (见 base.css 的 `.camera-pick-trigger` / `*-camera-input`)。离屏 + 脚本 `.click()`
* 在部分安卓 WebView 上会让浏览器把相机 intent 退化成文件选择。
*/
export function cameraCaptureInputProps(kind: CameraCaptureKind): {
accept: string
capture: 'environment'
} {
return {
accept: kind === 'image' ? 'image/*' : 'video/*',
capture: 'environment',
}
}
+8
View File
@@ -0,0 +1,8 @@
/** 客户端幂等键:一次用户点击一个,用于拼单参与/抢单的服务端去重。 */
export function createRequestId(prefix = 'req'): string {
const random =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
return `${prefix}-${random}`
}
+306
View File
@@ -0,0 +1,306 @@
/**
* 验收视频:浏览器内探测 + 重编码(Canvas + MediaRecorder)。
*
* 为什么不用 ffmpeg.wasm:核心包 31MB、需要 COOP/COEP,iOS 内存易崩;
* 这里只是把「游戏中录屏的 30 秒片段」压到 720p/1.2Mbps,WebM(VP8) 足够,
* 且零依赖、不上传原片(打手多为手机流量)。
*
* 时长策略(产品已确认):**超过上限直接拒绝,不自动裁剪**。
*/
export type AcceptanceVideoConfig = {
enabled: boolean
maxDurationSeconds: number
maxSizeMb: number
targetHeight: number
bitrateKbps: number
audioBitrateKbps: number
fps: number
}
export type VideoProbeResult = {
durationSeconds: number
width: number
height: number
}
export type CompressedVideo = {
blob: Blob
durationSeconds: number
width: number
height: number
contentType: string
extension: 'webm' | 'mp4'
}
/** 按兼容性从高到低尝试;实际可用性由 MediaRecorder.isTypeSupported 决定。 */
const MIME_CANDIDATES = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9,opus',
'video/webm',
'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
'video/mp4',
]
export function pickSupportedVideoMimeType(
isSupported: (mime: string) => boolean = defaultMimeSupport,
): string {
for (const candidate of MIME_CANDIDATES) {
if (isSupported(candidate)) return candidate
}
return ''
}
/** 时长是否超出配置上限;返回空串表示通过。 */
export function resolveVideoDurationLimitError(
durationSeconds: number,
maxDurationSeconds: number,
): string {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0)
return '无法读取视频时长,请更换视频'
if (durationSeconds > maxDurationSeconds + 0.5) {
return `视频时长不能超过 ${maxDurationSeconds} 秒,请先裁剪后再上传`
}
return ''
}
/** 按目标高度等比缩放(取偶数值,编码器要求宽高为偶数)。 */
export function resolveVideoTargetSize(
width: number,
height: number,
targetHeight: number,
): { width: number; height: number } {
const safeWidth = Math.max(2, Math.round(width || 0))
const safeHeight = Math.max(2, Math.round(height || 0))
const limit = Math.max(2, Math.round(targetHeight || 720))
const scale = Math.min(1, limit / safeHeight)
return {
width: ensureEven(safeWidth * scale),
height: ensureEven(safeHeight * scale),
}
}
export function isVideoCompressionSupported(): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') return false
if (typeof MediaRecorder === 'undefined') return false
if (typeof HTMLCanvasElement === 'undefined') return false
if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') return false
return Boolean(pickSupportedVideoMimeType())
}
/** 读取视频时长/分辨率;部分 WebM 的 duration 为 Infinity,这里做一次定位探测。 */
export function probeVideoFile(file: File): Promise<VideoProbeResult> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const video = document.createElement('video')
let settled = false
const cleanup = () => {
URL.revokeObjectURL(url)
video.removeAttribute('src')
video.load()
}
const finish = () => {
if (settled) return
settled = true
const durationSeconds = Number.isFinite(video.duration) ? video.duration : 0
const width = video.videoWidth
const height = video.videoHeight
cleanup()
if (!durationSeconds || durationSeconds <= 0 || !width || !height) {
reject(new Error('无法读取视频信息,请更换视频或先裁剪'))
return
}
resolve({ durationSeconds, width, height })
}
video.preload = 'metadata'
video.muted = true
video.src = url
video.onloadedmetadata = () => {
if (Number.isFinite(video.duration) && video.duration > 0) {
finish()
return
}
// WebM 常见:duration = Infinity。跳到极远处逼浏览器计算真实时长。
video.currentTime = 1e101
const timer = setTimeout(finish, 1500)
video.ondurationchange = () => {
clearTimeout(timer)
finish()
}
}
video.onerror = () => {
if (settled) return
settled = true
cleanup()
reject(new Error('无法读取视频信息,请更换视频或先裁剪'))
}
})
}
/**
* 压缩到配置参数并返回新的 Blob;超过时长上限直接抛错(不裁剪)。
* onProgress 回调 0~1,用于展示"压缩中"进度。
*/
export async function compressVideoForUpload(
file: File,
config: AcceptanceVideoConfig,
onProgress?: (ratio: number) => void,
): Promise<CompressedVideo> {
if (!isVideoCompressionSupported()) {
throw new Error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传')
}
const mimeType = pickSupportedVideoMimeType()
const probe = await probeVideoFile(file)
const limitError = resolveVideoDurationLimitError(
probe.durationSeconds,
config.maxDurationSeconds,
)
if (limitError) throw new Error(limitError)
const target = resolveVideoTargetSize(probe.width, probe.height, config.targetHeight)
const url = URL.createObjectURL(file)
const video = document.createElement('video')
const canvas = document.createElement('canvas')
let audioContext: AudioContext | null = null
let stopped = false
let frameHandle = 0
const stopRecording = () => {
if (stopped) return
stopped = true
try {
video.pause()
} catch {
// ignore
}
}
try {
video.src = url
video.muted = true
video.playsInline = true
video.preload = 'auto'
await waitForEvent(video, 'loadeddata')
canvas.width = target.width
canvas.height = target.height
const context = canvas.getContext('2d')
if (!context) throw new Error('当前浏览器不支持视频压缩,请更换浏览器')
const stream = canvas.captureStream(Math.max(15, Math.min(30, config.fps || 30)))
try {
const AudioContextCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (AudioContextCtor) {
audioContext = new AudioContextCtor()
const source = audioContext.createMediaElementSource(video)
const destination = audioContext.createMediaStreamDestination()
source.connect(destination)
for (const track of destination.stream.getAudioTracks()) stream.addTrack(track)
}
} catch {
// 视频无音轨或浏览器限制:退化为无声视频,不阻断压缩。
}
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: Math.max(300, Number(config.bitrateKbps) || 1200) * 1000,
audioBitsPerSecond: Math.max(32, Number(config.audioBitrateKbps) || 64) * 1000,
})
const chunks: Blob[] = []
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) chunks.push(event.data)
}
const recorderStopped = new Promise<void>((resolve) => {
recorder.onstop = () => resolve()
})
const drawFrame = () => {
if (stopped) return
if (video.readyState >= 2) {
context.drawImage(video, 0, 0, target.width, target.height)
}
onProgress?.(Math.max(0, Math.min(1, video.currentTime / probe.durationSeconds)))
if (video.ended || video.currentTime >= config.maxDurationSeconds) {
stopRecording()
return
}
frameHandle = requestAnimationFrame(drawFrame)
}
recorder.start(1000)
await video.play()
frameHandle = requestAnimationFrame(drawFrame)
video.addEventListener('ended', stopRecording, { once: true })
// 防呆:视频卡住时最多等「时长 + 10 秒」,避免压缩界面永久转圈。
const timeoutHandle = setTimeout(stopRecording, (probe.durationSeconds + 10) * 1000)
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (stopped) {
clearInterval(interval)
resolve()
}
}, 200)
})
clearTimeout(timeoutHandle)
if (recorder.state !== 'inactive') recorder.stop()
await recorderStopped
if (frameHandle) cancelAnimationFrame(frameHandle)
const blob = new Blob(chunks, { type: mimeType.split(';')[0] })
if (!blob.size) throw new Error('视频压缩失败,请重试或更换视频')
const contentType = blob.type || 'video/webm'
onProgress?.(1)
return {
blob,
durationSeconds: Math.min(probe.durationSeconds, config.maxDurationSeconds),
width: target.width,
height: target.height,
contentType,
extension: contentType.includes('mp4') ? 'mp4' : 'webm',
}
} finally {
if (frameHandle) cancelAnimationFrame(frameHandle)
try {
void audioContext?.close()
} catch {
// ignore
}
URL.revokeObjectURL(url)
video.removeAttribute('src')
video.load()
}
}
function defaultMimeSupport(mime: string): boolean {
if (typeof MediaRecorder === 'undefined') return false
return typeof MediaRecorder.isTypeSupported === 'function'
? MediaRecorder.isTypeSupported(mime)
: false
}
function ensureEven(value: number): number {
const rounded = Math.max(2, Math.round(value))
return rounded % 2 === 0 ? rounded : rounded - 1
}
function waitForEvent(target: HTMLMediaElement, eventName: string): Promise<void> {
return new Promise((resolve, reject) => {
const onEvent = () => {
cleanup()
resolve()
}
const onError = () => {
cleanup()
reject(new Error('视频读取失败,请更换文件'))
}
const cleanup = () => {
target.removeEventListener(eventName, onEvent)
target.removeEventListener('error', onError)
}
target.addEventListener(eventName, onEvent, { once: true })
target.addEventListener('error', onError, { once: true })
})
}
@@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: {
}): { operation: 'upsert' | 'remove'; shouldInsert: boolean } {
const item = isRecord(input.item) ? input.item : {}
const statusFilter = String(input.statusFilter || '')
const statusMatches = !statusFilter || statusFilter === String(item.status || '')
/**
* 服务端快照里的 `workerStatuses` 才是「这张单属于哪些页签」的准确答案。
*
* 同一打手在同一工单可能有多份份额(多次拼单),一张单可以同时出现在「代练中」和「待验收」;
* 而快照的 `status` 只能是一个值。旧逻辑拿单个 status 跟页签比对,只要不相等就删行,
* 于是多份额工单总会在其中一个页签里被删掉(表现为「待验收有时候没有数据」)。
* 老事件/其它来源没有该字段时,退回原来的单状态比对。
*/
const tabs = Array.isArray(item.workerStatuses)
? item.workerStatuses.map((tab) => String(tab || '')).filter(Boolean)
: []
const statusMatches =
!statusFilter ||
(tabs.length > 0 ? tabs.includes(statusFilter) : statusFilter === String(item.status || ''))
const statusProvable = input.explicitOrderNo || statusMatches
const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable
@@ -103,11 +116,60 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: {
}
}
/**
* 大厅行的实时排序:与后端列表 SQL(pinnedFirst 时 `pinned_at DESC NULLS LAST, id DESC`)
* 保持同一口径。大厅广播是「就地覆盖 + 第 1 页顶部插入」的缓存补丁,快照来自事件
* 而不是重新查询,行会带着事件发生时的快字段停在缓存里;排序只影响展示位置。
*
* 这里必须带置顶维度:广播快照的 pinnedAt 参与不了 SQL 排序,若只按 workOrderId 排,
* 每次广播变更(拼单进度、材料更新等)都会把置顶行挤到未置顶行后面 ——
* 表现为「进大厅过一会儿,排名靠后的订单跑到前面去」。
*/
export function compareWorkerHallOrderRows(left: unknown, right: unknown): number {
const leftPinnedAtMs = resolveHallPinnedAtMs(left)
const rightPinnedAtMs = resolveHallPinnedAtMs(right)
if (leftPinnedAtMs !== rightPinnedAtMs) {
// 有一方置顶即置顶行在前;双方都置顶时按置顶时间倒序(同列表 SQL 的 NULLS LAST)。
if (leftPinnedAtMs !== null && rightPinnedAtMs !== null) {
return rightPinnedAtMs - leftPinnedAtMs
}
return leftPinnedAtMs !== null ? -1 : 1
}
// 双方置顶状态一致:按工单号倒序(与列表 SQL 的 `id DESC` 一致)。
return (
toNumber((right as { workOrderId?: unknown })?.workOrderId) -
toNumber((left as { workOrderId?: unknown })?.workOrderId)
)
}
/** 置顶行的时间戳;未置顶返回 null(对应 SQL 的 NULLS LAST)。 */
function resolveHallPinnedAtMs(item: unknown): number | null {
if (!isRecord(item)) return null
const parsed = Date.parse(String(item.pinnedAt || ''))
return Number.isFinite(parsed) ? parsed : null
}
/** 按工单号原地增删行,并同步分页总数(结构同后端 `{ data: { items, pagination } }`)。 */
export function patchWorkerWorkOrderListEnvelope(
current: unknown,
event: WorkerListEvent,
insert = false,
/**
* 就地更新时,快照里没有的「本打手私有字段」保留旧值。
*
* 大厅广播是发给所有打手的通用快照,天然没有 `myShare`/`myShares`(那是每个打手自己的份额)。
* 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。
*/
preserveFields: readonly string[] = [],
/**
* 插行后的行内排序比较器。
*
* 插入的新行(大厅广播的 upsert)落在第 1 页顶部,如果大厅缓存里已有置顶行,
* 只按「插到最前面」处理会把新行插到置顶行前面;传入比较器可以按大厅的
* 排序口径(置顶优先,然后工单号倒序)重新排一次。「我的订单」等按服务端
* 顺序追加的列表不传该参数,保持原行为。
*/
sortCompare?: (left: unknown, right: unknown) => number,
): unknown {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
@@ -128,20 +190,24 @@ export function patchWorkerWorkOrderListEnvelope(
)
if (index >= 0) {
// 大厅广播是轻量快照,可能不含 myShare 等个性化字段;就地合并而不是整行替换。
nextItems = items.map((entry, itemIndex) =>
itemIndex === index
? {
...(isRecord(entry) ? entry : {}),
...(isRecord(event.data) ? event.data : {}),
}
: entry,
)
// preserveFields 是调用方对「这些键必须保留」的显式声明;整行合并已经覆盖,
// 这里逐项兜底,避免将来改成浅覆盖时静默丢字段。
const existing = isRecord(items[index]) ? items[index] : null
const merged: Record<string, unknown> = {
...(existing || {}),
...(isRecord(event.data) ? event.data : {}),
}
for (const field of preserveFields) {
if (existing && !(field in merged) && field in existing) merged[field] = existing[field]
}
nextItems = items.map((entry, itemIndex) => (itemIndex === index ? merged : entry))
} else if (insert) {
nextItems = [event.data, ...items]
} else {
return current
}
}
if (sortCompare) nextItems = [...nextItems].sort(sortCompare)
const delta = nextItems.length - items.length
const pagination = isRecord(current.data.pagination) ? current.data.pagination : null
@@ -198,10 +264,18 @@ export function patchWorkerWorkOrderInfiniteEnvelope(
current: unknown,
event: WorkerListEvent,
insert = false,
preserveFields: readonly string[] = [],
sortCompare?: (left: unknown, right: unknown) => number,
): unknown {
if (!isRecord(current) || !Array.isArray(current.pages)) return current
const pages = current.pages.map((page: unknown, index: number) =>
patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0),
patchWorkerWorkOrderListEnvelope(
page,
event,
insert && index === 0,
preserveFields,
sortCompare,
),
)
const lists: unknown[][] = []