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:
@@ -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 {
|
||||
// 存储不可用(隐私模式/配额满)时忽略,下一次打开会再弹一次。
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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[][] = []
|
||||
|
||||
Reference in New Issue
Block a user