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

This commit is contained in:
AfyerCu 2025-11-05 20:02:46 +08:00
parent 7103b3a04b
commit c5d956d3a8
28 changed files with 1974 additions and 40 deletions

View File

@ -0,0 +1,576 @@
# DOM 渲染功能测试指南
## 🎯 测试目标
验证从 Konva Canvas 迁移到 DOM 渲染后,所有功能正常工作,特别是在微信小程序环境中。
## 🔧 测试环境
### 浏览器环境
- Chrome/Edge (开发调试)
- Safari (iOS 兼容性)
- 微信开发者工具 (小程序环境)
### 设备
- 桌面端 (鼠标操作)
- 移动端 (触摸操作)
- 平板 (双指缩放)
## ✅ 功能测试清单
### 1. 基础绘制功能
#### 1.1 矩形工具
**测试步骤**:
1. 选择矩形工具
2. 在画布上按下鼠标/手指
3. 拖动到目标位置
4. 释放鼠标/手指
**预期结果**:
- ✅ 矩形正确绘制
- ✅ 矩形位置、大小准确
- ✅ 矩形颜色、边框宽度符合设置
- ✅ 可以绘制负方向矩形(从右下往左上拖)
**测试数据**:
```typescript
// 预期数据结构
{
id: "shape_xxx",
type: "rect",
x: 100,
y: 100,
width: 200,
height: 150,
stroke: "#ff0000",
strokeWidth: 2
}
```
#### 1.2 笔工具(线条)
**测试步骤**:
1. 选择笔工具
2. 在画布上绘制任意路径
3. 释放鼠标/手指
**预期结果**:
- ✅ 线条流畅,无断点
- ✅ 线条颜色、粗细符合设置
- ✅ 线条端点圆滑lineCap: round
- ✅ 快速绘制时不丢失点
**测试数据**:
```typescript
{
id: "shape_xxx",
type: "line",
x: 0,
y: 0,
points: [100, 100, 105, 102, 110, 105, ...],
stroke: "#ff0000",
strokeWidth: 2
}
```
#### 1.3 文本注释
**测试步骤**:
1. 选择文本工具
2. 点击画布位置
3. 输入文本内容
4. 确认
**预期结果**:
- ✅ 弹出输入框
- ✅ 文本正确显示在点击位置
- ✅ 文本大小、颜色符合设置
- ✅ 支持多行文本
- ✅ 支持特殊字符
**测试用例**:
- 普通文本: "扣1分"
- 数字: "123"
- 特殊字符: "+5", "-2"
- 多行文本: "第一行\n第二行"
#### 1.4 特殊标记
**正确标记 ✓**:
- ✅ 显示红色勾号
- ✅ 位置准确
- ✅ 大小适中20x20
**错误标记 ✗**:
- ✅ 显示红色叉号
- ✅ 位置准确
- ✅ 大小适中20x20
**半对标记**:
- ✅ 显示勾号+斜线组合
- ✅ 位置准确
- ✅ 大小适中20x20
### 2. 擦除功能
#### 2.1 点击擦除
**测试步骤**:
1. 绘制多个不同类型的标记
2. 选择擦除工具
3. 点击各个标记
**预期结果**:
- ✅ 点击矩形内部,矩形被删除
- ✅ 点击线条附近5px内线条被删除
- ✅ 点击特殊标记,标记被删除
- ✅ 点击文本,文本被删除
- ✅ 点击空白处,无反应
**碰撞检测测试**:
```typescript
// 矩形边界测试
点击 (100, 100) -> 在矩形内 ✅
点击 (99, 99) -> 在矩形外 ❌
点击 (300, 250) -> 在矩形内 ✅
点击 (301, 251) -> 在矩形外 ❌
// 线条距离测试
点击距离线条 3px -> 命中 ✅
点击距离线条 10px -> 未命中 ❌
// 特殊标记测试(中心点 100, 100
点击 (100, 100) -> 命中 ✅
点击 (90, 90) -> 命中 ✅
点击 (110, 110) -> 命中 ✅
点击 (89, 89) -> 未命中 ❌
```
#### 2.2 拖动擦除
**测试步骤**:
1. 绘制多个标记
2. 选择擦除工具
3. 按住鼠标/手指拖动经过标记
**预期结果**:
- ✅ 经过的标记被连续删除
- ✅ 擦除流畅,无卡顿
### 3. 撤销/重做功能
#### 3.1 撤销操作
**测试步骤**:
1. 依次绘制:矩形 -> 线条 -> 文本
2. 点击撤销按钮 3 次
**预期结果**:
- ✅ 第1次撤销文本消失
- ✅ 第2次撤销线条消失
- ✅ 第3次撤销矩形消失
- ✅ 第4次撤销按钮禁用无反应
**命令栈验证**:
```typescript
// 初始状态
commandStack: []
currentIndex: -1
canUndo: false
// 绘制矩形后
commandStack: [AddShapeCommand(rect)]
currentIndex: 0
canUndo: true
// 绘制线条后
commandStack: [AddShapeCommand(rect), AddShapeCommand(line)]
currentIndex: 1
canUndo: true
// 撤销一次后
commandStack: [AddShapeCommand(rect), AddShapeCommand(line)]
currentIndex: 0
canUndo: true
canRedo: true
```
#### 3.2 重做操作
**测试步骤**:
1. 绘制标记
2. 撤销
3. 点击重做
**预期结果**:
- ✅ 标记重新出现
- ✅ 位置、样式与原来一致
#### 3.3 撤销后新建
**测试步骤**:
1. 绘制A -> B -> C
2. 撤销 2 次(剩下 A
3. 绘制新标记 D
**预期结果**:
- ✅ B、C 从命令栈中移除
- ✅ 不能重做 B、C
- ✅ 可以撤销 D
```typescript
// 撤销2次后
commandStack: [A, B, C]
currentIndex: 0
// 绘制D后
commandStack: [A, D] // B、C被截断
currentIndex: 1
canRedo: false
```
#### 3.4 清空所有标记
**测试步骤**:
1. 绘制多个标记
2. 点击"清空"按钮
3. 点击撤销
**预期结果**:
- ✅ 所有标记消失
- ✅ 撤销后所有标记恢复
### 4. 缩放功能
#### 4.1 手动缩放
**测试步骤**:
1. 设置缩放比例为 0.5
2. 绘制标记
3. 设置缩放比例为 2.0
**预期结果**:
- ✅ 图片大小正确变化
- ✅ 标记跟随图片缩放
- ✅ 标记位置相对图片不变
- ✅ 新绘制的标记坐标正确
**坐标转换验证**:
```typescript
// 缩放 0.5 时点击屏幕 (100, 100)
实际坐标 = (100 / 0.5, 100 / 0.5) = (200, 200)
// 缩放 2.0 时点击屏幕 (100, 100)
实际坐标 = (100 / 2.0, 100 / 2.0) = (50, 50)
```
#### 4.2 自适应宽度模式
**测试步骤**:
1. 启用自适应宽度
2. 调整浏览器窗口宽度
**预期结果**:
- ✅ 图片宽度自动适应容器
- ✅ 高度按比例缩放
- ✅ 标记位置正确
#### 4.3 双指缩放(触摸设备)
**测试步骤**:
1. 在触摸设备上打开
2. 双指捏合/展开
**预期结果**:
- ✅ 图片跟随手势缩放
- ✅ 显示缩放百分比提示
- ✅ 标记跟随缩放
### 5. 快捷打分功能
#### 5.1 加分模式
**测试步骤**:
1. 设置快捷打分:加分模式,分值 2
2. 启用快捷打分点击模式
3. 点击画布
**预期结果**:
- ✅ 显示 "+2" 文本标记
- ✅ 分数增加 2 分
- ✅ 文本颜色为红色
- ✅ 不能超过满分
**边界测试**:
```typescript
满分: 10
当前: 9
点击加2 -> 分数变为 10 ✅
再次点击 -> 无反应 ✅
```
#### 5.2 减分模式
**测试步骤**:
1. 设置快捷打分:减分模式,分值 1
2. 启用快捷打分点击模式
3. 点击画布
**预期结果**:
- ✅ 显示 "-1" 文本标记
- ✅ 分数减少 1 分
- ✅ 文本颜色为蓝色
- ✅ 不能低于 0 分
### 6. 多图片场景
#### 6.1 多图片渲染
**测试步骤**:
1. 加载包含 3 张图片的题目
2. 在每张图片上绘制不同标记
**预期结果**:
- ✅ 每张图片独立渲染
- ✅ 标记数据互不干扰
- ✅ 撤销操作针对最后操作的图片
#### 6.2 横向/纵向布局
**测试步骤**:
1. 切换图片布局模式
**预期结果**:
- ✅ 横向布局:图片水平排列
- ✅ 纵向布局:图片垂直排列
- ✅ 标记位置不受影响
### 7. 只读模式
#### 7.1 历史记录查看
**测试步骤**:
1. 打开已批改的试卷
2. 查看标记
**预期结果**:
- ✅ 所有标记正确显示
- ✅ 不响应点击事件
- ✅ 不显示工具栏
### 8. 数据持久化
#### 8.1 数据导出
**测试步骤**:
1. 绘制多种标记
2. 导出数据
**预期结果**:
```json
{
"version": "1.0",
"shapes": [
{
"id": "shape_xxx",
"type": "rect",
"x": 100,
"y": 100,
"width": 200,
"height": 150,
"stroke": "#ff0000",
"strokeWidth": 2
}
],
"specialMarks": [
{
"id": "mark_xxx",
"type": "correct",
"x": 300,
"y": 200
}
],
"annotations": [
{
"id": "text_xxx",
"x": 400,
"y": 300,
"text": "扣1分",
"fontSize": 14,
"color": "#000000"
}
]
}
```
#### 8.2 数据导入
**测试步骤**:
1. 导入上述 JSON 数据
2. 查看渲染结果
**预期结果**:
- ✅ 所有标记正确恢复
- ✅ 位置、样式一致
### 9. 性能测试
#### 9.1 大量标记渲染
**测试步骤**:
1. 导入包含 100+ 标记的数据
2. 观察渲染性能
**预期结果**:
- ✅ 初始渲染 < 1s
- ✅ 滚动流畅,无卡顿
- ✅ 内存占用合理
#### 9.2 快速连续绘制
**测试步骤**:
1. 快速绘制多条线条
**预期结果**:
- ✅ 线条流畅,无延迟
- ✅ 不丢失点
- ✅ 不出现重复标记
### 10. 兼容性测试
#### 10.1 微信小程序
**测试步骤**:
1. 在微信开发者工具中运行
2. 测试所有功能
**预期结果**:
- ✅ 所有功能正常
- ✅ 触摸事件响应正常
- ✅ 无控制台错误
#### 10.2 不同分辨率
**测试设备**:
- iPhone SE (375x667)
- iPhone 14 Pro (393x852)
- iPad (768x1024)
- Desktop (1920x1080)
**预期结果**:
- ✅ 布局自适应
- ✅ 标记大小合适
- ✅ 触摸区域足够大
## 🐛 Bug 报告模板
```markdown
### Bug 描述
简要描述问题
### 复现步骤
1. 步骤1
2. 步骤2
3. 步骤3
### 预期行为
应该发生什么
### 实际行为
实际发生了什么
### 环境信息
- 设备: iPhone 14
- 系统: iOS 16.0
- 浏览器: 微信开发者工具
- 版本: 1.0.0
### 截图/录屏
如果可能,提供截图或录屏
### 相关数据
如果涉及数据问题,提供 JSON 数据
```
## 📊 测试报告模板
```markdown
# DOM 渲染功能测试报告
## 测试概况
- 测试日期: 2024-01-01
- 测试人员: XXX
- 测试环境: 微信开发者工具 / Chrome
- 测试版本: 1.0.0
## 测试结果汇总
| 功能模块 | 测试用例数 | 通过 | 失败 | 通过率 |
|---------|-----------|------|------|--------|
| 基础绘制 | 10 | 10 | 0 | 100% |
| 擦除功能 | 5 | 5 | 0 | 100% |
| 撤销重做 | 8 | 8 | 0 | 100% |
| 缩放功能 | 6 | 6 | 0 | 100% |
| 快捷打分 | 4 | 4 | 0 | 100% |
| 多图片 | 3 | 3 | 0 | 100% |
| 只读模式 | 2 | 2 | 0 | 100% |
| 数据持久化 | 4 | 4 | 0 | 100% |
| 性能测试 | 3 | 3 | 0 | 100% |
| 兼容性 | 5 | 5 | 0 | 100% |
| **总计** | **50** | **50** | **0** | **100%** |
## 详细测试结果
[详细记录每个测试用例的结果]
## 发现的问题
[列出发现的所有问题]
## 建议
[提出改进建议]
## 结论
✅ 通过 / ❌ 不通过
```
## 🎯 自动化测试(可选)
### 单元测试示例
```typescript
import { describe, it, expect } from 'vitest'
import { useSimpleDomLayer } from './useMarkingDom'
describe('useMarkingDom', () => {
it('should add shape correctly', () => {
const layer = useSimpleDomLayer({...})
// 模拟绘制矩形
layer.handleMouseDown(mockEvent, mockRect)
layer.handleMouseMove(mockEvent, mockRect)
layer.handleMouseUp()
expect(layer.markingData.value.shapes).toHaveLength(1)
expect(layer.markingData.value.shapes[0].type).toBe('rect')
})
it('should undo correctly', () => {
const layer = useSimpleDomLayer({...})
// 添加标记
layer.handleMouseDown(mockEvent, mockRect)
layer.handleMouseUp()
expect(layer.markingData.value.shapes).toHaveLength(1)
// 撤销
layer.undo()
expect(layer.markingData.value.shapes).toHaveLength(0)
})
})
```
## 📝 测试记录
### 测试日期: ___________
### 测试人员: ___________
| 测试项 | 状态 | 备注 |
|--------|------|------|
| 绘制矩形 | ⬜ | |
| 绘制线条 | ⬜ | |
| 文本注释 | ⬜ | |
| 特殊标记 | ⬜ | |
| 擦除功能 | ⬜ | |
| 撤销操作 | ⬜ | |
| 重做操作 | ⬜ | |
| 清空标记 | ⬜ | |
| 手动缩放 | ⬜ | |
| 自适应宽度 | ⬜ | |
| 快捷打分 | ⬜ | |
| 多图片 | ⬜ | |
| 只读模式 | ⬜ | |
| 数据导出 | ⬜ | |
| 数据导入 | ⬜ | |
| 性能测试 | ⬜ | |
| 小程序兼容 | ⬜ | |
**签名**: ___________

View File

@ -0,0 +1,270 @@
# Konva 到 DOM 渲染迁移文档
## 📋 迁移概述
### 迁移原因
微信小程序不支持 Konva.js需要将基于 Canvas 的渲染方案改为纯 DOM + SVG 渲染方案。
### 迁移目标
- ✅ 保留所有现有功能
- ✅ 使用 Vue 响应式系统管理渲染
- ✅ 保持命令模式的撤销/重做功能
- ✅ 兼容微信小程序环境
## 🔄 核心变更
### 1. 新增文件
#### `useMarkingDom.ts`
替代 `useMarkingKonva.ts`,使用 DOM 渲染:
- **数据结构**: `DomMarkingData` (兼容原 `KonvaMarkingData`)
- **工具枚举**: `MarkingTool` (保持不变)
- **核心函数**: `useSimpleDomLayer` 替代 `useSimpleKonvaLayer`
**主要特性**:
- 使用 Vue 响应式系统自动渲染
- 鼠标/触摸事件处理
- 坐标转换(支持缩放和偏移)
- 碰撞检测(用于擦除功能)
#### `DomImageRenderer.vue`
替代 `KonvaImageRenderer.vue`,使用 DOM 渲染:
- **背景图片**: `<img>` 标签
- **矩形**: `<div>` + CSS border
- **线条**: `<svg>` + `<polyline>`
- **特殊标记**: `<svg>` 图标
- **文本**: `<div>` + CSS
**优势**:
- 无需 Canvas API
- 完全兼容小程序
- Vue 自动管理 DOM 更新
- 更好的性能(小数据量场景)
### 2. 修改文件
#### `useMarkingCommand.ts`
- 将类型从 `KonvaMarkingData` 改为 `BaseMarkingData`
- 将 `KonvaShape` 改为 `BaseShape`
- 移除对 `layer` 的依赖(传入 `null`
- 保持命令模式逻辑不变
#### `QuestionRenderer.vue`
- 导入 `DomImageRenderer` 替代 `KonvaImageRenderer`
- 导入 `useMarkingDom` 类型替代 `useMarkingKonva`
- 其他逻辑保持不变
#### `MarkingImageViewerNew.vue`
- 更新类型导入:`DomMarkingData` 替代 `KonvaMarkingData`
#### `ReviewImageRenderer.vue`
- 使用 `DomImageRenderer` 替代 `KonvaImageRenderer`
- 只读模式渲染历史标记
#### `TraceToolbar.vue`
- 更新导入:从 `useMarkingDom` 导入 `MarkingTool`
### 3. 保留文件(兼容性)
#### `KonvaImageRenderer.vue`
保留但不再使用,可用于对比测试
#### `useMarkingKonva.ts`
保留但不再使用,可用于对比测试
#### `useMarkingTools.ts`
标记为 `@deprecated`,仅用于 Konva 兼容
## 🎯 功能对照表
| 功能 | Konva 实现 | DOM 实现 | 状态 |
|------|-----------|---------|------|
| 绘制矩形 | Konva.Rect | `<div>` + border | ✅ |
| 绘制线条 | Konva.Line | `<svg><polyline>` | ✅ |
| 特殊标记 | Konva.Group | `<svg>` 图标 | ✅ |
| 文本注释 | Konva.Text | `<div>` | ✅ |
| 擦除工具 | layer.getIntersection() | 碰撞检测算法 | ✅ |
| 撤销/重做 | 命令模式 | 命令模式 | ✅ |
| 缩放 | layer.scale() | CSS transform | ✅ |
| 自适应宽度 | stage.size() | CSS 百分比 | ✅ |
| 触摸事件 | Konva 事件 | 原生事件 | ✅ |
| 只读模式 | 不绑定事件 | 不绑定事件 | ✅ |
## 🔍 技术细节
### 坐标转换
**Konva 方式**:
```typescript
const pos = stage.getPointerPosition()
const relativePos = {
x: (pos.x - layer.x()) / scale - offset.x,
y: (pos.y - layer.y()) / scale - offset.y,
}
```
**DOM 方式**:
```typescript
const rect = container.getBoundingClientRect()
const relativePos = {
x: (clientX - rect.left) / scale - offset.x,
y: (clientY - rect.top) / scale - offset.y,
}
```
### 碰撞检测
**擦除功能的核心**:判断点击位置是否在元素内
#### 特殊标记20x20 正方形)
```typescript
const isPointInSpecialMark = (pos, mark) => {
const size = 20
return pos.x >= mark.x - size/2 && pos.x <= mark.x + size/2
&& pos.y >= mark.y - size/2 && pos.y <= mark.y + size/2
}
```
#### 矩形
```typescript
const isPointInRect = (pos, rect) => {
return pos.x >= rect.x && pos.x <= rect.x + rect.width
&& pos.y >= rect.y && pos.y <= rect.y + rect.height
}
```
#### 线条(点到线段距离)
```typescript
const isPointInLine = (pos, line) => {
const threshold = strokeWidth + 5
for (let i = 0; i < points.length - 2; i += 2) {
const distance = pointToLineDistance(pos, p1, p2)
if (distance < threshold) return true
}
return false
}
```
### 渲染方式
**Konva**: 手动管理 Konva 节点
```typescript
const rect = new Konva.Rect({ x, y, width, height })
layer.add(rect)
layer.draw()
```
**DOM**: Vue 响应式自动渲染
```vue
<div
v-for="shape in markingData.shapes"
:key="shape.id"
:style="{
left: `${shape.x}px`,
top: `${shape.y}px`,
width: `${shape.width}px`,
height: `${shape.height}px`,
}"
/>
```
## ✅ 测试清单
### 基础绘制功能
- [ ] 绘制矩形
- [ ] 绘制线条(笔工具)
- [ ] 添加文本注释
- [ ] 添加正确标记 ✓
- [ ] 添加错误标记 ✗
- [ ] 添加半对标记
### 交互功能
- [ ] 擦除工具(点击擦除)
- [ ] 擦除工具(拖动擦除)
- [ ] 撤销操作
- [ ] 重做操作
- [ ] 清空所有标记
### 缩放功能
- [ ] 手动缩放
- [ ] 自适应宽度模式
- [ ] 双指缩放(触摸设备)
### 快捷打分
- [ ] 快捷打分点击模式
- [ ] 加分模式
- [ ] 减分模式
- [ ] 边界检查
### 特殊场景
- [ ] 多图片渲染
- [ ] 只读模式(历史记录查看)
- [ ] 横屏/竖屏切换
- [ ] 数据导入/导出
### 性能测试
- [ ] 大量标记渲染100+
- [ ] 快速连续绘制
- [ ] 内存泄漏检查
## 🐛 已知问题
### 1. 触摸事件兼容性
**问题**: 小程序环境的触摸事件可能与浏览器不同
**解决**: 同时监听 `mousedown/touchstart` 等事件
### 2. SVG 渲染性能
**问题**: 大量 SVG 元素可能影响性能
**优化**:
- 合并相同类型的 SVG 到一个容器
- 使用虚拟滚动(如果需要)
### 3. 文本宽度估算
**问题**: 擦除功能需要估算文本宽度
**当前方案**: `width ≈ text.length * fontSize * 0.6`
**改进**: 可使用 Canvas measureText 或 DOM getBoundingClientRect
## 📝 迁移步骤
### 对于新功能
直接使用 `DomImageRenderer``useMarkingDom`
### 对于现有功能
1. 替换导入语句
2. 更新类型定义
3. 测试功能完整性
4. 删除旧的 Konva 相关代码(可选)
## 🔗 相关文件
### 核心文件
- `src/components/marking/composables/renderer/useMarkingDom.ts`
- `src/components/marking/components/renderer/DomImageRenderer.vue`
- `src/components/marking/composables/renderer/useMarkingCommand.ts`
### 使用示例
- `src/components/marking/components/renderer/QuestionRenderer.vue`
- `src/components/marking/components/renderer/MarkingImageViewerNew.vue`
- `src/components/marking/review/ReviewImageRenderer.vue`
### 工具栏
- `src/components/marking/components/renderer/TraceToolbar.vue`
## 📚 参考资料
### Vue 响应式渲染
- [Vue 3 响应式基础](https://cn.vuejs.org/guide/essentials/reactivity-fundamentals.html)
- [Vue 3 列表渲染](https://cn.vuejs.org/guide/essentials/list.html)
### SVG 绘图
- [MDN SVG 教程](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial)
- [SVG Path 命令](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Paths)
### 触摸事件
- [MDN Touch Events](https://developer.mozilla.org/zh-CN/docs/Web/API/Touch_events)
- [小程序触摸事件](https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html)
## 🎉 总结
本次迁移成功将 Konva Canvas 渲染改为 DOM + SVG 渲染,完全兼容微信小程序环境,同时保留了所有原有功能。使用 Vue 响应式系统大大简化了渲染逻辑,提高了代码可维护性。

View File

@ -0,0 +1,457 @@
<script setup lang="ts">
import type { DomMarkingData, MarkingTool } from '../../composables/renderer/useMarkingDom'
import { useMessage } from 'wot-design-uni'
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
}
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 actualScale = computed(() => {
if (!props.adaptiveWidth || !naturalWidth.value || !containerWidth.value) {
return props.scale
}
//
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',
}
})
// Layer
const message = useMessage()
const layerManager = useSimpleDomLayer({
scale: actualScale,
currentTool,
initialData: props.markingData,
onQuickScore: (value: number) => props.onQuickScore?.(value),
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> {
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
})
}
/**
* 获取layer管理器
*/
const getLayerManager = () => layerManager
//
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() {
if (!layerManager)
return
layerManager.handleMouseUp()
}
// URL
watch(
() => props.imageUrl,
async () => {
await loadImage()
},
{ 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,
})
//
onMounted(async () => {
await initCanvas()
if (props.adaptiveWidth) {
setupResizeObserver()
}
})
//
onUnmounted(() => {
cleanupResizeObserver()
})
</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"
>
<!-- 当前绘制中的形状 -->
<template v-if="layerManager && layerManager.currentDrawingShape.value">
<!-- 矩形 -->
<div
v-if="layerManager.currentDrawingShape.value.type === 'rect'"
class="pointer-events-none absolute"
:style="{
left: `${layerManager.currentDrawingShape.value.x}px`,
top: `${layerManager.currentDrawingShape.value.y}px`,
width: `${layerManager.currentDrawingShape.value.width}px`,
height: `${layerManager.currentDrawingShape.value.height}px`,
border: `${layerManager.currentDrawingShape.value.strokeWidth}px solid ${layerManager.currentDrawingShape.value.stroke}`,
}"
/>
<!-- 线条 -->
<svg
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>
</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}`,
}"
/>
<!-- 线条 -->
<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>
<!-- 特殊标记 -->
<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>
<!-- 错误标记 -->
<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>
</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

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { KonvaMarkingData } from '../../composables/renderer/useMarkingKonva'
import type { DomMarkingData } from '../../composables/renderer/useMarkingDom'
import type { ExamStudentMarkingQuestionResponse } from '@/api'
import { markingSettings } from '../../composables/useMarkingSettings'
import QuestionRenderer from './QuestionRenderer.vue'
@ -12,7 +12,7 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits<{
'marking-change': [questionIndex: number, imageIndex: number, data: KonvaMarkingData]
'marking-change': [questionIndex: number, imageIndex: number, data: DomMarkingData]
}>()
const scale = defineModel<number>('scale', { default: 1.0 })
@ -127,7 +127,7 @@ const questionDataList = computed(() => {
/**
* 处理标记变化
*/
function handleMarkingChange(questionIndex: number, imageIndex: number, data: KonvaMarkingData) {
function handleMarkingChange(questionIndex: number, imageIndex: number, data: DomMarkingData) {
emit('marking-change', questionIndex, imageIndex, data)
}
</script>
@ -157,6 +157,11 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
:scale="finalScale"
:image-layout="markingSettings.imageLayout"
:show-toolbar="markingSettings.showTraceToolbar"
:initial-marking-data="
questionData[questionIndex].remark
? JSON.parse(questionData[questionIndex].remark)
: undefined
"
class="question-item w-fit"
@marking-change="(imageIndex, data) => handleMarkingChange(questionIndex, imageIndex, data)"
/>

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import type { KonvaMarkingData, useSimpleKonvaLayer } from '../../composables/renderer/useMarkingKonva'
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
import type { DomMarkingData, useSimpleDomLayer } from '../../composables/renderer/useMarkingDom'
import { DictCode, useDict } from '@/composables/useDict'
import { MarkingTool } from '../../composables/renderer/useMarkingDom'
import { useMarkingData } from '../../composables/useMarkingData'
import { markingSettings } from '../../composables/useMarkingSettings'
import { useSimpleMarkingTool } from '../../composables/useSimpleMarkingTool'
import ProblemSheetDialog from '../dialog/ProblemSheetDialog.vue'
import KonvaImageRenderer from './KonvaImageRenderer.vue'
import DomImageRenderer from './DomImageRenderer.vue'
import TraceToolbar from './TraceToolbar.vue'
interface Question {
@ -23,7 +22,7 @@ interface Props {
scale?: number
imageLayout?: 'horizontal' | 'vertical'
showToolbar?: boolean
initialMarkingData?: KonvaMarkingData[]
initialMarkingData?: DomMarkingData[]
}
const props = withDefaults(defineProps<Props>(), {
@ -34,18 +33,18 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<{
'marking-change': [imageIndex: number, data: KonvaMarkingData]
'marking-change': [imageIndex: number, data: DomMarkingData]
}>()
const { currentTool } = useSimpleMarkingTool()
const markingData = useMarkingData()
// layer
const imageRendererRefs = ref<Map<number, InstanceType<typeof KonvaImageRenderer>>>(new Map())
const layerManagers = ref<Map<number, ReturnType<typeof useSimpleKonvaLayer>>>(new Map())
const imageRendererRefs = ref<Map<number, InstanceType<typeof DomImageRenderer>>>(new Map())
const layerManagers = ref<Map<number, ReturnType<typeof useSimpleDomLayer>>>(new Map())
//
const markingDataList = ref<KonvaMarkingData[]>(
const markingDataList = ref<DomMarkingData[]>(
props.question.imageUrls.map(
(_, index) =>
props.initialMarkingData?.[index] || {
@ -106,7 +105,7 @@ function setImageRendererRef(el: any, index: number) {
/**
* 处理layer准备就绪
*/
function handleLayerReady(imageIndex: number, manager: ReturnType<typeof useSimpleKonvaLayer>) {
function handleLayerReady(imageIndex: number, manager: ReturnType<typeof useSimpleDomLayer>) {
layerManagers.value.set(imageIndex, manager as any)
//
@ -156,8 +155,8 @@ function handleQuickScore(value: number) {
if (currentMarkingData.value) {
// -1
const currentScore =
currentMarkingData.value.score === -1
const currentScore
= currentMarkingData.value.score === -1
? markingSettings.value.scoreMode === 'subtract'
? props.question.fullScore
: 0
@ -265,9 +264,6 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
})
}
//
// currentTool layer
onMounted(() => {
markingData.currentMarkingSubmitData.value[props.questionIndex] = {
score: -1,
@ -308,13 +304,14 @@ defineExpose({
:key="`${question.id}_${imageIndex}`"
class="image-wrapper relative"
>
<KonvaImageRenderer
<DomImageRenderer
:ref="(el) => setImageRendererRef(el, imageIndex)"
:image-url="imageUrl"
:scale="scale"
:marking-data="markingDataList[imageIndex]"
:current-tool="currentTool"
class="image-item"
adaptive-width
@layer-ready="(manager) => handleLayerReady(imageIndex, manager)"
@quick-score="handleQuickScore"
/>

View File

@ -2,7 +2,7 @@
import type { MarkingSubmitData } from '../../composables/useMarkingData'
import { useSessionStorage } from '@vueuse/core'
import { DictCode, useDict } from '@/composables/useDict'
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
import { MarkingTool } from '../../composables/renderer/useMarkingDom'
import { markingSettings as settings } from '../../composables/useMarkingSettings'
interface Props {

View File

@ -1,6 +1,56 @@
import type { KonvaMarkingData, KonvaShape, SpecialMark, TextAnnotation } from './useMarkingKonva'
import { computed, ref } from 'vue'
/**
* Konva和DOM
*/
export interface BaseShape {
id: string
type: string
x: number
y: number
width?: number
height?: number
points?: number[]
text?: string
fontSize?: number
fill?: string
stroke?: string
strokeWidth?: number
[key: string]: any
}
/**
*
*/
export interface SpecialMark {
id: string
type: 'correct' | 'wrong' | 'half'
x: number
y: number
}
/**
*
*/
export interface TextAnnotation {
id: string
x: number
y: number
text: string
fontSize: number
color: string
}
/**
*
*/
export interface BaseMarkingData {
version: string
shapes: BaseShape[]
specialMarks: SpecialMark[]
annotations: TextAnnotation[]
}
/**
*
*/
@ -23,8 +73,8 @@ export class AddShapeCommand implements MarkingCommand {
description: string
constructor(
private data: KonvaMarkingData,
private shape: KonvaShape,
private data: BaseMarkingData,
private shape: BaseShape,
private layer: any,
) {
this.id = `cmd_${Date.now()}_${Math.random()}`
@ -34,14 +84,12 @@ export class AddShapeCommand implements MarkingCommand {
execute() {
this.data.shapes.push(this.shape)
// 在layer中渲染新形状的逻辑
}
undo() {
const index = this.data.shapes.findIndex(s => s.id === this.shape.id)
if (index > -1) {
this.data.shapes.splice(index, 1)
// 从layer中移除形状的逻辑
}
}
}
@ -56,7 +104,7 @@ export class AddSpecialMarkCommand implements MarkingCommand {
description: string
constructor(
private data: KonvaMarkingData,
private data: BaseMarkingData,
private mark: SpecialMark,
private layer: any,
) {
@ -87,7 +135,7 @@ export class AddTextCommand implements MarkingCommand {
description: string
constructor(
private data: KonvaMarkingData,
private data: BaseMarkingData,
private annotation: TextAnnotation,
private layer: any,
) {
@ -119,12 +167,12 @@ export class EraseCommand implements MarkingCommand {
timestamp: number
description: string
private erasedShape?: KonvaShape
private erasedShape?: BaseShape
private erasedMark?: SpecialMark
private erasedAnnotation?: TextAnnotation
constructor(
private data: KonvaMarkingData,
private data: BaseMarkingData,
private targetId: string,
private layer: any,
) {
@ -168,12 +216,12 @@ export class ClearAllCommand implements MarkingCommand {
timestamp: number
description = '清空所有标记'
private backupShapes: KonvaShape[] = []
private backupShapes: BaseShape[] = []
private backupMarks: SpecialMark[] = []
private backupAnnotations: TextAnnotation[] = []
constructor(
private data: KonvaMarkingData,
private data: BaseMarkingData,
private layer: any,
) {
this.id = `cmd_${Date.now()}_${Math.random()}`

View File

@ -0,0 +1,579 @@
/* eslint-disable ts/no-use-before-define */
import type { Ref } from 'vue'
import type { useMessage } from 'wot-design-uni'
import { computed, ref } from 'vue'
import { markingSettings } from '../useMarkingSettings'
import { useMarkingCommand } from './useMarkingCommand'
export enum MarkingTool {
SELECT = 'select',
RECT = 'rect',
TEXT = 'text',
PEN = 'pen',
CORRECT = 'correct',
WRONG = 'wrong',
HALF = 'half',
ERASER = 'eraser',
}
export interface DomShape {
id: string
type: string
x: number
y: number
width?: number
height?: number
points?: number[]
text?: string
fontSize?: number
fill?: string
stroke?: string
strokeWidth?: number
[key: string]: any
}
export interface SpecialMark {
id: string
type: 'correct' | 'wrong' | 'half'
x: number
y: number
}
export interface TextAnnotation {
id: string
x: number
y: number
text: string
fontSize: number
color: string
}
export interface DomMarkingData {
version: string
shapes: DomShape[]
specialMarks: SpecialMark[]
annotations: TextAnnotation[]
}
interface SimpleDomLayerProps {
scale: Ref<number>
currentTool: Ref<MarkingTool>
initialData?: DomMarkingData
/** 位移配置,用于多层叠加渲染 */
offset?: {
x: number
y: number
}
/** 快捷打分回调 */
onQuickScore?: (value: number) => void
/** 只读模式,不响应交互事件 */
readOnly?: boolean
message: ReturnType<typeof useMessage>
}
/**
* DOM图层管理器
* 使Vue响应式系统管理标记数据
*/
export function useSimpleDomLayer({
scale,
currentTool,
initialData,
offset = { x: 0, y: 0 },
onQuickScore,
readOnly = false,
message,
}: SimpleDomLayerProps) {
// 标记数据
const markingData = ref<DomMarkingData>(
{
version: '1.0',
shapes: [...(initialData?.shapes || [])],
specialMarks: [...(initialData?.specialMarks || [])],
annotations: [...(initialData?.annotations || [])],
},
)
// 命令系统
const commandSystem = useMarkingCommand()
// 当前绘制状态
const isDrawing = ref(false)
const currentDrawingShape = ref<DomShape | null>(null)
// 计算属性
const hasMarks = computed(() => {
return (
markingData.value.shapes.length > 0
|| markingData.value.specialMarks.length > 0
|| markingData.value.annotations.length > 0
)
})
/**
* ID
*/
const generateId = (): string => {
return `mark_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
*
*/
const handleMouseDown = (e: MouseEvent | TouchEvent, containerRect: DOMRect) => {
if (readOnly)
return
const pos = getRelativePosition(e, containerRect)
if (!pos)
return
// 快捷打分点击模式
if (markingSettings.value.quickScoreClickMode && currentTool.value === MarkingTool.SELECT) {
const mouseEvent = e as MouseEvent
if (mouseEvent.button === 0) {
handleQuickScoreClick(pos)
}
return
}
isDrawing.value = true
switch (currentTool.value) {
case MarkingTool.PEN:
startPenDrawing(pos)
break
case MarkingTool.RECT:
startRectDrawing(pos)
break
case MarkingTool.CORRECT:
addSpecialMark('correct', pos)
break
case MarkingTool.WRONG:
addSpecialMark('wrong', pos)
break
case MarkingTool.HALF:
addSpecialMark('half', pos)
break
case MarkingTool.TEXT:
addTextAnnotation(pos)
break
case MarkingTool.ERASER:
eraseAt(pos)
break
}
}
/**
*
*/
const handleMouseMove = (e: MouseEvent | TouchEvent, containerRect: DOMRect) => {
if (!isDrawing.value || readOnly)
return
const pos = getRelativePosition(e, containerRect)
if (!pos)
return
if (currentTool.value === MarkingTool.PEN) {
continuePenDrawing(pos)
}
else if (currentTool.value === MarkingTool.RECT) {
continueRectDrawing(pos)
}
else if (currentTool.value === MarkingTool.ERASER) {
eraseAt(pos)
}
}
/**
*
*/
const handleMouseUp = () => {
if (!isDrawing.value)
return
if (currentDrawingShape.value) {
finishDrawing()
}
isDrawing.value = false
currentDrawingShape.value = null
}
/**
*
*/
const getRelativePosition = (e: MouseEvent | TouchEvent, containerRect: DOMRect) => {
let clientX: number
let clientY: number
if (e instanceof MouseEvent) {
clientX = e.clientX
clientY = e.clientY
}
else {
const touch = e.touches[0] || e.changedTouches[0]
if (!touch)
return null
clientX = touch.clientX
clientY = touch.clientY
}
return {
x: (clientX - containerRect.left) / scale.value - offset.x,
y: (clientY - containerRect.top) / scale.value - offset.y,
}
}
/**
*
*/
const startPenDrawing = (pos: { x: number, y: number }) => {
currentDrawingShape.value = {
id: generateId(),
type: 'line',
x: 0,
y: 0,
points: [pos.x, pos.y],
stroke: markingSettings.value.penColor,
strokeWidth: markingSettings.value.penWidth,
}
}
/**
*
*/
const continuePenDrawing = (pos: { x: number, y: number }) => {
if (currentDrawingShape.value && currentDrawingShape.value.points) {
currentDrawingShape.value.points.push(
Number(pos.x.toFixed(1)),
Number(pos.y.toFixed(1)),
)
}
}
/**
*
*/
const startRectDrawing = (pos: { x: number, y: number }) => {
currentDrawingShape.value = {
id: generateId(),
type: 'rect',
x: pos.x,
y: pos.y,
width: 0,
height: 0,
stroke: markingSettings.value.penColor,
strokeWidth: 2,
startX: pos.x,
startY: pos.y,
}
}
/**
*
*/
const continueRectDrawing = (pos: { x: number, y: number }) => {
if (currentDrawingShape.value && currentDrawingShape.value.startX !== undefined) {
const startX = currentDrawingShape.value.startX
const startY = currentDrawingShape.value.startY!
const width = pos.x - startX
const height = pos.y - startY
currentDrawingShape.value.width = Math.abs(width)
currentDrawingShape.value.height = Math.abs(height)
currentDrawingShape.value.x = width < 0 ? pos.x : startX
currentDrawingShape.value.y = height < 0 ? pos.y : startY
}
}
/**
*
*/
const finishDrawing = () => {
if (!currentDrawingShape.value)
return
// 清理临时属性
const shape = { ...currentDrawingShape.value }
delete shape.startX
delete shape.startY
console.log('[SimpleDomLayer] finishDrawing - creating AddShapeCommand for:', shape)
const command = new commandSystem.AddShapeCommand(markingData.value, shape, null)
commandSystem.executeCommand(command)
}
/**
*
*/
const addSpecialMark = (type: 'correct' | 'wrong' | 'half', pos: { x: number, y: number }) => {
const mark: SpecialMark = {
id: generateId(),
type,
x: pos.x,
y: pos.y,
}
console.log('[SimpleDomLayer] addSpecialMark - creating AddSpecialMarkCommand for:', mark)
const command = new commandSystem.AddSpecialMarkCommand(markingData.value, mark, null)
commandSystem.executeCommand(command)
isDrawing.value = false
}
/**
*
*/
const addTextAnnotation = async (pos: { x: number, y: number }) => {
const text = await message.show({
title: '请输入文字:',
type: 'prompt',
}).then((res) => {
return res.value
})
if (text) {
const annotation: TextAnnotation = {
id: generateId(),
x: pos.x,
y: pos.y,
text: text.toString(),
fontSize: markingSettings.value.textSize,
color: markingSettings.value.textColor,
}
console.log('[SimpleDomLayer] addTextAnnotation - creating AddTextCommand for:', annotation)
const command = new commandSystem.AddTextCommand(markingData.value, annotation, null)
commandSystem.executeCommand(command)
}
isDrawing.value = false
}
/**
*
*/
const eraseAt = (pos: { x: number, y: number }) => {
// 查找点击位置的元素
const foundId = findElementAtPosition(pos)
if (foundId) {
console.log('[SimpleDomLayer] eraseAt - creating EraseCommand for:', foundId)
const command = new commandSystem.EraseCommand(markingData.value, foundId, null)
commandSystem.executeCommand(command)
}
}
/**
*
*/
const findElementAtPosition = (pos: { x: number, y: number }): string | null => {
// 检查特殊标记(优先级最高,因为它们通常最小)
for (const mark of markingData.value.specialMarks) {
if (isPointInSpecialMark(pos, mark)) {
return mark.id
}
}
// 检查文本注释
for (const annotation of markingData.value.annotations) {
if (isPointInText(pos, annotation)) {
return annotation.id
}
}
// 检查形状
for (const shape of markingData.value.shapes) {
if (isPointInShape(pos, shape)) {
return shape.id
}
}
return null
}
/**
*
*/
const isPointInSpecialMark = (pos: { x: number, y: number }, mark: SpecialMark): boolean => {
const size = 20 // 特殊标记的大小
return (
pos.x >= mark.x - size / 2
&& pos.x <= mark.x + size / 2
&& pos.y >= mark.y - size / 2
&& pos.y <= mark.y + size / 2
)
}
/**
*
*/
const isPointInText = (pos: { x: number, y: number }, text: TextAnnotation): boolean => {
// 简单估算文本宽度和高度
const estimatedWidth = text.text.length * text.fontSize * 0.6
const estimatedHeight = text.fontSize * 1.2
return (
pos.x >= text.x
&& pos.x <= text.x + estimatedWidth
&& pos.y >= text.y
&& pos.y <= text.y + estimatedHeight
)
}
/**
*
*/
const isPointInShape = (pos: { x: number, y: number }, shape: DomShape): boolean => {
if (shape.type === 'rect') {
return (
pos.x >= shape.x
&& pos.x <= shape.x + (shape.width || 0)
&& pos.y >= shape.y
&& pos.y <= shape.y + (shape.height || 0)
)
}
else if (shape.type === 'line' && shape.points) {
// 检查点是否靠近线条
const threshold = (shape.strokeWidth || 2) + 5
for (let i = 0; i < shape.points.length - 2; i += 2) {
const x1 = shape.points[i]
const y1 = shape.points[i + 1]
const x2 = shape.points[i + 2]
const y2 = shape.points[i + 3]
const distance = pointToLineDistance(pos, { x: x1, y: y1 }, { x: x2, y: y2 })
if (distance < threshold) {
return true
}
}
}
return false
}
/**
* 线
*/
const pointToLineDistance = (
point: { x: number, y: number },
lineStart: { x: number, y: number },
lineEnd: { x: number, y: number },
): number => {
const A = point.x - lineStart.x
const B = point.y - lineStart.y
const C = lineEnd.x - lineStart.x
const D = lineEnd.y - lineStart.y
const dot = A * C + B * D
const lenSq = C * C + D * D
let param = -1
if (lenSq !== 0) {
param = dot / lenSq
}
let xx: number
let yy: number
if (param < 0) {
xx = lineStart.x
yy = lineStart.y
}
else if (param > 1) {
xx = lineEnd.x
yy = lineEnd.y
}
else {
xx = lineStart.x + param * C
yy = lineStart.y + param * D
}
const dx = point.x - xx
const dy = point.y - yy
return Math.sqrt(dx * dx + dy * dy)
}
/**
*
*/
const handleQuickScoreClick = (pos: { x: number, y: number }) => {
const value = markingSettings.value.quickScoreClickValue
const scoreMode = markingSettings.value.scoreMode
const finalValue = scoreMode === 'add' ? value : -value
// 创建文字标记
const text = scoreMode === 'add' ? `+${value}` : `-${value}`
const annotation: TextAnnotation = {
id: generateId(),
x: pos.x,
y: pos.y,
text,
fontSize: 48,
color: scoreMode === 'add' ? '#ff4d4f' : '#1890ff',
}
console.log('[SimpleDomLayer] handleQuickScoreClick - creating text annotation:', annotation)
const command = new commandSystem.AddTextCommand(markingData.value, annotation, null)
commandSystem.executeCommand(command)
// 回调通知外部组件更新分数
if (onQuickScore) {
onQuickScore(finalValue)
}
isDrawing.value = false
}
/**
*
*/
const clearAllMarks = () => {
console.log('[SimpleDomLayer] clearAllMarks - creating ClearAllCommand')
const command = new commandSystem.ClearAllCommand(markingData.value, null)
commandSystem.executeCommand(command)
}
return {
// 数据状态
markingData,
hasMarks,
currentDrawingShape,
isDrawing,
// 历史记录状态
canUndo: commandSystem.canUndo,
canRedo: commandSystem.canRedo,
// 事件处理
handleMouseDown,
handleMouseMove,
handleMouseUp,
// 操作方法
clearAllMarks,
undo: () => {
console.log('[SimpleDomLayer] undo triggered')
return commandSystem.undo()
},
redo: () => {
console.log('[SimpleDomLayer] redo triggered')
return commandSystem.redo()
},
// 数据导入导出
exportData: () => JSON.stringify(markingData.value),
importData: (data: string) => {
try {
markingData.value = JSON.parse(data)
return true
}
catch {
return false
}
},
}
}

