2026-05-12 15:26:30 +08:00

375 lines
9.1 KiB
Vue

<script setup lang="ts">
import { XMessage, debounce } from '@/hooks'
import CameraPreview from './CameraPreview.vue'
import ImageCropper from '@/components/ImageCropper/index.vue'
const props = withDefaults(
defineProps<{
submitAnswerPic?: string
submitAnswerPicOriginal?: string
isAndroid?: 0 | 1
reportFlag: 0 | 1
markAnswerPic?: string
markAnswer?: string
titleId: string
uploadLoading?: boolean
uploadFile: any
beforeUpload: any
isDeadline?: boolean
}>(),
{ isDeadline: true },
)
const emit = defineEmits(['showPic', 'submit'])
const showCanvas = ref(false)
const showClearDialog = ref(false)
const activeTool = ref<'pen' | 'eraser'>('pen')
let canvasSessionId = 0
function resetCanvasToolbarState() {
activeTool.value = 'pen'
}
function pen() {
activeTool.value = 'pen'
signaturePadRef.value?.handlePen?.()
}
function eraser() {
activeTool.value = 'eraser'
signaturePadRef.value?.handleEraser?.()
}
function requestClear() {
showClearDialog.value = true
}
function confirmClear() {
signaturePadRef.value?.handleClear?.()
emit('submit', { id: props.titleId, clearCanvas: true })
}
function closeCanvas() {
showClearDialog.value = false
resetCanvasToolbarState()
showCanvas.value = false
}
async function save() {
const { blob, data } = await signaturePadRef.value.handleSaveAsync()
if (!blob) {
return XMessage.error('内容为空,无法保存')
}
if (typeof props.uploadFile !== 'function') {
return XMessage.error('上传方法不存在,请重试')
}
try {
const uploadSuccess = await props.uploadFile({ file: blob, submitAnswerPicOriginal: data })
if (uploadSuccess !== false) {
showCanvas.value = false
}
} catch (e) {
console.error('uploadFile error', e)
XMessage.error('上传失败,请重试')
}
}
const showCropper = ref(false)
const cropperImg = ref<Blob | File | string>('')
const uploadQueue = ref<any[]>([])
// 串行化标记,避免并发上传
const isUploading = ref(false)
function onUploadCamera(options: any) {
showCanvas.value = false
uploadQueue.value.push(options)
processQueue()
return Promise.resolve()
}
function processQueue() {
if (showCropper.value) return
if (isUploading.value) return
if (uploadQueue.value.length === 0) return
const options = uploadQueue.value.shift()
cropperImg.value = options.file
showCropper.value = true
}
async function handleCropConfirm(blob: Blob) {
if (typeof props.uploadFile !== 'function') {
processQueue()
return
}
// 在 await 期间 isCameraMode 可能因父组件状态变化而改变,先快照
const replaceOnCameraUpload = !isCameraMode.value
isUploading.value = true
try {
await props.uploadFile({
file: blob,
isAndroid: 0,
fromCamera: true,
replaceOnCameraUpload,
})
} catch (e) {
console.error('uploadFile error', e)
} finally {
isUploading.value = false
processQueue()
}
}
function handleCropCancel() {
processQueue()
}
async function handleStart() {
if (props.reportFlag) {
return emit('showPic', viewPic.value)
}
if (props.uploadLoading) {
return XMessage.error('上传中,请稍后~')
}
showCanvas.value = true
}
const uploadRef = ref()
function handleCameraClick() {
if (props.uploadLoading) return
if (viewPic.value && !isCameraMode.value) {
emit('submit', { id: props.titleId, clearCanvas: true })
nextTick(() => {
uploadRef.value?.$el.querySelector('input')?.click()
})
} else {
uploadRef.value?.$el.querySelector('input')?.click()
}
}
const signaturePadRef = ref()
const debounceFallback = debounce(() => signaturePadRef.value?.handleFallback(), 200)
const isCameraMode = computed(() => !!(props.submitAnswerPic && !props.submitAnswerPicOriginal))
const viewPic = computed(() => {
const raw = props.submitAnswerPic || ''
const arr = raw.split(',').filter(Boolean)
const u = arr.length ? arr[arr.length - 1] : ''
return String(u || '')
.replace(/[`]/g, '')
.replace(/http:\/\//gi, 'https://')
.trim()
})
watch(
() => props.titleId,
async () => {
canvasSessionId++
resetCanvasToolbarState()
showCanvas.value = false
},
{ immediate: true },
)
watch(showCanvas, async (nv: boolean) => {
if (!nv) {
canvasSessionId++
resetCanvasToolbarState()
return
}
const currentSessionId = ++canvasSessionId
resetCanvasToolbarState()
await nextTick()
if (!showCanvas.value || currentSessionId !== canvasSessionId) return
pen()
const bg = viewPic.value
const data = props.isAndroid ? '' : props.submitAnswerPicOriginal || ''
if (bg) {
await signaturePadRef.value?.setBackgroundImage?.(bg)
}
if (!showCanvas.value || currentSessionId !== canvasSessionId) return
if (data) {
signaturePadRef.value?.handleLoad?.(data)
}
})
</script>
<template>
<!-- 拍照/手写切换 -->
<div>
<CameraPreview
v-if="isCameraMode"
:submit-answer-pic="submitAnswerPic"
:report-flag="reportFlag"
:upload-loading="uploadLoading"
:upload-file="uploadFile"
:before-upload="beforeUpload"
@showPic="emit('showPic', $event)"
@submit="emit('submit', $event)"
/>
<div v-else class="hand-drawn">
<div class="pic-box" @click="handleStart">
<img v-if="viewPic" :src="viewPic" />
<div v-show="uploadLoading" class="pic-mask">
<Icon name="global_loading" loading size="96" />
</div>
</div>
<div v-if="!props.reportFlag" class="upload-action">
<div class="start-btn" @click="handleCameraClick">
<mj-button :disabled="uploadLoading">相机/上传</mj-button>
</div>
<div class="upload-tip">
<span class="upload-tip-dot"></span>
最多上传3张图片
</div>
</div>
<el-upload
v-show="false"
ref="uploadRef"
accept="image/*"
:before-upload="beforeUpload"
:show-file-list="false"
:disabled="uploadLoading"
multiple
:http-request="onUploadCamera"
>
</el-upload>
</div>
<div v-if="showCanvas && !isCameraMode" class="hand-drawn-mask">
<div class="drawn-box">
<SignaturePad ref="signaturePadRef" bg="#fff" @save="save"></SignaturePad>
</div>
<div class="tools">
<mj-button
:type="activeTool === 'pen' ? 'warn' : undefined"
:class="{ 'tool-active': activeTool === 'pen' }"
@click="pen"
>
画笔
</mj-button>
<mj-button
:type="activeTool === 'eraser' ? 'warn' : undefined"
:class="{ 'tool-active': activeTool === 'eraser' }"
@click="eraser"
>
橡皮擦
</mj-button>
<mj-button @click="debounceFallback">上一步</mj-button>
<mj-button @click="requestClear">清除</mj-button>
<mj-button type="warn" @click="closeCanvas">取消</mj-button>
<mj-button type="warn" :disabled="uploadLoading" @click="save">保存</mj-button>
</div>
</div>
<slot name="markAnswer"></slot>
<mj-dialog
v-model="showClearDialog"
title="提示"
content="确认清空当前画布吗?清空后将恢复为白板。"
ok-text="确认清除"
cancel-text="取消"
:mask-close="false"
:z-index="10001"
:ok="confirmClear"
/>
<!-- <Other :markAnswerPic="markAnswerPic" :markAnswer="markAnswer" /> -->
<ImageCropper
v-model:visible="showCropper"
:img="cropperImg"
@confirm="handleCropConfirm"
@cancel="handleCropCancel"
/>
</div>
</template>
<style scoped lang="scss">
.hand-drawn {
.pic-box {
height: 22.67vh;
border-radius: 1.67vh;
border: 0.33vh solid #bed5fe;
background: #f7faf9;
position: relative;
background-color: #fff;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.pic-mask {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 1.67vh;
z-index: 10;
}
}
.start-btn {
margin-top: 2vh;
display: flex;
justify-content: flex-end;
}
.upload-tip {
margin-top: 10px;
padding: 6px 14px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 8px;
color: #fa8c16;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
.upload-tip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fa8c16;
flex-shrink: 0;
}
}
}
.hand-drawn-mask {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
width: 100vw;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
background: #f7faf9;
.drawn-box {
height: calc(100% - 100px);
}
.tools {
display: flex;
justify-content: flex-end;
.mj-button {
margin: 0 4px;
}
.tool-active {
box-shadow: 0 0 0 2px rgba(250, 140, 22, 0.28);
transform: translateY(-1px);
}
}
}
</style>