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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user