refactor: 重制老师阅卷
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
8b1bc80d4b
commit
5884a6c5da
|
|
@ -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"/>',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 中不应该显示的 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 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>
|
||||
|
|
|
|||
|
|
@ -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.1倍到5倍
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 中不应该显示的 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)
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
// 优先使用 getBoundingClientRect(H5环境)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue