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
@@ -34,6 +34,18 @@ function formatStatusLabel(status: string) {
return STATUS_LABELS[status] || status || '未知'
}
/** 份额级问题单处置动作中文名(与后台弹窗的四个选项一一对应) */
const SHARE_PROBLEM_ACTION_LABELS: Record<string, string> = {
return_to_worker: '退回打手继续处理',
reopen: '取消该份额并退回接单大厅',
cancel_release: '取消该份额并退还押金',
cancel_deduct: '取消该份额并扣除押金',
}
function formatShareProblemAction(action: string) {
return SHARE_PROBLEM_ACTION_LABELS[action] || ''
}
function readPayload(event: WorkOrderEvent, key: string): string {
const value = event.payload?.[key]
if (value === undefined || value === null) return ''
@@ -311,6 +323,88 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
}
case 'sharing_config_updated':
return { title: '更新了拼单配置', color: 'default' }
case 'sharing_joined': {
const workerLabel = resolveEventWorkerLabel(event)
const quantity = readPayloadAmount(event, 'quantity')
const deadlineAt = readPayload(event, 'deadlineAt')
const timeoutMinutes = readPayloadAmount(event, 'timeoutMinutes')
const deadlineText = deadlineAt
? `截止 ${dayjs(deadlineAt).format('MM-DD HH:mm')}`
: timeoutMinutes > 0
? `限时 ${Math.round(timeoutMinutes)} 分钟`
: '不限时'
return {
title: workerLabel ? `打手参与拼单(${workerLabel})` : '打手参与拼单',
description: [quantity > 0 ? `参与 ${quantity} 份` : '', deadlineText]
.filter(Boolean)
.join(' · '),
color: 'purple',
}
}
case 'sharing_share_quantity_reduced': {
const workerLabel = resolveEventWorkerLabel(event)
const fromQuantity = readPayloadAmount(event, 'fromQuantity')
const toQuantity = readPayloadAmount(event, 'toQuantity')
const reduceQuantity = readPayloadAmount(event, 'reduceQuantity')
const details = [
fromQuantity > 0 && toQuantity > 0 ? `${fromQuantity} 份 → ${toQuantity} 份` : '',
reduceQuantity > 0 ? `撤销 ${reduceQuantity} 份` : '',
note ? `原因:${note}` : '',
'撤掉的份数已退回接单大厅,押金保持冻结',
].filter(Boolean)
return {
title: workerLabel ? `撤销部分拼单份额(${workerLabel})` : '撤销部分拼单份额',
description: details.join(';'),
color: 'orange',
}
}
case 'sharing_share_marked_problem': {
const workerLabel = resolveEventWorkerLabel(event)
return {
title: workerLabel ? `标记拼单份额问题单(${workerLabel})` : '标记拼单份额问题单',
description: note
? `原因:${note};仅该份额受影响,母单状态不变`
: '未填备注;仅该份额受影响,母单状态不变',
color: 'red',
}
}
case 'sharing_share_problem_disposed': {
const workerLabel = resolveEventWorkerLabel(event)
const actionLabel = formatShareProblemAction(readPayload(event, 'action'))
const depositAmount = readPayloadAmount(event, 'depositAmount')
const details = [
actionLabel ? `处理方式:${actionLabel}` : '',
depositAmount > 0 ? `涉及押金 ${formatFenAmount(depositAmount)}` : '',
note ? `备注:${note}` : '',
].filter(Boolean)
return {
title: workerLabel ? `处置拼单份额问题单(${workerLabel})` : '处置拼单份额问题单',
description: details.length > 0 ? details.join(';') : undefined,
color: 'orange',
}
}
case 'sharing_acceptance_draft_saved':
case 'sharing_acceptance_updated': {
const workerLabel = resolveEventWorkerLabel(event)
const imageCount = readPayloadAmount(event, 'imageCount')
const hasVideo = Boolean(event.payload?.hasVideo)
const details = [
imageCount > 0 ? `验收图 ${imageCount} 张` : '',
hasVideo ? '含验收视频' : '',
].filter(Boolean)
return {
title:
type === 'sharing_acceptance_draft_saved'
? workerLabel
? `保存了拼单验收草稿(${workerLabel})`
: '保存了拼单验收草稿'
: workerLabel
? `更新了拼单验收资料(${workerLabel})`
: '更新了拼单验收资料',
description: details.length > 0 ? details.join(' · ') : undefined,
color: 'purple',
}
}
case 'price_synced_from_rule': {
const rewardAmount = Number(readPayload(event, 'rewardAmount') || 0)
const unitReward = Number(readPayload(event, 'sharingUnitReward') || 0)
@@ -1,11 +1,13 @@
import { CloudUploadOutlined, PlusOutlined } from '@ant-design/icons'
import { App, Typography, Upload } from 'antd'
import { CameraOutlined, CloudUploadOutlined, PlusOutlined } from '@ant-design/icons'
import { App, Button, Typography, Upload } from 'antd'
import type { UploadFile, UploadProps } from 'antd'
import { useEffect, useRef, useState } from 'react'
import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react'
import type { ChangeEvent, ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react'
import { uploadFile } from '@/services/files'
import type { UploadedFile } from '@/types/worker-platform'
import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture'
import { useIsMobile } from '@/utils/use-is-mobile'
type ImageUploadProps = {
value?: UploadedFile[]
@@ -16,6 +18,8 @@ type ImageUploadProps = {
showPasteHint?: boolean
/** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */
captureGlobalPaste?: boolean
/** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */
allowCamera?: boolean
}
type GlobalPasteTarget = {
@@ -62,14 +66,19 @@ export default function ImageUpload({
maxCount = 6,
showPasteHint = false,
captureGlobalPaste = false,
allowCamera,
}: ImageUploadProps) {
const { message } = App.useApp()
const isMobile = useIsMobile()
const [isDragging, setIsDragging] = useState(false)
const dragCounterRef = useRef(0)
const targetIdRef = useRef(0)
const valueRef = useRef(value)
valueRef.current = value
const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera })
const cameraInputProps = cameraCaptureInputProps('image')
const fileList: UploadFile[] = value.map((file) => ({
uid: file.objectKey || file.url,
name: file.filename || '图片',
@@ -105,6 +114,15 @@ export default function ImageUpload({
await uploadImages(images, source)
}
/** 系统相机拍照:一次一张,省去 multiple(部分浏览器在 capture 模式下会忽略多选)。 */
async function handleCameraSelect(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
// 让「再拍同一张」也能再次触发 change。
event.target.value = ''
if (!file) return
await uploadImages([file])
}
function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
const files = Array.from(event.clipboardData?.files || [])
if (files.length === 0) return
@@ -237,9 +255,34 @@ export default function ImageUpload({
</button>
)}
</Upload>
{cameraEnabled && value.length < maxCount ? (
<div className="image-upload-camera">
{/*
相机入口用「透明原生 input 铺满按钮」的方式:
用户手指直接点在 input 上,浏览器才会把它当作拍照手势;
旧实现把 input 移到 left:-9999px 再由按钮脚本 .click(),部分安卓 WebView
会把相机 intent 降级成文件选择(表现为点了「拍照」却弹出文件浏览)。
*/}
<span className="camera-pick-trigger">
<input
type="file"
accept={cameraInputProps.accept}
capture={cameraInputProps.capture}
className="image-upload-camera-input"
aria-label="拍照"
onChange={(event) => void handleCameraSelect(event)}
/>
<Button size="small" icon={<CameraOutlined />} tabIndex={-1}>
拍照
</Button>
</span>
</div>
) : null}
{showPasteHint ? (
<Typography.Text type="secondary" className="image-upload-hint">
点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处
{cameraEnabled
? '点击「上传」从相册选择,或点「拍照」直接调用相机'
: '点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处'}
</Typography.Text>
) : null}
</div>
@@ -0,0 +1,57 @@
import { VideoCameraOutlined } from '@ant-design/icons'
import { Typography } from 'antd'
import type { WorkerAcceptanceVideo } from '@/types/worker-platform'
type VideoPreviewProps = {
video?: WorkerAcceptanceVideo | null
maxHeight?: number
emptyText?: string
showMeta?: boolean
}
/** 验收视频只读预览(打手端详情、后台工单详情、售后固化快照共用)。 */
export default function VideoPreview({
video = null,
maxHeight = 220,
emptyText = '未上传验收视频',
showMeta = true,
}: VideoPreviewProps) {
if (!video?.url) {
return <Typography.Text type="secondary">{emptyText}</Typography.Text>
}
return (
<div className="acceptance-video-preview">
<video
src={video.url}
controls
playsInline
preload="metadata"
style={{ maxWidth: '100%', maxHeight, borderRadius: 8, background: '#000' }}
/>
{showMeta ? (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<VideoCameraOutlined /> {formatAcceptanceVideoDuration(video.durationSeconds)}
{video.size ? ` · ${formatAcceptanceVideoSize(video.size)}` : ''}
</Typography.Text>
</div>
) : null}
</div>
)
}
export function formatAcceptanceVideoDuration(seconds?: number) {
const total = Math.max(0, Math.round(Number(seconds) || 0))
if (!total) return '时长未知'
const minutes = Math.floor(total / 60)
const rest = total % 60
return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒`
}
function formatAcceptanceVideoSize(bytes: number) {
const size = Number(bytes || 0)
if (!size) return ''
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB`
return `${Math.max(1, Math.round(size / 1024))}KB`
}
@@ -0,0 +1,270 @@
import {
CameraOutlined,
DeleteOutlined,
LoadingOutlined,
VideoCameraOutlined,
} from '@ant-design/icons'
import { App, Button, Progress, Space, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useRef, useState, type ChangeEvent } from 'react'
import { uploadVideoFile } from '@/services/files'
import { fetchWorkerAcceptanceConfig } from '@/services/worker'
import type { WorkerAcceptanceVideo } from '@/types/worker-platform'
import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture'
import { useIsMobile } from '@/utils/use-is-mobile'
import {
compressVideoForUpload,
isVideoCompressionSupported,
probeVideoFile,
resolveVideoDurationLimitError,
type AcceptanceVideoConfig,
} from '@/utils/video-compress'
type VideoUploadProps = {
value?: WorkerAcceptanceVideo | null
onChange?: (video: WorkerAcceptanceVideo | null) => void
disabled?: boolean
/** 是否显示移动端「拍摄」入口;不传时按移动端自动判断(本组件仅用于打手端) */
allowCamera?: boolean
}
const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*'
/**
* 验收视频上传(非必填,单个)。
*
* 流程:选片 → 读时长/分辨率 → 超过配置上限直接拒绝(不裁剪)→ Canvas 重编码为
* 720p/1.2Mbps(见 utils/video-compress)→ 上传 → 回填视频信息。
* 浏览器不支持 MediaRecorder / captureStream 时直接提示更换浏览器,不上传原片。
*
* 移动端额外提供「拍摄 / 重拍」入口(capture=environment 调起系统相机);
* 录制时长由系统相机控制,HTML 无法强制限制,超长由时长校验拦下。
*/
export default function VideoUpload({
value = null,
onChange,
disabled = false,
allowCamera,
}: VideoUploadProps) {
const { message } = App.useApp()
const isMobile = useIsMobile()
const inputRef = useRef<HTMLInputElement | null>(null)
const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle')
const [progress, setProgress] = useState(0)
const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope: 'worker', allowCamera })
const cameraInputProps = cameraCaptureInputProps('video')
const configQuery = useQuery({
queryKey: ['worker-acceptance-config'],
queryFn: fetchWorkerAcceptanceConfig,
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: false,
})
const config = configQuery.data?.data as AcceptanceVideoConfig | undefined
const supported = isVideoCompressionSupported()
const busy = phase !== 'idle'
const canPick = Boolean(config?.enabled) && supported && !disabled && !busy
async function processFile(file: File | undefined) {
if (!file || !config) return
if (!config.enabled) {
message.error('验收视频功能暂未开启')
return
}
if (!supported) {
message.error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传')
return
}
setPhase('compressing')
setProgress(0)
try {
const probe = await probeVideoFile(file)
const limitError = resolveVideoDurationLimitError(
probe.durationSeconds,
config.maxDurationSeconds,
)
if (limitError) throw new Error(limitError)
const compressed = await compressVideoForUpload(file, config, setProgress)
if (compressed.blob.size > config.maxSizeMb * 1024 * 1024) {
throw new Error(`压缩后仍超过 ${config.maxSizeMb}MB,请缩短视频后重试`)
}
setPhase('uploading')
setProgress(0)
const uploadName = `acceptance-${Date.now()}.${compressed.extension}`
const uploaded = await uploadVideoFile(
new File([compressed.blob], uploadName, { type: compressed.contentType }),
'worker-acceptance-video',
compressed.durationSeconds,
)
onChange?.({
objectKey: uploaded.objectKey,
url: uploaded.url,
filename: uploaded.filename || uploadName,
contentType: uploaded.contentType || compressed.contentType,
size: uploaded.size || compressed.blob.size,
durationSeconds: uploaded.durationSeconds || compressed.durationSeconds,
width: compressed.width,
height: compressed.height,
})
message.success('验收视频已上传')
} catch (error) {
message.error(error instanceof Error ? error.message : '视频处理失败,请重试')
} finally {
setPhase('idle')
setProgress(0)
}
}
function handleSelect(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
// 让「选择/拍摄同一个文件」也能再次触发 change。
event.target.value = ''
void processFile(file)
}
const hint = config
? cameraEnabled
? `选填,拍摄请控制在 ${config.maxDurationSeconds} 秒内(超时会被拒绝);上传前自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内`
: `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内`
: '选填;正在读取验收视频配置…'
return (
<div className="worker-acceptance-video">
<input
ref={inputRef}
type="file"
accept={VIDEO_ACCEPT}
style={{ display: 'none' }}
onChange={handleSelect}
/>
{value ? (
<div className="worker-acceptance-video-preview">
<video
src={value.url}
controls
playsInline
preload="metadata"
className="worker-acceptance-video-player"
/>
<div className="worker-acceptance-video-meta">
<Typography.Text type="secondary">
{formatDuration(value.durationSeconds)} · {formatSize(value.size)}
</Typography.Text>
<Space size={8} wrap>
{cameraEnabled ? (
// 透明原生 input 铺满按钮:真实手势点 input 才会被当作「拍摄」,
// 脚本 click 在部分安卓 WebView 会退化成文件选择。
<span className="camera-pick-trigger">
<input
type="file"
accept={cameraInputProps.accept}
capture={cameraInputProps.capture}
className="worker-acceptance-video-camera-input"
aria-label="重拍验收视频"
disabled={!canPick}
onChange={handleSelect}
/>
<Button size="small" icon={<CameraOutlined />} disabled={!canPick} tabIndex={-1}>
重拍
</Button>
</span>
) : null}
<Button size="small" disabled={!canPick} onClick={() => inputRef.current?.click()}>
替换
</Button>
<Button
size="small"
danger
icon={<DeleteOutlined />}
disabled={disabled || busy}
onClick={() => onChange?.(null)}
>
删除
</Button>
</Space>
</div>
</div>
) : (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{busy ? (
<Button icon={<LoadingOutlined />} disabled>
处理中…
</Button>
) : (
<Space size={8} wrap>
{cameraEnabled ? (
// 同「重拍」:透明原生 input 铺满按钮,靠真实手势调起系统相机。
<span className="camera-pick-trigger">
<input
type="file"
accept={cameraInputProps.accept}
capture={cameraInputProps.capture}
className="worker-acceptance-video-camera-input"
aria-label="拍摄验收视频"
disabled={!canPick}
onChange={handleSelect}
/>
<Button
type="primary"
icon={<CameraOutlined />}
disabled={!canPick}
tabIndex={-1}
>
拍摄验收视频
</Button>
</span>
) : null}
<Button
icon={<VideoCameraOutlined />}
disabled={!canPick}
onClick={() => inputRef.current?.click()}
>
{cameraEnabled ? '从相册选择' : '选择验收视频'}
</Button>
</Space>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{hint}
</Typography.Text>
</Space>
)}
{busy ? (
<Progress
percent={Math.round(progress * 100)}
size="small"
status="active"
format={(percent) => `${phase === 'compressing' ? '压缩中' : '上传中'} ${percent ?? 0}%`}
style={{ marginTop: 8 }}
/>
) : null}
</div>
)
}
function estimatedSizeMb(config: AcceptanceVideoConfig) {
const bytesPerSecond = ((config.bitrateKbps + config.audioBitrateKbps) * 1000) / 8
const estimated = (bytesPerSecond * config.maxDurationSeconds) / (1024 * 1024)
return Math.max(1, Math.ceil(estimated))
}
function formatDuration(seconds: number) {
const total = Math.max(0, Math.round(Number(seconds) || 0))
const minutes = Math.floor(total / 60)
const rest = total % 60
return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒`
}
function formatSize(bytes?: number) {
const size = Number(bytes || 0)
if (!size) return '大小未知'
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB`
return `${Math.max(1, Math.round(size / 1024))}KB`
}
@@ -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>
)
}