460 lines
14 KiB
Vue
460 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import type { MarkingSubmitData } from '../../composables/useMarkingData'
|
||
import { useSessionStorage } from '@vueuse/core'
|
||
import { DictCode, useDict } from '@/composables/useDict'
|
||
import { MarkingTool } from '../../composables/renderer/useMarkingDom'
|
||
import { markingSettings as settings } from '../../composables/useMarkingSettings'
|
||
|
||
interface Props {
|
||
canUndo?: boolean
|
||
canRedo?: boolean
|
||
hasMarks?: boolean
|
||
specialMarks?: {
|
||
excellent: boolean
|
||
typical: boolean
|
||
problem: boolean
|
||
}
|
||
isLandscape?: boolean
|
||
currentMarkingData?: MarkingSubmitData
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
canUndo: false,
|
||
canRedo: false,
|
||
hasMarks: false,
|
||
specialMarks: () => ({
|
||
excellent: false,
|
||
typical: false,
|
||
problem: false,
|
||
}),
|
||
isLandscape: false,
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
'add-correct-mark': [position: { x: number, y: number }]
|
||
'add-wrong-mark': [position: { x: number, y: number }]
|
||
'add-half-mark': [position: { x: number, y: number }]
|
||
'undo': []
|
||
'redo': []
|
||
'clear-all': []
|
||
'toggle-special-mark': [type: 'excellent' | 'typical' | 'problem']
|
||
}>()
|
||
const currentTool = defineModel<MarkingTool>('currentTool', { required: true })
|
||
const showToolOptions = computed(() => {
|
||
return [MarkingTool.PEN, MarkingTool.TEXT].includes(currentTool.value)
|
||
})
|
||
|
||
// 工具栏引用
|
||
const toolbarRef = ref<HTMLElement>()
|
||
|
||
// 工具栏位置状态,使用 SessionStorage 持久化,默认位于左下角
|
||
const position = useSessionStorage('marking-toolbar-position', { x: 16, y: 16 })
|
||
|
||
// 收起状态
|
||
const isCollapsed = useSessionStorage('marking-toolbar-collapsed', false)
|
||
|
||
// 拖动状态
|
||
const isDragging = ref(false)
|
||
const dragState = ref({
|
||
startX: 0,
|
||
startY: 0,
|
||
startPositionX: 0,
|
||
startPositionY: 0,
|
||
})
|
||
|
||
const { getDictOptionsComputed } = useDict()
|
||
const { options: problemTypeOptions } = getDictOptionsComputed(DictCode.SCAN_ANOMALY_STATUS)
|
||
const problemTooltipContent = computed(() => {
|
||
if (props.currentMarkingData?.isProblem) {
|
||
return `当前问题类型:${problemTypeOptions.value.find(item => item.value === props.currentMarkingData?.problemType)?.label}`
|
||
}
|
||
return '标记为问题卷'
|
||
})
|
||
|
||
/**
|
||
* 将 SVG 转换为 data URL(用于小程序)
|
||
*/
|
||
function svgToDataURL(svg: string): string {
|
||
// 将被设置到 dataset 中的属性还原出来
|
||
svg = svg.replace(/data-(.*?=(['"]).*?\2)/g, '$1')
|
||
|
||
// 将被设置到 data-xlink-href 的属性还原出来
|
||
svg = svg.replace(/xlink-href=/g, 'xlink:href=')
|
||
|
||
// 将 dataset 中被变成 kebab-case 写法的 viewBox 还原出来
|
||
svg = svg.replace(/view-box=/g, 'viewBox=')
|
||
|
||
// 清除 SVG 中不应该显示的 title、desc、defs 元素
|
||
svg = svg.replace(/<(title|desc|defs)>[\s\S]*?<\/\1>/g, '')
|
||
|
||
// 为非标准 XML 的 SVG 添加 xmlns,防止视图层解析出错
|
||
if (!/xmlns=/.test(svg))
|
||
svg = svg.replace(/<svg/, '<svg xmlns=\'http://www.w3.org/2000/svg\'')
|
||
|
||
// 对 SVG 中出现的浮点数统一取最多两位小数,缓解数据量过大问题
|
||
svg = svg.replace(/\d+\.\d+/g, match => Number.parseFloat(Number.parseFloat(match).toFixed(2)) as any)
|
||
|
||
// 清除注释,缓解数据量过大的问题
|
||
svg = svg.replace(/<!--[\s\S]*?-->/g, '')
|
||
|
||
// 模拟 HTML 的 white-space 行为,将多个空格或换行符换成一个空格,减少数据量
|
||
svg = svg.replace(/\s+/g, ' ')
|
||
|
||
// 对特殊符号进行转义,这里参考了 https://github.com/bhovhannes/svg-url-loader/blob/master/src/loader.js
|
||
svg = svg.replace(/[{}|\\^~[\]`"<>#%]/g, (match) => {
|
||
return `%${match[0].charCodeAt(0).toString(16).toUpperCase()}`
|
||
})
|
||
|
||
// 单引号替换为 \',由于 kbone 的 bug,节点属性中的双引号在生成 outerHTML 时不会被转义导致出错
|
||
// 因此 background-image: url( 后面只能跟单引号,所以生成的 URI 内部也就只能用斜杠转义单引号了
|
||
svg = svg.replace(/'/g, '\\\'')
|
||
|
||
// 最后添加 mime 头部,变成 Webview 可以识别的 Data URI
|
||
return `data:image/svg+xml,${svg.trim()}`
|
||
}
|
||
|
||
/**
|
||
* 生成半对标记的 SVG data URL
|
||
* 根据当前按钮状态动态生成颜色
|
||
*/
|
||
function generateHalfMarkSvg(isActive: boolean): string {
|
||
// 根据激活状态选择颜色
|
||
const color = isActive ? '#ffffff' : '#eab308' // 白色(激活)或黄色(未激活)
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||
<line x1="6" y1="12" x2="10.5" y2="16.5" stroke="${color}" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||
<line x1="10.5" y1="16.5" x2="18" y2="7.5" stroke="${color}" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||
<line x1="11.25" y1="9.38" x2="17.25" y2="15" stroke="${color}" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||
</svg>`
|
||
return svgToDataURL(svg)
|
||
}
|
||
|
||
const halfMarkSvg = computed(() => {
|
||
const isActive = currentTool.value === MarkingTool.HALF
|
||
return generateHalfMarkSvg(isActive)
|
||
})
|
||
|
||
// 待放置标记的状态
|
||
const pendingMarkType = ref<'correct' | 'wrong' | 'half' | null>(null)
|
||
|
||
// 统一的按钮基础样式
|
||
const baseButtonClass = computed(() => {
|
||
return props.isLandscape
|
||
? 'size-32rpx lg:size-8 !p-0 rounded-lg flex items-center justify-center transition-all duration-200 ease-in-out shadow-sm hover:shadow-md active:scale-95 after:border-none'
|
||
: 'size-8 lg:size-10 !p-0 rounded-lg flex items-center justify-center transition-all duration-200 ease-in-out shadow-sm hover:shadow-md active:scale-95 after:border-none'
|
||
})
|
||
|
||
const separatorClass = computed(() => {
|
||
return props.isLandscape
|
||
? 'mx-4rpx lg:mx-1 h-4 lg:h-6 w-px flex-shrink-0 bg-gray-300'
|
||
: 'h-5 lg:h-6 w-px bg-gray-300'
|
||
})
|
||
const iconClass = computed(() => {
|
||
return props.isLandscape
|
||
? 'size-20rpx lg:size-4'
|
||
: 'size-5 lg:size-5 w-4 lg:w-5'
|
||
})
|
||
|
||
/**
|
||
* 获取工具按钮样式类
|
||
*/
|
||
function getToolButtonClass(tool: MarkingTool) {
|
||
const isActive = currentTool.value === tool
|
||
return [
|
||
baseButtonClass.value,
|
||
isActive
|
||
? 'bg-blue-600 text-white border border-blue-500'
|
||
: 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700',
|
||
]
|
||
}
|
||
|
||
/**
|
||
* 获取标记按钮样式类(对错半对)
|
||
*/
|
||
function getMarkButtonClass(type: 'correct' | 'wrong' | 'half') {
|
||
const toolMap = {
|
||
correct: MarkingTool.CORRECT,
|
||
wrong: MarkingTool.WRONG,
|
||
half: MarkingTool.HALF,
|
||
}
|
||
const colorMap = {
|
||
correct: {
|
||
active: 'bg-green-500 text-white border border-green-400',
|
||
inactive: 'bg-white text-green-600 border border-green-200 hover:bg-green-50 hover:border-green-300 hover:text-green-700',
|
||
},
|
||
wrong: {
|
||
active: 'bg-red-500 text-white border border-red-400',
|
||
inactive: 'bg-white text-red-600 border border-red-200 hover:bg-red-50 hover:border-red-300 hover:text-red-700',
|
||
},
|
||
half: {
|
||
active: 'bg-yellow-500 text-white border border-yellow-400',
|
||
inactive: 'bg-white text-yellow-600 border border-yellow-200 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700',
|
||
},
|
||
}
|
||
|
||
const isActive = currentTool.value === toolMap[type]
|
||
const colors = colorMap[type]
|
||
|
||
return [baseButtonClass.value, isActive ? colors.active : colors.inactive]
|
||
}
|
||
|
||
/**
|
||
* 获取特殊标记按钮样式类
|
||
*/
|
||
function getSpecialButtonClass(type: 'excellent' | 'typical' | 'problem') {
|
||
const isActive = props.specialMarks[type]
|
||
const colorMap = {
|
||
excellent: {
|
||
active: 'bg-yellow-500 text-white border border-yellow-400',
|
||
inactive: 'bg-white text-yellow-600 border border-yellow-200 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700',
|
||
},
|
||
typical: {
|
||
active: 'bg-blue-600 text-white border border-blue-500',
|
||
inactive: 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700',
|
||
},
|
||
problem: {
|
||
active: 'bg-red-500 text-white border border-red-400',
|
||
inactive: 'bg-white text-red-600 border border-red-200 hover:bg-red-50 hover:border-red-300 hover:text-red-700',
|
||
},
|
||
}
|
||
|
||
const colors = colorMap[type]
|
||
return [baseButtonClass.value, props.isLandscape ? 'text-12rpx lg:text-sm' : 'text-sm lg:text-base', 'font-medium', isActive ? colors.active : colors.inactive]
|
||
}
|
||
|
||
/**
|
||
* 切换工具(支持取消激活)
|
||
*/
|
||
function switchTool(tool: MarkingTool) {
|
||
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
|
||
pendingMarkType.value = null
|
||
|
||
// 显示/隐藏工具选项
|
||
settings.value.showToolOptions = currentTool.value === 'pen' || currentTool.value === 'text'
|
||
}
|
||
|
||
/**
|
||
* 处理标记工具切换(正确/错误/半对)
|
||
*/
|
||
function handleMarkTool(type: 'correct' | 'wrong' | 'half') {
|
||
const toolMap = {
|
||
correct: MarkingTool.CORRECT,
|
||
wrong: MarkingTool.WRONG,
|
||
half: MarkingTool.HALF,
|
||
}
|
||
const tool = toolMap[type]
|
||
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
|
||
pendingMarkType.value = null
|
||
}
|
||
|
||
/**
|
||
* 处理图片点击(用于放置标记)
|
||
*/
|
||
function handleImageClick(position: { x: number, y: number }) {
|
||
if (pendingMarkType.value) {
|
||
switch (pendingMarkType.value) {
|
||
case 'correct':
|
||
emit('add-correct-mark', position)
|
||
break
|
||
case 'wrong':
|
||
emit('add-wrong-mark', position)
|
||
break
|
||
case 'half':
|
||
emit('add-half-mark', position)
|
||
break
|
||
}
|
||
|
||
pendingMarkType.value = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理撤销
|
||
*/
|
||
function handleUndo() {
|
||
if (props.canUndo) {
|
||
emit('undo')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理重做
|
||
*/
|
||
function handleRedo() {
|
||
if (props.canRedo) {
|
||
emit('redo')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理清除所有
|
||
*/
|
||
function handleClearAll() {
|
||
if (props.hasMarks) {
|
||
uni.showModal({
|
||
confirmButtonText: '确认清除',
|
||
cancelButtonText: '取消',
|
||
success: (result) => {
|
||
if (result.confirm) {
|
||
emit('clear-all')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换特殊标记
|
||
*/
|
||
function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
|
||
emit('toggle-special-mark', type)
|
||
}
|
||
|
||
/**
|
||
* 切换收起/展开状态
|
||
*/
|
||
function toggleCollapse() {
|
||
isCollapsed.value = !isCollapsed.value
|
||
}
|
||
|
||
// 暴露点击处理方法给父组件
|
||
defineExpose({
|
||
handleImageClick,
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="toolbarRef"
|
||
class="rounded-tr-base fixed bottom-0 left-0 z-10 select-none border border-white/20 shadow-xl backdrop-blur-sm transition-all duration-300"
|
||
:class="[
|
||
{
|
||
'px-2 py-1 w-fit cursor-move lg:px-3 lg:py-2': !isLandscape && !isCollapsed,
|
||
'px-4rpx py-2rpx': isLandscape && !isCollapsed,
|
||
'bg-slate-800/95': !isCollapsed,
|
||
'backdrop-saturate-150': !isCollapsed,
|
||
},
|
||
]"
|
||
>
|
||
<!-- 收起按钮 -->
|
||
<div
|
||
v-if="isCollapsed"
|
||
class="h-full w-8 flex transform cursor-pointer items-center justify-center border border-gray-200 rounded-tr-2xl bg-white shadow-inner"
|
||
:class="{
|
||
'w-64px': isLandscape,
|
||
}"
|
||
@click="toggleCollapse"
|
||
@mousedown.stop
|
||
>
|
||
<div class="i-carbon:chevron-right text-base text-blue-600 transition-colors lg:text-lg hover:text-blue-700" />
|
||
</div>
|
||
|
||
<!-- 工具栏内容 -->
|
||
<div
|
||
v-show="!isCollapsed"
|
||
class="flex items-center" :class="{
|
||
'gap-4rpx lg:gap-2': !isLandscape,
|
||
'gap-4rpx lg:gap-1': isLandscape,
|
||
}"
|
||
>
|
||
<!-- 收起按钮(展开状态时显示) -->
|
||
<button
|
||
class="border border-gray-200 bg-white text-slate-600 transition-all hover:border-gray-300 hover:bg-gray-50 hover:text-slate-700"
|
||
:class="baseButtonClass"
|
||
@click="toggleCollapse"
|
||
>
|
||
<div :class="iconClass" class="i-carbon:chevron-left" />
|
||
</button>
|
||
|
||
<div :class="separatorClass" />
|
||
<!-- 对错半对标记 -->
|
||
<wd-tooltip content="正确标记" placement="top">
|
||
<button :class="getMarkButtonClass('correct')" @click="handleMarkTool('correct')">
|
||
<div :class="iconClass" class="i-fluent:checkmark-24-regular" />
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<wd-tooltip content="错误标记" placement="top">
|
||
<button :class="getMarkButtonClass('wrong')" @click="handleMarkTool('wrong')">
|
||
<div :class="iconClass" class="i-fluent:dismiss-24-regular" />
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<wd-tooltip content="半对标记" placement="top">
|
||
<button :class="getMarkButtonClass('half')" @click="handleMarkTool('half')">
|
||
<view
|
||
class="h-48rpx w-48rpx" :class="iconClass"
|
||
:style="{
|
||
backgroundImage: `url('${halfMarkSvg}')`,
|
||
backgroundSize: 'contain',
|
||
backgroundRepeat: 'no-repeat',
|
||
backgroundPosition: 'center',
|
||
}"
|
||
/>
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<div :class="separatorClass" />
|
||
<!-- 橡皮擦 -->
|
||
<wd-tooltip content="擦除" placement="top">
|
||
<button
|
||
:class="getToolButtonClass(MarkingTool.ERASER)"
|
||
@click="switchTool(MarkingTool.ERASER)"
|
||
>
|
||
<div :class="iconClass" class="i-fluent:eraser-24-regular" />
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<!-- 文字工具 -->
|
||
<wd-tooltip content="添加文字" placement="top">
|
||
<button :class="getToolButtonClass(MarkingTool.TEXT)" @click="switchTool(MarkingTool.TEXT)">
|
||
<div :class="iconClass" class="i-fluent:text-24-regular" />
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<!-- 任意笔画 -->
|
||
<wd-tooltip content="自由绘制" placement="top">
|
||
<button :class="getToolButtonClass(MarkingTool.PEN)" @click="switchTool(MarkingTool.PEN)">
|
||
<div :class="iconClass" class="i-fluent:pen-24-regular" />
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<div :class="separatorClass" />
|
||
|
||
<!-- 撤回 -->
|
||
<!-- <wd-tooltip content="撤回一步" placement="top">
|
||
<button :class="getActionButtonClass('undo')" :disabled="!canUndo" @click="handleUndo">
|
||
<div class="i-fluent:arrow-undo-24-regular h-4 w-4" />
|
||
</button>
|
||
</wd-tooltip> -->
|
||
|
||
<!-- 特殊标记 -->
|
||
<wd-tooltip content="标记为优秀卷" placement="top">
|
||
<button
|
||
:class="getSpecialButtonClass('excellent')"
|
||
@click="toggleSpecialMark('excellent')"
|
||
>
|
||
优秀
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<wd-tooltip content="标记为典例卷" placement="top">
|
||
<button
|
||
:class="getSpecialButtonClass('typical')"
|
||
@click="toggleSpecialMark('typical')"
|
||
>
|
||
典例
|
||
</button>
|
||
</wd-tooltip>
|
||
|
||
<wd-tooltip :content="problemTooltipContent" placement="top">
|
||
<button
|
||
:class="getSpecialButtonClass('problem')"
|
||
@click="toggleSpecialMark('problem')"
|
||
>
|
||
问题
|
||
</button>
|
||
</wd-tooltip>
|
||
</div>
|
||
</div>
|
||
</template>
|