refactor: 重制老师阅卷
continuous-integration/drone/push Build is passing Details

This commit is contained in:
AfyerCu 2025-11-12 23:14:13 +08:00
parent 8b1bc80d4b
commit 5884a6c5da
13 changed files with 931 additions and 676 deletions

View File

@ -55,7 +55,7 @@ export default defineManifestConfig({
android: {
minSdkVersion: 30,
targetSdkVersion: 30,
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
abiFilters: ['armeabi-v7a', 'arm64-v8a', 'x86'],
permissions: [
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',

View File

@ -200,14 +200,14 @@ const currentHistoryIndexInList = computed(() => {
<view class="relative h-full flex-1 overflow-auto bg-green-200">
<!-- 左右箭头按钮 -->
<view
class="absolute left-2 top-1/2 size-32px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
class="arrow-btn-landscape fixed z-100 size-32px flex cursor-pointer items-center justify-center rounded transition-colors"
:class="canGoPrev ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoPrev && emit('prevQuestion')"
>
<view class="i-carbon-chevron-left size-16px text-gray-700" />
</view>
<view
class="absolute right-2 top-1/2 size-32px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
class="arrow-btn-landscape fixed right-0 z-100 size-32px flex cursor-pointer items-center justify-center rounded transition-colors"
:class="canGoNext ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoNext && emit('nextQuestion')"
>
@ -299,17 +299,17 @@ const currentHistoryIndexInList = computed(() => {
</view>
<!-- 作答区域 -->
<view class="relative h-0 flex-1 bg-green-200">
<view class="relative flex-1 bg-green-200">
<!-- 左右箭头按钮 -->
<view
class="absolute left-2 top-1/2 size-28px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
class="arrow-btn-portrait fixed left-2 z-100 size-28px flex cursor-pointer items-center justify-center rounded transition-colors"
:class="canGoPrev ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoPrev && emit('prevQuestion')"
>
<view class="i-carbon-chevron-left size-16px text-gray-700" />
</view>
<view
class="absolute right-2 top-1/2 size-28px flex cursor-pointer items-center justify-center rounded transition-colors -translate-y-1/2"
class="arrow-btn-portrait fixed right-2 z-100 size-28px flex cursor-pointer items-center justify-center rounded transition-colors"
:class="canGoNext ? 'bg-gray-100 hover:bg-gray-200' : 'bg-gray-50 opacity-50 cursor-not-allowed'"
@click="canGoNext && emit('nextQuestion')"
>
@ -320,3 +320,15 @@ const currentHistoryIndexInList = computed(() => {
</view>
</view>
</template>
<style scoped lang="scss">
.arrow-btn-landscape {
top: 50%;
transform: translateY(-50%);
}
.arrow-btn-portrait {
top: 50%;
transform: translateY(-50%);
}
</style>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { useSafeArea } from '@/composables/useSafeArea'
interface Props {
imageUrls: string[]
}
@ -7,6 +9,9 @@ const props = defineProps<Props>()
const visible = defineModel<boolean>({ required: true })
//
const { safeAreaInsets } = useSafeArea()
/**
* 扩大阿里云OSS图片裁剪范围
* 正则匹配阿里云OSS裁剪参数如果存在则扩大范围
@ -55,9 +60,16 @@ const expandedImageUrls = computed(() => {
:close-on-click-modal="false"
custom-class="fullscreen-dialog"
>
<view class="fullscreen-container h-100vh w-100vw flex flex-col bg-black">
<view
class="fullscreen-container h-100vh w-100vw flex flex-col bg-black"
@click="visible = false"
>
<!-- 顶部工具栏 -->
<view class="flex items-center justify-between bg-black/80 px-4 py-3">
<view
class="flex items-center justify-between bg-black/80 px-4 py-3"
:style="{ paddingTop: `${(safeAreaInsets?.top || 0) + 12}px` }"
@click.stop
>
<text class="text-base text-white font-medium">全屏查看</text>
<view
class="size-8 flex cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
@ -101,7 +113,7 @@ const expandedImageUrls = computed(() => {
</view>
<!-- 底部提示 -->
<view class="bg-black/80 px-4 py-2 text-center">
<view class="bg-black/80 px-4 py-2 text-center" @click.stop>
<text class="text-sm text-white/70">长按图片可保存</text>
</view>
</view>

View File

@ -1,196 +1,251 @@
<!-- eslint-disable ts/no-use-before-define -->
<script setup lang="ts">
import type { DomMarkingData, MarkingTool } from '../../composables/renderer/useMarkingDom'
import { useMessage } from 'wot-design-uni'
import { parseOSSImageSize } from '@/utils/image'
import { useSimpleDomLayer } from '../../composables/renderer/useMarkingDom'
interface Props {
imageUrl: string
scale?: number
markingData?: DomMarkingData
backgroundColor?: string
currentTool: MarkingTool
readOnly?: boolean
adaptiveWidth?: boolean //
onQuickScore?: (value: number) => boolean
onTextInput?: (pos: { x: number, y: number }) => Promise<string | null>
}
const props = withDefaults(defineProps<Props>(), {
scale: 1,
backgroundColor: '#ffffff',
readOnly: false,
adaptiveWidth: false,
})
const emit = defineEmits<{
'layer-ready': [layerManager: ReturnType<typeof useSimpleDomLayer>]
'quick-score': [value: number]
}>()
const scale = computed(() => {
return props.scale
})
const currentTool = computed(() => {
return props.currentTool
})
// Refs
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLDivElement>()
//
const naturalWidth = ref(0)
const naturalHeight = ref(0)
//
const containerWidth = ref(0)
const containerHeight = ref(0)
//
const displayWidth = computed(() => naturalWidth.value * props.scale)
const displayHeight = computed(() => naturalHeight.value * props.scale)
function encodeSvg(svg: string) {
return svg.replace('<svg', (~svg.indexOf('xmlns') ? '<svg' : '<svg xmlns="http://www.w3.org/2000/svg"'))
.replace(/"/g, '\'')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
.replace(/\{/g, '%7B')
.replace(/\}/g, '%7D')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
// 使
const actualScale = computed(() => {
if (!props.adaptiveWidth || !naturalWidth.value || !containerWidth.value) {
return props.scale
/**
* SVG 转换为 base64 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 titledescdefs
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 generateLineSvg(points: string, stroke: string, strokeWidth: number, width: number, height: number): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<polyline points="${points}" stroke="${stroke}" stroke-width="${strokeWidth}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
return svgToDataURL(svg)
}
/**
* 生成特殊标记的 SVG data URL
*/
function generateMarkSvg(type: 'correct' | 'wrong' | 'half'): string {
let svg = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
if (type === 'correct') {
// 线 + 线
svg += '<line x1="16" y1="32" x2="28" y2="44" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" fill="none"/>'
svg += '<line x1="28" y1="44" x2="48" y2="20" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" fill="none"/>'
}
else if (type === 'wrong') {
// 线
svg += '<line x1="20" y1="20" x2="44" y2="44" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round"/>'
svg += '<line x1="44" y1="20" x2="20" y2="44" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round"/>'
}
else if (type === 'half') {
// + 线
svg += '<line x1="16" y1="32" x2="28" y2="44" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" fill="none"/>'
svg += '<line x1="28" y1="44" x2="48" y2="20" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" fill="none"/>'
svg += '<line x1="30" y1="25" x2="46" y2="40" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" fill="none"/>'
}
//
const adaptiveScale = containerWidth.value / naturalWidth.value
return Math.min(adaptiveScale, props.scale) //
})
//
const containerStyle = computed(() => {
if (!naturalWidth.value || !naturalHeight.value) {
return {
width: props.adaptiveWidth ? '100%' : '100%',
height: '400px',
backgroundColor: props.backgroundColor,
}
}
if (props.adaptiveWidth) {
// 100%
const scaledHeight = naturalHeight.value * actualScale.value
return {
width: '100%',
height: `${scaledHeight}px`,
backgroundColor: props.backgroundColor,
}
}
else {
//
const scaledWidth = naturalWidth.value * actualScale.value
const scaledHeight = naturalHeight.value * actualScale.value
return {
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
backgroundColor: props.backgroundColor,
}
}
})
// Canvas
const canvasStyle = computed(() => {
if (!naturalWidth.value || !naturalHeight.value) {
return {
width: '100%',
height: '100%',
}
}
return {
width: `${naturalWidth.value}px`,
height: `${naturalHeight.value}px`,
transform: `scale(${actualScale.value})`,
transformOrigin: 'top left',
}
})
svg += '</svg>'
return svgToDataURL(svg)
}
// Layer
const message = useMessage()
const layerManager = useSimpleDomLayer({
scale: actualScale,
currentTool,
scale: computed(() => props.scale),
currentTool: computed(() => props.currentTool),
initialData: props.markingData,
onQuickScore: (value: number) => props.onQuickScore?.(value),
onTextInput: props.onTextInput,
readOnly: props.readOnly,
message,
})
/**
* 更新容器尺寸
*/
function updateContainerSize() {
if (!canvasRef.value)
return
containerWidth.value = canvasRef.value.offsetWidth
containerHeight.value = canvasRef.value.offsetHeight
}
/**
* 初始化DOM画布
*/
async function initCanvas() {
if (!canvasRef.value)
return
//
await nextTick()
//
updateContainerSize()
//
await loadImage()
// ready
emit('layer-ready', layerManager)
}
/**
* 加载图片获取尺寸
*/
async function loadImage(): Promise<void> {
// OSS URL
const ossInfo = parseOSSImageSize(props.imageUrl)
console.log('ossInfo', ossInfo)
if (ossInfo) {
naturalWidth.value = ossInfo.width
naturalHeight.value = ossInfo.height
return
}
// 使 uni.getImageInfo
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
//
naturalWidth.value = img.naturalWidth
naturalHeight.value = img.naturalHeight
resolve()
}
img.onerror = () => {
reject(new Error(`Failed to load image: ${props.imageUrl}`))
}
img.src = props.imageUrl
uni.getImageInfo({
src: props.imageUrl,
success: (res) => {
naturalWidth.value = res.width
naturalHeight.value = res.height
resolve()
},
fail: (err) => {
console.error('Failed to load image:', err)
reject(new Error(`Failed to load image: ${props.imageUrl}`))
},
})
})
}
// rect
let cachedRect: DOMRect | null = null
const instance = getCurrentInstance()
/**
* 获取layer管理器
* 获取canvas元素的位置信息使用uni-app API
*/
const getLayerManager = () => layerManager
async function getCanvasRect(): Promise<DOMRect> {
return new Promise((resolve, reject) => {
const query = uni.createSelectorQuery().in(instance.proxy)
query.select('.canvas-container').boundingClientRect((data) => {
if (data && !Array.isArray(data)) {
resolve({
left: data.left,
top: data.top,
right: data.right,
bottom: data.bottom,
width: data.width,
height: data.height,
x: data.left,
y: data.top,
toJSON: () => ({}),
} as DOMRect)
}
else {
reject(new Error('Failed to get canvas rect'))
}
}).exec()
})
}
//
function handleMouseDown(e: MouseEvent | TouchEvent) {
if (!layerManager || !canvasRef.value)
return
const rect = canvasRef.value.getBoundingClientRect()
layerManager.handleMouseDown(e, rect)
}
function handleMouseMove(e: MouseEvent | TouchEvent) {
if (!layerManager || !canvasRef.value)
return
const rect = canvasRef.value.getBoundingClientRect()
layerManager.handleMouseMove(e, rect)
}
function handleMouseUp() {
async function handleTouchStart(e: TouchEvent) {
if (!layerManager)
return
//
if (e.touches.length >= 2) {
return
}
try {
const rect = cachedRect || await getCanvasRect()
if (!cachedRect) {
cachedRect = rect
}
layerManager.handleMouseDown(e, rect)
}
catch (error) {
console.error('Failed to get canvas rect:', error)
}
}
async function handleTouchMove(e: TouchEvent) {
if (!layerManager)
return
//
if (e.touches.length >= 2) {
return
}
try {
const rect = cachedRect || await getCanvasRect()
if (!cachedRect) {
cachedRect = rect
}
layerManager.handleMouseMove(e, rect)
}
catch (error) {
console.error('Failed to get canvas rect:', error)
}
}
function handleTouchEnd(e: TouchEvent) {
if (!layerManager)
return
//
if (e.touches.length > 0) {
return
}
layerManager.handleMouseUp()
}
@ -199,120 +254,74 @@ watch(
() => props.imageUrl,
async () => {
await loadImage()
// rect
cachedRect = null
},
{ immediate: false },
)
// ResizeObserver
let resizeObserver: any = null
/**
* 设置尺寸监听器
*/
function setupResizeObserver() {
if (!props.adaptiveWidth || !containerRef.value)
return
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
containerWidth.value = width
containerHeight.value = height
}
})
resizeObserver.observe(containerRef.value)
}
else {
// 使
let lastWidth = 0
let lastHeight = 0
const checkSizeChange = () => {
if (!containerRef.value)
return
const newWidth = containerRef.value.offsetWidth || 0
const newHeight = containerRef.value.offsetHeight || 0
if (newWidth !== lastWidth || newHeight !== lastHeight) {
lastWidth = newWidth
lastHeight = newHeight
containerWidth.value = newWidth
containerHeight.value = newHeight
}
}
resizeObserver = setInterval(checkSizeChange, 500)
}
}
/**
* 清理尺寸监听器
*/
function cleanupResizeObserver() {
if (resizeObserver) {
if (typeof ResizeObserver !== 'undefined' && resizeObserver.disconnect) {
resizeObserver.disconnect()
}
else if (typeof resizeObserver === 'number') {
clearInterval(resizeObserver)
}
resizeObserver = null
}
}
//
defineExpose({
getLayerManager,
updateContainerSize,
getLayerManager: () => layerManager,
})
//
onMounted(async () => {
await initCanvas()
if (props.adaptiveWidth) {
setupResizeObserver()
}
})
await loadImage()
await nextTick()
//
onUnmounted(() => {
cleanupResizeObserver()
// rect
try {
cachedRect = await getCanvasRect()
}
catch (error) {
console.warn('Failed to cache canvas rect on init:', error)
}
emit('layer-ready', layerManager)
})
</script>
<template>
<div ref="containerRef" class="dom-image-renderer relative" :style="containerStyle">
<div
ref="canvasRef"
class="canvas-container relative"
:style="canvasStyle"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@touchstart="handleMouseDown"
@touchmove="handleMouseMove"
@touchend="handleMouseUp"
>
<!-- 背景图片 -->
<img
:src="imageUrl"
class="pointer-events-none absolute left-0 top-0 select-none"
:style="{
width: `${naturalWidth}px`,
height: `${naturalHeight}px`,
}"
draggable="false"
>
<view
v-if="naturalWidth && naturalHeight"
class="canvas-container relative"
:style="{
width: `${displayWidth}px`,
height: `${displayHeight}px`,
backgroundColor: '#ffffff',
}"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend.prevent="handleTouchEnd"
>
<!-- 背景图片 -->
<image
:src="imageUrl"
mode="aspectFit"
:style="{
width: `${displayWidth}px`,
height: `${displayHeight}px`,
pointerEvents: 'none',
}"
/>
<!-- 标记层容器 -->
<view
class="absolute left-0 top-0"
:style="{
width: `${naturalWidth}px`,
height: `${naturalHeight}px`,
transform: `scale(${scale})`,
transformOrigin: 'top left',
pointerEvents: 'none',
}"
>
<!-- 当前绘制中的形状 -->
<template v-if="layerManager && layerManager.currentDrawingShape.value">
<template v-if="layerManager.currentDrawingShape.value">
<!-- 矩形 -->
<div
<view
v-if="layerManager.currentDrawingShape.value.type === 'rect'"
class="pointer-events-none absolute"
class="absolute"
:style="{
left: `${layerManager.currentDrawingShape.value.x}px`,
top: `${layerManager.currentDrawingShape.value.y}px`,
@ -323,135 +332,94 @@ onUnmounted(() => {
/>
<!-- 线条 -->
<svg
<view
v-else-if="layerManager.currentDrawingShape.value.type === 'line'"
class="pointer-events-none absolute left-0 top-0"
:width="naturalWidth"
:height="naturalHeight"
>
<polyline
:points="layerManager.currentDrawingShape.value.points?.join(' ')"
:stroke="layerManager.currentDrawingShape.value.stroke"
:stroke-width="layerManager.currentDrawingShape.value.strokeWidth"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
class="absolute left-0 top-0"
:style="{
width: `${naturalWidth}px`,
height: `${naturalHeight}px`,
backgroundImage: `url('${generateLineSvg(
layerManager.currentDrawingShape.value.points?.join(' ') || '',
layerManager.currentDrawingShape.value.stroke,
layerManager.currentDrawingShape.value.strokeWidth,
naturalWidth,
naturalHeight,
)}')`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
pointerEvents: 'none',
}"
/>
</template>
<!-- 已保存的形状 -->
<template v-if="layerManager">
<!-- 矩形 -->
<div
v-for="shape in layerManager.markingData.value.shapes.filter(s => s.type === 'rect')"
:key="shape.id"
class="pointer-events-none absolute"
:style="{
left: `${shape.x}px`,
top: `${shape.y}px`,
width: `${shape.width}px`,
height: `${shape.height}px`,
border: `${shape.strokeWidth}px solid ${shape.stroke}`,
}"
/>
<!-- 矩形 -->
<view
v-for="shape in layerManager.markingData.value.shapes.filter(s => s.type === 'rect')"
:key="shape.id"
class="absolute"
:style="{
left: `${shape.x}px`,
top: `${shape.y}px`,
width: `${shape.width}px`,
height: `${shape.height}px`,
border: `${shape.strokeWidth}px solid ${shape.stroke}`,
}"
/>
<!-- 线条 -->
<svg
v-if="layerManager.markingData.value.shapes.some(s => s.type === 'line')"
class="pointer-events-none absolute left-0 top-0"
:width="naturalWidth"
:height="naturalHeight"
>
<polyline
v-for="shape in layerManager.markingData.value.shapes.filter(s => s.type === 'line')"
:key="shape.id"
:points="shape.points?.join(' ')"
:stroke="shape.stroke"
:stroke-width="shape.strokeWidth"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- 线条 -->
<view
v-for="shape in layerManager.markingData.value.shapes.filter(s => s.type === 'line')"
:key="shape.id"
class="absolute left-0 top-0"
:style="{
width: `${naturalWidth}px`,
height: `${naturalHeight}px`,
backgroundImage: `url('${generateLineSvg(
shape.points?.join(' ') || '',
shape.stroke,
shape.strokeWidth,
naturalWidth,
naturalHeight,
)}')`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
pointerEvents: 'none',
}"
/>
<!-- 特殊标记 -->
<div
v-for="mark in layerManager.markingData.value.specialMarks"
:key="mark.id"
class="pointer-events-none absolute"
:style="{
left: `${mark.x - 10}px`,
top: `${mark.y - 10}px`,
width: '20px',
height: '20px',
}"
>
<!-- 正确标记 -->
<svg v-if="mark.type === 'correct'" width="20" height="20" viewBox="0 0 20 20">
<polyline
points="2,8 7,14 18,2"
stroke="#ff4d4f"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- 特殊标记 -->
<view
v-for="mark in layerManager.markingData.value.specialMarks"
:key="mark.id"
class="absolute"
:style="{
left: `${mark.x - 32}px`,
top: `${mark.y - 32}px`,
width: '64px',
height: '64px',
backgroundImage: `url('${generateMarkSvg(mark.type)}')`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
pointerEvents: 'none',
}"
/>
<!-- 错误标记 -->
<svg v-else-if="mark.type === 'wrong'" width="20" height="20" viewBox="0 0 20 20">
<line x1="2" y1="2" x2="18" y2="18" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" />
<line x1="18" y1="2" x2="2" y2="18" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" />
</svg>
<!-- 半对标记 -->
<svg v-else-if="mark.type === 'half'" width="20" height="20" viewBox="0 0 20 20">
<polyline
points="2,8 7,14 18,2"
stroke="#ff4d4f"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line x1="7" y1="2" x2="20" y2="12" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<!-- 文本注释 -->
<div
v-for="annotation in layerManager.markingData.value.annotations"
:key="annotation.id"
class="pointer-events-none absolute whitespace-nowrap font-bold"
:style="{
left: `${annotation.x}px`,
top: `${annotation.y}px`,
fontSize: `${annotation.fontSize}px`,
color: annotation.color,
}"
>
{{ annotation.text }}
</div>
</template>
</div>
</div>
<!-- 文本注释 -->
<view
v-for="annotation in layerManager.markingData.value.annotations"
:key="annotation.id"
class="absolute font-bold"
:style="{
left: `${annotation.x}px`,
top: `${annotation.y}px`,
fontSize: `${annotation.fontSize}px`,
color: annotation.color,
whiteSpace: 'nowrap',
}"
>
{{ annotation.text }}
</view>
</view>
</view>
</template>
<style scoped>
.dom-image-renderer {
display: inline-block;
overflow: hidden;
user-select: none;
}
.canvas-container {
cursor: crosshair;
touch-action: none;
}
.canvas-container img {
user-select: none;
-webkit-user-drag: none;
}
</style>

