375 lines
9.1 KiB
Vue
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>
|