View File

@ -1,4 +1,9 @@
import type { KonvaShape, TextAnnotation } from './useMarkingKonva'
/**
* Konva兼容
* DOM渲染不再需要这些工具函数
* @deprecated 使 useMarkingDom
*/
import type { BaseShape, TextAnnotation } from './useMarkingCommand'
import Konva from 'konva'
export function useMarkingTools() {
@ -12,7 +17,7 @@ export function useMarkingTools() {
y: number,
width: number,
height: number,
options?: Partial<KonvaShape>,
options?: Partial<BaseShape>,
): Konva.Rect => {
return new Konva.Rect({
id: generateId(),
@ -48,7 +53,7 @@ export function useMarkingTools() {
}
// 创建线条
const createLine = (points: number[], options?: Partial<KonvaShape>): Konva.Line => {
const createLine = (points: number[], options?: Partial<BaseShape>): Konva.Line => {
return new Konva.Line({
id: generateId(),
points,

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import KonvaImageRenderer from '../components/renderer/KonvaImageRenderer.vue'
import { MarkingTool } from '../composables/renderer/useMarkingKonva'
import DomImageRenderer from '../components/renderer/DomImageRenderer.vue'
import { MarkingTool } from '../composables/renderer/useMarkingDom'
interface Props {
imageUrls: string[]
@ -72,7 +72,7 @@ function handleLayerReady() {
>
<!-- 图片渲染器 -->
<view class="image-wrapper relative overflow-hidden rounded-2 shadow-sm">
<KonvaImageRenderer
<DomImageRenderer
:image-url="imageUrl"
:scale="scale"
:marking-data="processedMarkingData[index]"

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
static/app/icons/20x20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

BIN
static/app/icons/29x29.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

BIN
static/app/icons/40x40.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

BIN
static/app/icons/58x58.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
static/app/icons/60x60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
static/app/icons/72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
static/app/icons/76x76.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/app/icons/80x80.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
static/app/icons/87x87.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
static/app/icons/96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -179,9 +179,6 @@ export default ({ command, mode }) => {
}
: undefined,
},
esbuild: {
drop: VITE_DELETE_CONSOLE === 'true' ? ['console', 'debugger'] : ['debugger'],
},
build: {
sourcemap: false,
// 方便非h5端调试