View File

@ -17,100 +17,6 @@ const emit = defineEmits<{
const scale = defineModel<number>('scale', { default: 1.0 })
// -
// 1.0 = 100%0.5 = 50%2.0 = 200%
const userScaleFactor = ref(scale.value || 1.0)
// - 100%
const finalScale = computed(() => {
return userScaleFactor.value
})
//
function setScale(newScale: number) {
// 0.15
const clampedScale = Math.max(0.1, Math.min(5.0, newScale))
userScaleFactor.value = clampedScale
scale.value = clampedScale
}
//
const scaleText = computed(() => {
const percentage = Math.round(userScaleFactor.value * 100)
return `${percentage}%`
})
// scale
watch(scale, (newScale) => {
if (newScale !== undefined && newScale !== userScaleFactor.value) {
userScaleFactor.value = newScale
}
})
//
const containerRef = ref<HTMLElement>()
const initialDistance = ref(0)
const isGesturing = ref(false)
const initialScale = ref(1.0)
/**
* 计算两点之间的距离
*/
function getDistance(touch1: Touch, touch2: Touch): number {
const dx = touch2.clientX - touch1.clientX
const dy = touch2.clientY - touch1.clientY
return Math.sqrt(dx * dx + dy * dy)
}
/**
* 处理触摸开始
*/
function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
//
isGesturing.value = true
initialDistance.value = getDistance(e.touches[0], e.touches[1])
initialScale.value = userScaleFactor.value
e.preventDefault()
}
}
/**
* 处理触摸移动
*/
function handleTouchMove(e: TouchEvent) {
if (e.touches.length === 2 && isGesturing.value) {
//
const currentDistance = getDistance(e.touches[0], e.touches[1])
//
const scaleChange = currentDistance / initialDistance.value
//
const newScale = initialScale.value * scaleChange
setScale(newScale)
e.preventDefault()
}
}
/**
* 处理触摸结束
*/
function handleTouchEnd(e: TouchEvent) {
if (e.touches.length < 2) {
isGesturing.value = false
}
}
//
const questionsLayoutClass = computed(() => ({
'questions-vertical': true, //
'items-start': markingSettings.value.imagePosition === 'left',
'items-center': markingSettings.value.imagePosition === 'center',
'items-end': markingSettings.value.imagePosition === 'right',
}))
//
const questionDataList = computed(() => {
if (!props.questionData?.length)
@ -133,28 +39,27 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Do
</script>
<template>
<div
ref="containerRef"
class="multi-question-renderer"
@touchmove="handleTouchMove"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
<scroll-view
scroll-y
class="h-full w-full"
:style="{ paddingBottom: '96rpx' }"
>
<!-- 缩放提示 -->
<div
v-if="isGesturing"
class="fixed left-1/2 top-1/2 z-50 rounded-lg bg-black/70 px-4 py-2 text-white -translate-x-1/2 -translate-y-1/2"
<!-- 题目列表 -->
<view
class="flex flex-col"
:class="{
'items-start': markingSettings.imagePosition === 'left',
'items-center': markingSettings.imagePosition === 'center',
'items-end': markingSettings.imagePosition === 'right',
}"
:style="{ gap: '24px', padding: '12px' }"
>
{{ scaleText }}
</div>
<div class="flex flex-col flex-nowrap" :class="questionsLayoutClass">
<QuestionRenderer
v-for="(question, questionIndex) in questionDataList"
:key="question.id"
:question="question"
:question-index="questionIndex"
:scale="finalScale"
:scale="scale"
:image-layout="markingSettings.imageLayout"
:show-toolbar="markingSettings.showTraceToolbar"
:initial-marking-data="
@ -162,41 +67,8 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Do
? JSON.parse(questionData[questionIndex].remark)
: undefined
"
class="question-item w-fit"
@marking-change="(imageIndex, data) => handleMarkingChange(questionIndex, imageIndex, data)"
/>
</div>
</div>
</view>
</scroll-view>
</template>
<style scoped>
.multi-question-renderer {
width: 100%;
height: 100%;
overflow: auto;
padding: 8rpx 16rpx;
padding-bottom: 96rpx;
touch-action: pan-x pan-y;
}
.questions-container {
display: flex;
align-items: flex-start;
}
.questions-vertical {
flex-direction: column;
gap: 24px;
padding-left: 12px;
}
.questions-single {
justify-content: center;
align-items: center;
min-height: 100%;
}
.question-item {
flex-shrink: 0;
}
</style>

View File

@ -39,8 +39,7 @@ const emit = defineEmits<{
const { currentTool } = useSimpleMarkingTool()
const markingData = useMarkingData()
// layer
const imageRendererRefs = ref<Map<number, InstanceType<typeof DomImageRenderer>>>(new Map())
// layer
const layerManagers = ref<Map<number, ReturnType<typeof useSimpleDomLayer>>>(new Map())
//
@ -56,12 +55,6 @@ const markingDataList = ref<DomMarkingData[]>(
),
)
//
const imagesLayoutClass = computed(() => ({
'images-horizontal': props.imageLayout === 'horizontal',
'images-vertical': props.imageLayout === 'vertical',
}))
const currentMarkingData = computed({
get: () => {
return markingData.currentMarkingSubmitData.value[props.questionIndex]
@ -90,18 +83,6 @@ const hasMarks = computed(() => {
return Array.from(layerManagers.value.values()).some(manager => manager.hasMarks)
})
/**
* 设置图片渲染器引用
*/
function setImageRendererRef(el: any, index: number) {
if (el) {
imageRendererRefs.value.set(index, el)
}
else {
imageRendererRefs.value.delete(index)
}
}
/**
* 处理layer准备就绪
*/
@ -144,17 +125,11 @@ function undo() {
* 处理快捷打分
*/
function handleQuickScore(value: number) {
//
if (currentTool.value !== MarkingTool.SELECT) {
return false
}
if (!markingSettings.value.quickScoreClickMode) {
if (currentTool.value !== MarkingTool.SELECT || !markingSettings.value.quickScoreClickMode) {
return false
}
if (currentMarkingData.value) {
// -1
const currentScore
= currentMarkingData.value.score === -1
? markingSettings.value.scoreMode === 'subtract'
@ -162,7 +137,6 @@ function handleQuickScore(value: number) {
: 0
: currentMarkingData.value.score
//
if (value > 0 && currentScore >= props.question.fullScore) {
return false
}
@ -178,6 +152,30 @@ function handleQuickScore(value: number) {
return false
}
/**
* 处理文本输入
*/
async function handleTextInput(pos: { x: number, y: number }): Promise<string | null> {
return new Promise((resolve) => {
uni.showModal({
title: '请输入文字',
editable: true,
placeholderText: '请输入文字内容',
success: (res) => {
if (res.confirm && res.content) {
resolve(res.content)
}
else {
resolve(null)
}
},
fail: () => {
resolve(null)
},
})
})
}
//
const problemSheetDialogRef = ref<InstanceType<typeof ProblemSheetDialog>>()
@ -211,15 +209,28 @@ const specialMarkTypeMap = {
} as const
async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
//
if (currentSpecialMarkText.value) {
await uni.showModal({
title: '提示',
content: `当前已经被标记为 ${currentSpecialMarkText.value},是否改为${specialMarkTypeMap[type]}`,
const modalResult = await new Promise<boolean>((resolve) => {
uni.showModal({
title: '提示',
content: `当前已经被标记为 ${currentSpecialMarkText.value},是否改为${specialMarkTypeMap[type]}`,
success: (res) => {
resolve(res.confirm)
},
fail: () => {
resolve(false)
},
})
})
//
if (!modalResult) {
return
}
}
if (type === 'problem') {
//
try {
problemSheetDialogRef.value?.syncFormData({
problemType: currentMarkingData.value.problemType ?? '',
@ -227,7 +238,6 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
})
const result = await problemSheetDialogRef.value?.start()
if (result) {
//
Object.assign(markingData.currentMarkingSubmitData.value[props.questionIndex]!, {
isExcellent: false,
isTypical: false,
@ -237,7 +247,6 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
score: 0,
})
//
if (markingData.mode.value === 'single') {
markingData.submitRecord()
}
@ -285,7 +294,7 @@ defineExpose({
</script>
<template>
<div
<view
v-if="
markingData.mode.value !== 'multi'
|| !markingSettings.hideMarked
@ -298,40 +307,44 @@ defineExpose({
&& markingData.firstNotScoredIndex.value === questionIndex,
}"
>
<div class="images-container" :class="imagesLayoutClass">
<div
<!-- 图片容器 -->
<view
class="flex"
:class="imageLayout === 'horizontal' ? 'flex-row' : 'flex-col'"
:style="{ gap: '16px' }"
>
<view
v-for="(imageUrl, imageIndex) in question.imageUrls"
:key="`${question.id}_${imageIndex}`"
class="image-wrapper relative"
class="relative"
>
<DomImageRenderer
:ref="(el) => setImageRendererRef(el, imageIndex)"
:image-url="imageUrl"
:scale="scale"
:marking-data="markingDataList[imageIndex]"
:current-tool="currentTool"
class="image-item"
adaptive-width
:on-text-input="handleTextInput"
@layer-ready="(manager) => handleLayerReady(imageIndex, manager)"
@quick-score="handleQuickScore"
/>
<!-- 第一个图片显示分数 -->
<div
<view
v-if="
imageIndex === 0
&& currentMarkingData?.score !== undefined
&& currentMarkingData?.score !== -1
"
class="score-badge absolute left-2 top-2 z-10"
class="absolute left-2 top-2 z-10 flex items-baseline"
:style="{ gap: '2px', textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)' }"
>
<span class="current-score text-3xl text-red-600 font-bold">{{
currentMarkingData.score
}}</span>
<span class="total-score text-lg text-red-500 font-semibold">/{{ question.fullScore }}</span>
</div>
</div>
</div>
<text class="text-3xl text-red-600 font-bold">{{ currentMarkingData.score }}</text>
<text class="text-lg text-red-500 font-semibold">/{{ question.fullScore }}</text>
</view>
</view>
</view>
<!-- 工具栏 -->
<TraceToolbar
v-if="showToolbar && markingData.firstNotScoredIndex.value === questionIndex"
v-model:current-tool="currentTool"
@ -347,52 +360,5 @@ defineExpose({
<!-- 问题卷对话框 -->
<ProblemSheetDialog ref="problemSheetDialogRef" />
</div>
</view>
</template>
<style scoped>
.images-container {
display: flex;
align-items: flex-start;
}
.images-horizontal {
flex-direction: row;
gap: 16px;
flex-wrap: nowrap;
}
.images-vertical {
flex-direction: column;
gap: 16px;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.image-item {
flex-shrink: 0;
}
.image-wrapper {
flex-shrink: 0;
}
.score-badge {
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
display: flex;
align-items: baseline;
gap: 2px;
}
.current-score {
line-height: 1;
}
.total-score {
line-height: 1;
}
</style>

View File

@ -71,6 +71,68 @@ const problemTooltipContent = computed(() => {
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 titledescdefs
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)
@ -83,8 +145,8 @@ const baseButtonClass = computed(() => {
const separatorClass = computed(() => {
return props.isLandscape
? 'mx-4rpx lg:mx-1 h-4 lg:h-6 w-px flex-shrink-0 bg-gradient-to-b from-transparent via-gray-300 to-transparent'
: 'h-5 lg:h-6 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent'
? '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
@ -100,8 +162,8 @@ function getToolButtonClass(tool: MarkingTool) {
return [
baseButtonClass.value,
isActive
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-500'
: 'bg-white text-blue-600 border border-blue-200 hover:bg-gradient-to-r hover:from-blue-50 hover:to-blue-100 hover:border-blue-300 hover:text-blue-700',
? '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',
]
}
@ -116,16 +178,16 @@ function getMarkButtonClass(type: 'correct' | 'wrong' | 'half') {
}
const colorMap = {
correct: {
active: 'bg-gradient-to-r from-green-500 to-green-600 text-white border border-green-400',
inactive: 'bg-white text-green-600 border border-green-200 hover:bg-gradient-to-r hover:from-green-50 hover:to-green-100 hover:border-green-300 hover:text-green-700',
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-gradient-to-r from-red-500 to-red-600 text-white border border-red-400',
inactive: 'bg-white text-red-600 border border-red-200 hover:bg-gradient-to-r hover:from-red-50 hover:to-red-100 hover:border-red-300 hover:text-red-700',
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-gradient-to-r from-yellow-500 to-yellow-600 text-white border border-yellow-400',
inactive: 'bg-white text-yellow-600 border border-yellow-200 hover:bg-gradient-to-r hover:from-yellow-50 hover:to-yellow-100 hover:border-yellow-300 hover:text-yellow-700',
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',
},
}
@ -142,16 +204,16 @@ function getSpecialButtonClass(type: 'excellent' | 'typical' | 'problem') {
const isActive = props.specialMarks[type]
const colorMap = {
excellent: {
active: 'bg-gradient-to-r from-yellow-500 to-yellow-600 text-white border border-yellow-400',
inactive: 'bg-white text-yellow-600 border border-yellow-200 hover:bg-gradient-to-r hover:from-yellow-50 hover:to-yellow-100 hover:border-yellow-300 hover:text-yellow-700',
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-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-500',
inactive: 'bg-white text-blue-600 border border-blue-200 hover:bg-gradient-to-r hover:from-blue-50 hover:to-blue-100 hover:border-blue-300 hover:text-blue-700',
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-gradient-to-r from-red-500 to-red-600 text-white border border-red-400',
inactive: 'bg-white text-red-600 border border-red-200 hover:bg-gradient-to-r hover:from-red-50 hover:to-red-100 hover:border-red-300 hover:text-red-700',
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',
},
}
@ -268,7 +330,7 @@ defineExpose({
{
'px-2 py-1 w-fit cursor-move lg:px-3 lg:py-2': !isLandscape && !isCollapsed,
'px-4rpx py-2rpx': isLandscape && !isCollapsed,
'bg-gradient-to-r from-slate-800/95 to-slate-900/95': !isCollapsed,
'bg-slate-800/95': !isCollapsed,
'backdrop-saturate-150': !isCollapsed,
},
]"
@ -276,7 +338,7 @@ defineExpose({
<!-- 收起按钮 -->
<div
v-if="isCollapsed"
class="h-full w-8 flex transform cursor-pointer items-center justify-center border border-gray-200 rounded-tr-2xl from-white to-gray-50 bg-gradient-to-r shadow-inner"
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,
}"
@ -296,7 +358,7 @@ defineExpose({
>
<!-- 收起按钮展开状态时显示 -->
<button
class="border border-gray-200 from-white to-gray-50 bg-gradient-to-r text-slate-600 transition-all hover:border-gray-300 hover:from-gray-50 hover:to-gray-100 hover:text-slate-700"
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"
>
@ -319,41 +381,15 @@ defineExpose({
<wd-tooltip content="半对标记" placement="top">
<button :class="getMarkButtonClass('half')" @click="handleMarkTool('half')">
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<!-- 对勾的第一段短的向下斜线 -->
<line
x1="16"
y1="32"
x2="28"
y2="44"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
fill="none"
/>
<!-- 对勾的第二段长的向上斜线 -->
<line
x1="28"
y1="44"
x2="48"
y2="20"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
fill="none"
/>
<!-- 右侧的斜线向下倾斜 -->
<line
x1="30"
y1="25"
x2="46"
y2="40"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
fill="none"
/>
</svg>
<view
class="h-48rpx w-48rpx" :class="iconClass"
:style="{
backgroundImage: `url('${halfMarkSvg}')`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}"
/>
</button>
</wd-tooltip>

View File

@ -66,6 +66,8 @@ interface SimpleDomLayerProps {
}
/** 快捷打分回调 */
onQuickScore?: (value: number) => void
/** 文本输入回调 */
onTextInput?: (pos: { x: number, y: number }) => Promise<string | null>
/** 只读模式,不响应交互事件 */
readOnly?: boolean
message: ReturnType<typeof useMessage>
@ -81,6 +83,7 @@ export function useSimpleDomLayer({
initialData,
offset = { x: 0, y: 0 },
onQuickScore,
onTextInput,
readOnly = false,
message,
}: SimpleDomLayerProps) {
@ -124,6 +127,12 @@ export function useSimpleDomLayer({
if (readOnly)
return
// 检测双指手势,直接返回不处理(让全局缩放处理)
const touchEvent = e as TouchEvent
if (touchEvent.touches && touchEvent.touches.length >= 2) {
return
}
const pos = getRelativePosition(e, containerRect)
if (!pos)
return
@ -171,6 +180,12 @@ export function useSimpleDomLayer({
if (!isDrawing.value || readOnly)
return
// 检测双指手势,直接返回不处理(让全局缩放处理)
const touchEvent = e as TouchEvent
if (touchEvent.touches && touchEvent.touches.length >= 2) {
return
}
const pos = getRelativePosition(e, containerRect)
if (!pos)
return
@ -208,12 +223,13 @@ export function useSimpleDomLayer({
let clientX: number
let clientY: number
if (e instanceof MouseEvent) {
if (typeof window !== 'undefined' && window.MouseEvent && e instanceof MouseEvent) {
clientX = e.clientX
clientY = e.clientY
}
else {
const touch = e.touches[0] || e.changedTouches[0]
const touchEvent = e as TouchEvent
const touch = touchEvent.touches[0] || touchEvent.changedTouches[0]
if (!touch)
return null
clientX = touch.clientX
@ -327,12 +343,21 @@ export function useSimpleDomLayer({
*
*/
const addTextAnnotation = async (pos: { x: number, y: number }) => {
const text = await message.show({
title: '请输入文字:',
type: 'prompt',
}).then((res) => {
return res.value
})
let text: string | null = null
// 如果提供了回调,使用回调获取文本
if (onTextInput) {
text = await onTextInput(pos)
}
else {
// 否则使用默认的 message 弹窗
text = await message.show({
title: '请输入文字:',
type: 'prompt',
}).then((res) => {
return res.value?.toString() || null
}).catch(() => null)
}
if (text) {
const annotation: TextAnnotation = {
@ -398,7 +423,7 @@ export function useSimpleDomLayer({
*
*/
const isPointInSpecialMark = (pos: { x: number, y: number }, mark: SpecialMark): boolean => {
const size = 20 // 特殊标记的大小
const size = 64 // 特殊标记的大小
return (
pos.x >= mark.x - size / 2
&& pos.x <= mark.x + size / 2

View File

@ -0,0 +1,39 @@
/**
* Composable
*
*/
export interface SafeAreaInsets {
top: number
right: number
bottom: number
left: number
}
export function useSafeArea() {
let safeAreaInsets: SafeAreaInsets | null = null
let systemInfo: any
// #ifdef MP-WEIXIN
// 微信小程序使用新的API
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
// 其他平台继续使用uni API
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
return {
safeAreaInsets,
systemInfo,
}
}

View File

@ -39,7 +39,8 @@
"targetSdkVersion": 30,
"abiFilters": [
"armeabi-v7a",
"arm64-v8a"
"arm64-v8a",
"x86"
]
},
"ios": {},
@ -87,7 +88,8 @@
"setting": {
"urlCheck": false,
"es6": true,
"minified": true
"minified": true,
"swc": true
},
"usingComponents": true,
"optimization": {

View File

@ -22,11 +22,15 @@ import MarkingLayout from '@/components/marking/MarkingLayout.vue'
import { DefaultMarkingDataProvider, DefaultMarkingHistoryProvider, provideMarkingContext } from '@/composables/marking/MarkingContext'
import { provideMarkingHistory } from '@/composables/marking/useMarkingHistory'
import { provideMarkingNavigation } from '@/composables/marking/useMarkingNavigation'
import { useSafeArea } from '@/composables/useSafeArea'
defineOptions({
name: 'GradingPage',
})
//
const { safeAreaInsets } = useSafeArea()
//
const examId = ref<number>()
const subjectId = ref<number>()
@ -41,6 +45,57 @@ const isLandscape = ref(false)
//
const imageScale = ref(1.0)
//
const isGesturing = ref(false)
const initialDistance = ref(0)
const initialScale = ref(1.0)
/**
* 计算两点之间的距离
*/
function getDistance(touch1: Touch, touch2: Touch): number {
const dx = touch2.clientX - touch1.clientX
const dy = touch2.clientY - touch1.clientY
return Math.sqrt(dx * dx + dy * dy)
}
/**
* 全局触摸开始处理 - 捕获阶段优先处理双指缩放
*/
function handleGlobalTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
isGesturing.value = true
initialDistance.value = getDistance(e.touches[0], e.touches[1])
initialScale.value = imageScale.value
//
e.stopPropagation()
}
}
/**
* 全局触摸移动处理 - 捕获阶段优先处理双指缩放
*/
function handleGlobalTouchMove(e: TouchEvent) {
if (e.touches.length === 2 && isGesturing.value) {
const currentDistance = getDistance(e.touches[0], e.touches[1])
const scaleChange = currentDistance / initialDistance.value
const newScale = Math.max(0.15, Math.min(5.0, initialScale.value * scaleChange))
imageScale.value = newScale
//
e.stopPropagation()
e.preventDefault()
}
}
/**
* 全局触摸结束处理
*/
function handleGlobalTouchEnd(e: TouchEvent) {
if (e.touches.length < 2) {
isGesturing.value = false
}
}
// 使
provideMarkingContext({
dataProvider: new DefaultMarkingDataProvider(),
@ -252,10 +307,25 @@ async function handleNextQuestion() {
<template>
<div
class="relative h-screen w-100vw flex flex-col touch-pan-x touch-pan-y overflow-hidden overscroll-none pt-safe"
class="relative h-screen w-100vw flex flex-col touch-pan-x touch-pan-y overflow-hidden overscroll-none"
:style="{ paddingTop: `${(safeAreaInsets?.top || 0) + 8}px` }"
@touchstart.capture="handleGlobalTouchStart"
@touchmove.capture="handleGlobalTouchMove"
@touchend.capture="handleGlobalTouchEnd"
@touchstart="swipeHandler.onTouchStart"
@touchend="swipeHandler.onTouchEnd"
>
<!-- 缩放提示 -->
<view
v-if="isGesturing"
class="fixed left-1/2 top-1/2 z-50 rounded-lg px-4 py-2 text-white"
:style="{
backgroundColor: 'rgba(0, 0, 0, 0.7)',
transform: 'translate(-50%, -50%)',
}"
>
{{ Math.round(imageScale * 100) }}%
</view>
<MarkingLayout
:is-landscape="isLandscape"
:is-fullscreen="false"

147
src/utils/dom.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* uniapp各平台
* DOMRect
*/
export interface ElementRect {
left: number
top: number
right: number
bottom: number
width: number
height: number
x: number
y: number
}
/**
* uniapp app模式
* @param element HTML元素引用
* @param selector uniapp模式
* @param context Vue组件实例uniapp模式
* @returns Promise<ElementRect>
*/
export async function getElementRect(
element?: HTMLElement | null,
selector?: string,
context?: any,
): Promise<ElementRect> {
// 优先使用 getBoundingClientRectH5环境
if (element && typeof element.getBoundingClientRect === 'function') {
const rect = element.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
}
}
// 如果element存在但没有getBoundingClientRect尝试使用offset属性
if (element) {
// 获取元素位置需要遍历父元素累加offset
let left = 0
let top = 0
let current: HTMLElement | null = element
while (current) {
left += current.offsetLeft || 0
top += current.offsetTop || 0
current = current.offsetParent as HTMLElement | null
}
const width = element.offsetWidth || 0
const height = element.offsetHeight || 0
return {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
}
}
// uniapp模式使用 uni.createSelectorQuery
if (selector && context) {
return new Promise((resolve, reject) => {
const query = uni.createSelectorQuery().in(context)
query.select(selector).boundingClientRect((rect: any) => {
if (rect) {
resolve({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
x: rect.left,
y: rect.top,
})
}
else {
reject(new Error('Failed to get element rect'))
}
}).exec()
})
}
// 如果都不满足,返回默认值
return Promise.reject(new Error('Unable to get element rect: element, selector, or context is required'))
}
/**
* H5环境使offset属性
* uniapp app模式下不可靠使 getElementRect
*/
export function getElementRectSync(element: HTMLElement | null): ElementRect | null {
if (!element) {
return null
}
// 优先使用 getBoundingClientRect
if (typeof element.getBoundingClientRect === 'function') {
const rect = element.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
}
}
// 备选方案使用offset属性
let left = 0
let top = 0
let current: HTMLElement | null = element
while (current) {
left += current.offsetLeft || 0
top += current.offsetTop || 0
current = current.offsetParent as HTMLElement | null
}
const width = element.offsetWidth || 0
const height = element.offsetHeight || 0
return {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
}
}

106
src/utils/image.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* OSS图片URL中的尺寸信息
* 支持的URL格式: ?x-oss-process=image/rotate,90/crop,w_1277,h_267,x_204,y_1151
*/
export interface OSSImageInfo {
/** 图片宽度(考虑旋转后的实际显示宽度) */
width: number
/** 图片高度(考虑旋转后的实际显示高度) */
height: number
/** 裁剪信息 */
crop?: {
x: number
y: number
width: number
height: number
}
}
/**
* OSS URL中解析图片尺寸信息
* @param url OSS图片URL
* @returns null
*/
export function parseOSSImageSize(url: string): OSSImageInfo | null {
try {
// 从URL中提取查询参数部分
const queryIndex = url.indexOf('?')
if (queryIndex === -1) {
return null
}
const queryString = url.substring(queryIndex + 1)
// 手动解析查询参数
const params = new Map<string, string>()
const pairs = queryString.split('&')
for (const pair of pairs) {
const [key, value] = pair.split('=')
if (key && value) {
params.set(decodeURIComponent(key), decodeURIComponent(value))
}
}
const processParam = params.get('x-oss-process')
if (!processParam) {
return null
}
// 解析 x-oss-process 参数
// 格式: image/rotate,90/crop,w_1277,h_267,x_204,y_1151
const parts = processParam.split('/')
let rotateAngle = 0
let cropInfo: { x: number, y: number, width: number, height: number } | undefined
for (const part of parts) {
// 解析旋转角度
if (part.startsWith('rotate,')) {
const angle = Number.parseInt(part.replace('rotate,', ''), 10)
if (!Number.isNaN(angle)) {
rotateAngle = angle
}
}
// 解析裁剪信息
if (part.startsWith('crop,')) {
const cropParams = part.replace('crop,', '').split(',')
const cropData: Record<string, number> = {}
for (const param of cropParams) {
const [key, value] = param.split('_')
if (key && value) {
const numValue = Number.parseInt(value, 10)
if (!Number.isNaN(numValue)) {
cropData[key] = numValue
}
}
}
if (cropData.w && cropData.h) {
cropInfo = {
x: cropData.x || 0,
y: cropData.y || 0,
width: cropData.w,
height: cropData.h,
}
}
}
}
// 如果没有裁剪信息,无法确定尺寸
if (!cropInfo) {
return null
}
return {
width: cropInfo.width,
height: cropInfo.height,
crop: cropInfo,
}
}
catch (error) {
console.warn('Failed to parse OSS image URL:', error)
return null
}
}