feat(template): 增强答题卡编辑器功能并优化类型定义
- 新增模板详情编辑功能,支持从后端加载并回填数据 - 在画布中增加网格、标尺和坐标显示辅助工具 - 优化模板列表操作按钮布局,新增打印功能 - 扩展响应类型定义,增加 success 字段和 TemplateDetail 接口 - 添加 ESLint 配置排除 Markdown 文件,并完善事件总线类型 - 新增请求工具使用文档,说明错误处理机制
This commit is contained in:
@ -12,7 +12,7 @@ export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增模板
|
||||
* 新增模板, 更新模板
|
||||
*/
|
||||
export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) {
|
||||
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
|
||||
@ -26,7 +26,7 @@ export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) {
|
||||
* 获取模板详情
|
||||
*/
|
||||
export function fetchTemplateDetail(id: number) {
|
||||
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
|
||||
return request<App.Service.Response<Api.Template.TemplateDetail>>({
|
||||
url: `/Base/ActivityMain/GetTemplete_PagByID?id=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
76
apps/admin/src/service/request/readme.md
Normal file
76
apps/admin/src/service/request/readme.md
Normal file
@ -0,0 +1,76 @@
|
||||
## error 字段 不是后端接口直接返回的 ,而是前端请求工具 request (基于 @sa/axios 的 createFlatRequest 封装) 为了简化错误处理而增加的。
|
||||
|
||||
它的机制如下:
|
||||
|
||||
- 请求成功 : error 为 null , data 为后端返回的响应体(包含 code , msg , data , success 等字段)。
|
||||
- 请求失败 (如网络断开、超时): error 会包含错误信息对象, data 为 null 。
|
||||
这种写法让您可以直接解构返回值判断请求是否成功,避免了使用 `try-catch` 包裹。
|
||||
|
||||
总结:
|
||||
|
||||
- response (您重命名的 data ) :包含后端返回的 { success, code, msg, data } 。
|
||||
- error :前端请求库生成的错误对象,用于判断 HTTP 请求层面的成败
|
||||
|
||||
参考:
|
||||
|
||||
- [@sa/axios 文档](https://bytedance.larkoffice.com/wiki/wikcnn33hQ3h3FkqCnqCn33h3Fk)
|
||||
|
||||
例子:
|
||||
|
||||
```typescript
|
||||
import request from '@/service/request'
|
||||
|
||||
// 成功请求
|
||||
const { data, error } = await request.get('/api/success')
|
||||
if (error) {
|
||||
console.error('请求失败:', error)
|
||||
return
|
||||
}
|
||||
console.log('请求成功:', data)
|
||||
|
||||
// 失败请求(如网络断开)
|
||||
const { data: failedData, error: failedError } = await request.get('/api/fail')
|
||||
if (failedError) {
|
||||
console.error('请求失败:', failedError)
|
||||
return
|
||||
}
|
||||
console.log('请求成功:', failedData) // 不会执行到这里
|
||||
|
||||
/**
|
||||
* 初始化模板详情
|
||||
*/
|
||||
async function initData() {
|
||||
const id = Number(route.query.id)
|
||||
if (id) {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data: response, error } = await fetchTemplateDetail(id)
|
||||
if (!error && response) {
|
||||
const { data, success } = response
|
||||
if (success) {
|
||||
const { Name, Width, Height, BackGroundUrl, TempContent } = data || {}
|
||||
templateInfo.value = {
|
||||
name: Name,
|
||||
width: Width,
|
||||
height: Height,
|
||||
backGroundUrl: BackGroundUrl,
|
||||
}
|
||||
imgSrc.value = BackGroundUrl || ''
|
||||
|
||||
if (TempContent) {
|
||||
try {
|
||||
regions.value = JSON.parse(TempContent || '[]')
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse tempContent', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
18
apps/admin/src/typings/api/template.d.ts
vendored
18
apps/admin/src/typings/api/template.d.ts
vendored
@ -32,6 +32,24 @@ declare namespace Api {
|
||||
tempContent: string
|
||||
}
|
||||
|
||||
/** template detail */
|
||||
interface TemplateDetail {
|
||||
/** template id */
|
||||
ID: number
|
||||
/** template name */
|
||||
Name: string
|
||||
/** width */
|
||||
Width: number
|
||||
/** height */
|
||||
Height: number
|
||||
/** background url */
|
||||
BackGroundUrl: string
|
||||
/** template content */
|
||||
TempContent: string
|
||||
/** competition id */
|
||||
CompetitionId?: number
|
||||
}
|
||||
|
||||
/** template search params */
|
||||
interface TemplateSearchParams extends PaginatingCommonParams {
|
||||
/** competition activity name */
|
||||
|
||||
4
apps/admin/src/typings/app.d.ts
vendored
4
apps/admin/src/typings/app.d.ts
vendored
@ -633,11 +633,13 @@ declare namespace App {
|
||||
/** The backend service response data */
|
||||
interface Response<T = unknown> {
|
||||
/** The backend service response code */
|
||||
code: string
|
||||
code: number
|
||||
/** The backend service response message */
|
||||
msg: string
|
||||
/** The backend service response data */
|
||||
data: T
|
||||
/** The backend service response success status */
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
/** The demo backend service response data */
|
||||
|
||||
@ -1,23 +1,26 @@
|
||||
/* eslint-disable ts/no-unsafe-function-type */
|
||||
export class EventBus {
|
||||
private listeners: Record<string, Function[]> = {};
|
||||
private listeners: Record<string, Function[]> = {}
|
||||
|
||||
on(event: string, callback: Function) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
this.listeners[event] = []
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
off(event: string, callback: Function) {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
||||
if (!this.listeners[event])
|
||||
return
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
|
||||
}
|
||||
|
||||
emit(event: string, data?: any) {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event].forEach(cb => cb(data));
|
||||
if (!this.listeners[event])
|
||||
return
|
||||
this.listeners[event].forEach(cb => cb(data))
|
||||
}
|
||||
}
|
||||
|
||||
export const $mitt = new EventBus();
|
||||
export const open_book_topic_edit = 'open_book_topic_edit';
|
||||
export const $mitt = new EventBus()
|
||||
export const open_book_topic_edit = 'open_book_topic_edit'
|
||||
|
||||
184
apps/admin/src/views/template/template-detail/README.md
Normal file
184
apps/admin/src/views/template/template-detail/README.md
Normal file
@ -0,0 +1,184 @@
|
||||
# 答题卡排版编辑器 (Template Editor) 业务流程文档
|
||||
|
||||
本文档详细描述了 `src/views/template/template-detail` 模块的业务流程与核心逻辑。
|
||||
|
||||
## 1. 核心功能概述
|
||||
|
||||
该模块主要用于创建和编辑答题卡模板。用户可以上传底图(支持图片或 PDF),在底图上通过配置参数批量生成答题区域,并对生成的区域进行拖拽、缩放、旋转等排版操作,最终保存模板数据到后端。
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
- `index.vue`: **主入口文件**。负责整体状态管理、组件组装(Canvas + Settings)、数据初始化、保存逻辑以及核心的“生成区域”逻辑。
|
||||
- `hooks/useUpload.ts`: **上传逻辑封装**。负责文件处理(PDF 转图片)、OSS 上传、Token 获取等。
|
||||
- `modules/TemplateCanvas.vue`: **画布组件**。负责渲染底图、绘制答题区域、处理区域的交互(拖拽、缩放、旋转、多选/单选、对齐辅助线)。
|
||||
- `modules/TemplateSettings.vue`: **设置面板组件**。负责展示模板基础信息、提供区域生成的参数配置(行列、宽高、间距)、展示已生成的区域列表。
|
||||
|
||||
## 3. 详细业务流程
|
||||
|
||||
### 3.1 初始化 (Initialization)
|
||||
|
||||
1. **加载页面**: 进入 `index.vue`。
|
||||
2. **获取详情**: `onMounted` 钩子调用 `initData`。
|
||||
- 检查 URL 参数 `id`。
|
||||
- 如果有 `id`,调用 `fetchTemplateDetail(id)` 获取模板详情。
|
||||
- **数据回填**:
|
||||
- 将 `Name`, `Width`, `Height` 回填到 `templateInfo`。
|
||||
- 将 `BackGroundUrl` 回填到 `imgSrc` 和 `templateInfo.backGroundUrl`。
|
||||
- 解析 `TempContent` (JSON 字符串) 回填到 `regions` 列表。
|
||||
|
||||
### 3.2 上传底图 (Upload Background)
|
||||
|
||||
逻辑主要在 `hooks/useUpload.ts` 中实现:
|
||||
|
||||
1. **触发上传**: 用户点击“上传底稿”。
|
||||
2. **上传检查 (`handleUploadCheck`)**:
|
||||
- 如果已有底图,弹出确认框,提示“更换底图将清空当前已生成的布局”。
|
||||
- 确认后打开文件选择器。
|
||||
3. **文件处理 (`handleUpload`)**:
|
||||
- **PDF 处理**: 使用 `pdfjs-dist` 渲染 PDF 第一页,按 A4 比例(2100px 宽)转换为 Canvas,再导出为 Blob。
|
||||
- **图片处理**: 直接读取图片文件。
|
||||
4. **OSS 上传**:
|
||||
- 调用 `getAliOssTokenAxios` 获取 STS Token。
|
||||
- 使用 `ali-oss` 客户端将文件上传至 OSS。
|
||||
5. **状态更新**:
|
||||
- 更新 `imgSrc` 为 OSS 返回的 URL。
|
||||
- 更新 `templateInfo` 的宽高。
|
||||
- **清空现有区域** (`regions`) 和选中状态 (`selectedRegionIds`)。
|
||||
|
||||
### 3.3 区域生成 (Region Generation)
|
||||
|
||||
逻辑在 `index.vue` 的 `handleGenerate` 中:
|
||||
|
||||
1. **配置参数**: 用户在 `TemplateSettings` 面板输入行列数、宽高、间距。
|
||||
2. **计算编号**:
|
||||
- 遍历现有 `regions`,解析 `label` (如 "Q1", "Q2"),找到最大编号 `maxNum`。
|
||||
- 新生成的区域编号从 `maxNum + 1` 开始递增。
|
||||
3. **批量创建**:
|
||||
- 根据行 (`rows`)、列 (`cols`) 双重循环。
|
||||
- 计算每个区域的 `x, y` 坐标:`start + index * (size + gap)`。
|
||||
- 为这批区域生成同一个 `groupId` (UUID)。
|
||||
4. **自动选中**:
|
||||
- 将新生成的区域加入 `regions` 列表。
|
||||
- 如果生成了多个区域,自动将它们全部加入 `selectedRegionIds`,方便用户立即进行批量拖拽。
|
||||
|
||||
### 3.4 画布交互 (Canvas Interaction)
|
||||
|
||||
逻辑在 `modules/TemplateCanvas.vue` 中,核心依赖 `vue3-draggable-resizable`:
|
||||
|
||||
1. **渲染**:
|
||||
- 底图作为背景。
|
||||
- 遍历 `regions` 渲染 `VueDraggableResizable` 组件。
|
||||
2. **选择逻辑**:
|
||||
- **单选**: 点击某个区域,清除其他选中,只选中当前。
|
||||
- **多选**: 按住 Shift/Ctrl/Meta 点击,切换当前区域的选中状态。
|
||||
- **取消选中**: 点击画布空白处 (`onCanvasMouseDown`),清空 `selectedRegionIds`。
|
||||
- **从多选切换单选**: 在多选状态下,**松开鼠标** (`onRegionMouseUp`) 且没有发生拖拽(位移 < 3px)时,切换为单选当前区域。
|
||||
3. **拖拽与移动 (`onDragging`)**:
|
||||
- **单体移动**: 拖拽单个元素。
|
||||
- **批量移动**: 如果当前拖拽的元素在选中集合中,计算其位移差 (`deltaX`, `deltaY`),同步更新所有选中元素的坐标。
|
||||
- **辅助线**: 计算当前拖拽元素中心点与画布中心点的距离,小于阈值时显示吸附辅助线。
|
||||
4. **缩放与旋转**:
|
||||
- **缩放**: 通过组件自带句柄调整 `w, h`。
|
||||
- **旋转**: 自定义旋转手柄,计算鼠标角度变化,更新 `rotation` 属性。
|
||||
|
||||
### 3.5 保存 (Save)
|
||||
|
||||
逻辑在 `index.vue` 的 `handleSave` 中:
|
||||
|
||||
1. **数据组装**:
|
||||
- 收集 `templateInfo` (名称、宽高、背景图)。
|
||||
- 将 `regions` 数组序列化为 JSON 字符串 (`tempContent`)。
|
||||
2. **API 调用**: 调用 `fetchAddTemplate` 提交数据。
|
||||
3. **反馈**: 成功后提示并返回上一页。
|
||||
|
||||
## 4. 数据结构说明
|
||||
|
||||
### Region (区域)
|
||||
|
||||
```typescript
|
||||
interface Region {
|
||||
id: string // 唯一标识
|
||||
groupId: string // 批次ID (同一次生成的区域共享)
|
||||
x: number // X坐标
|
||||
y: number // Y坐标
|
||||
w: number // 宽度
|
||||
h: number // 高度
|
||||
label: string // 标签 (如 Q1)
|
||||
selected: boolean // (前端临时状态)
|
||||
rotation?: number // 旋转角度
|
||||
}
|
||||
```
|
||||
|
||||
### TemplateInfo (模板信息)
|
||||
|
||||
```typescript
|
||||
interface TemplateInfo {
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
backGroundUrl?: string
|
||||
}
|
||||
```
|
||||
|
||||
### 核心原理
|
||||
|
||||
我们在前端渲染时,是基于 A4 标准尺寸 (210mm × 297mm) 进行比例换算的。
|
||||
|
||||
- 校验逻辑 :如果系统认为当前画布宽度对应 210mm,那么我们可以在画布上绘制一个 10mm × 10mm 的标准网格。
|
||||
- 测试方法 :只需要上传一张 带有刻度尺 或 已知网格 的标准 A4 图片/PDF,开启系统网格,观察系统的红线是否与图片上的刻度重合,即可验证位置和比例是否精确。
|
||||
|
||||
### 网格辅助层和开关控制,具体修改如下:
|
||||
|
||||
1. TemplateCanvas.vue (画布组件)
|
||||
- 新增了 showGrid 属性和网格渲染层。
|
||||
- 自动计算:根据当前画布宽度( templateInfo.width )与 A4 宽度(210mm)的比例,动态计算出 10mm 在屏幕上的像素值。
|
||||
- 使用 SVG pattern 技术绘制高精度的 10mm 标准红线网格。
|
||||
|
||||
2. index.vue (主页面)
|
||||
- 在顶部工具栏增加了 "显示网格/隐藏网格" 按钮。
|
||||
- 现在的操作栏顺序为:返回 -> 标题 -> 网格开关 -> 上传按钮 -> 保存按钮。
|
||||
|
||||
### 如何进行测试(操作步骤)
|
||||
|
||||
1. 准备测试底图 :找UI设计一张 A4 尺寸 (210x297mm) 的图片或 PDF,在上面画上 10mm 间隔的标尺或网格。(系统已支持直接上传 PDF,会自动渲染为高清底图)。
|
||||
2. 上传底图 :在系统中点击“上传底稿”上传该文件。
|
||||
3. 开启网格 :点击顶部新增的 "显示网格" 按钮。
|
||||
4. 视觉验证 :
|
||||
- 观察系统生成的 红色虚线网格 是否与您图片上的 标尺/网格 完美重合。
|
||||
- 如果重合,说明系统的坐标映射与物理 A4 纸张是完全对应的。
|
||||
- 如果不重合(例如系统网格偏大或偏小),则说明上传的图片本身比例不是标准的 A4 比例,或者存在边缘留白问题。
|
||||
|
||||
### A4 尺寸与像素换算说明
|
||||
|
||||
下面是页面渲染在页面的真实图片的信息:
|
||||
Rendered size: 2100 × 2970 px
|
||||
Rendered aspect ratio: 70∶99
|
||||
Intrinsic size: 2100 × 2969 px
|
||||
Intrinsic aspect ratio: 2100∶2969
|
||||
File size: 526 kB
|
||||
Current source: https://oss.qyzhjy.com/temp/1769655687535/book.jpg
|
||||
|
||||
比如生成出来的图片尺寸 2100px × 2970px , ,这是系统专门针对 A4 纸张优化的标准分辨率。
|
||||
|
||||
1. 换算公式
|
||||
我们的代码(在 useUpload.ts 中)在处理 PDF 转图片时,强制将宽度设定为 2100px 。基于 A4 标准宽度 210mm ,换算关系如下:
|
||||
[ o bj ec tO bj ec t ] 1 mm = 10 px
|
||||
这个比例非常便于计算:
|
||||
|
||||
- 宽度 : [ o bj ec tO bj ec t ] 210 mm × 10 = 2100 px
|
||||
- 高度 : [ o bj ec tO bj ec t ] 297 mm × 10 = 2970 px
|
||||
- DPI : [ o bj ec tO bj ec t ] 10 px/mm × 25.4 ≈ 254 DPI 2. 如何验证比例是否正确?
|
||||
是的,只要确保**比例(Aspect Ratio)**是对的,且系统能正确识别这是 A4 纸即可。
|
||||
|
||||
我在代码中已经为您实现了 动态网格系统 ,它不依赖固定的像素值,而是根据当前图片宽度反向计算:
|
||||
|
||||
1. 系统读取当前图片宽度(例如 2100px)。
|
||||
2. 系统知道这是 A4 纸(210mm)。
|
||||
3. 系统自动计算出 10mm = (2100 / 21) = 100px 。
|
||||
4. 系统在屏幕上绘制间隔为 100px 的网格线。
|
||||
验证方法: 如果您上传了一张分辨率较低的图片(例如 1050px 宽),系统会自动计算出 1mm = 5px ,网格线会变为 50px 间隔。 只要网格线能与您图片上的物理刻度对齐,就说明换算和位置是绝对精准的。
|
||||
5. 结论
|
||||
|
||||
- 您当前的图片 2100 x 2970 是 完美标准 。
|
||||
- 如果您上传其他尺寸,只要长宽比接近 A4 (1:1.414),我们的网格系统都能自动适配并准确显示 10mm 刻度。
|
||||
- 您可以使用我在顶部新增的 “显示网格” 按钮来直观验证这一点。
|
||||
@ -3,10 +3,10 @@
|
||||
import type { GenSettings, Region, TemplateInfo } from './types'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { NButton, NUpload, useMessage } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { fetchAddTemplate } from '@/service/api/template'
|
||||
import { fetchAddTemplate, fetchTemplateDetail } from '@/service/api/template'
|
||||
import { useUpload } from './hooks/useUpload'
|
||||
import TemplateCanvas from './modules/TemplateCanvas.vue'
|
||||
import TemplateSettings from './modules/TemplateSettings.vue'
|
||||
@ -17,16 +17,16 @@ const { routerBack } = useRouterPush()
|
||||
|
||||
// 模版基础信息
|
||||
const templateInfo = ref<TemplateInfo>({
|
||||
name: '期末大考标准答题卡',
|
||||
name: '',
|
||||
width: 2100,
|
||||
height: 2994,
|
||||
height: 2997,
|
||||
})
|
||||
|
||||
const imgSrc = ref('')
|
||||
|
||||
// 生成配置
|
||||
const genSettings = ref<GenSettings>({
|
||||
cols: 1,
|
||||
cols: 6,
|
||||
rows: 1,
|
||||
w: 296,
|
||||
h: 309,
|
||||
@ -42,6 +42,11 @@ const regions = ref<Region[]>([])
|
||||
// 选中区域集合
|
||||
const selectedRegionIds = ref<Set<string>>(new Set())
|
||||
|
||||
// 视图控制
|
||||
const showGrid = ref(false)// 是否显示网格
|
||||
const showRuler = ref(true)// 是否显示标尺
|
||||
const showCoordinates = ref(false)// 是否显示坐标
|
||||
|
||||
// Use Hook
|
||||
const { loading, handleUpload, handleUploadCheck } = useUpload(imgSrc, templateInfo, regions, selectedRegionIds)
|
||||
|
||||
@ -135,6 +140,12 @@ async function handleSave() {
|
||||
}
|
||||
console.log(params, 'params')
|
||||
|
||||
// 校验
|
||||
if (!params.name) {
|
||||
message.error('请输入模板名称')
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await fetchAddTemplate(params)
|
||||
|
||||
if (!error) {
|
||||
@ -142,6 +153,54 @@ async function handleSave() {
|
||||
routerBack()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情编辑
|
||||
* 初始化数据
|
||||
*/
|
||||
async function initData() {
|
||||
const id = Number(route.query.id)
|
||||
if (id) {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data: response, error } = await fetchTemplateDetail(id)
|
||||
console.log(response, 'response')
|
||||
console.log(error, 'error')
|
||||
if (!error && response) {
|
||||
const { data, success } = response
|
||||
if (success) {
|
||||
const { Name, Width, Height, BackGroundUrl, TempContent } = data || {}
|
||||
templateInfo.value = {
|
||||
name: Name,
|
||||
width: Width,
|
||||
height: Height,
|
||||
backGroundUrl: BackGroundUrl,
|
||||
}
|
||||
imgSrc.value = BackGroundUrl || ''
|
||||
|
||||
if (TempContent) {
|
||||
try {
|
||||
regions.value = JSON.parse(TempContent || '[]')
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse tempContent', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to fetch template detail', e)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -157,12 +216,43 @@ async function handleSave() {
|
||||
<span class="text-lg font-medium">答题卡排版编辑器 V2.0</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton
|
||||
size="small"
|
||||
:type="showGrid ? 'primary' : 'default'"
|
||||
secondary
|
||||
@click="showGrid = !showGrid"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="carbon:grid" />
|
||||
</template>
|
||||
{{ showGrid ? '隐藏网格' : '显示网格' }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
:type="showRuler ? 'primary' : 'default'"
|
||||
secondary
|
||||
@click="showRuler = !showRuler"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="carbon:ruler" />
|
||||
</template>
|
||||
{{ showRuler ? '隐藏标尺' : '显示标尺' }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
:type="showCoordinates ? 'primary' : 'default'"
|
||||
secondary
|
||||
@click="showCoordinates = !showCoordinates"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="carbon:center-to-fit" />
|
||||
</template>
|
||||
{{ showCoordinates ? '关闭测距' : '开启测距' }}
|
||||
</NButton>
|
||||
<div class="h-6 w-px bg-gray-200" />
|
||||
<NUpload
|
||||
ref="uploadRef"
|
||||
:show-file-list="false"
|
||||
:custom-request="handleUpload"
|
||||
accept=".pdf,image/png,image/jpeg,image/jpg"
|
||||
style="display: none"
|
||||
ref="uploadRef" :show-file-list="false" :custom-request="handleUpload"
|
||||
accept=".pdf,image/png,image/jpeg,image/jpg" style="display: none"
|
||||
/>
|
||||
<NButton @click="handleUploadTrigger">
|
||||
<template #icon>
|
||||
@ -181,12 +271,13 @@ async function handleSave() {
|
||||
<TemplateCanvas
|
||||
v-model:template-info="templateInfo" v-model:regions="regions"
|
||||
v-model:selected-region-ids="selectedRegionIds" :img-src="imgSrc" :loading="loading"
|
||||
:show-grid="showGrid" :show-ruler="showRuler" :show-coordinates="showCoordinates"
|
||||
/>
|
||||
|
||||
<!-- Right: Settings Panel -->
|
||||
<TemplateSettings
|
||||
v-model:template-info="templateInfo" v-model:gen-settings="genSettings"
|
||||
:regions="regions" @generate="handleGenerate" @delete-region="deleteRegion" @clear-all="clearAll"
|
||||
v-model:template-info="templateInfo" v-model:gen-settings="genSettings" :regions="regions"
|
||||
@generate="handleGenerate" @delete-region="deleteRegion" @clear-all="clearAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { Region, TemplateInfo } from '../types'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { NButton, NScrollbar, NSpin } from 'naive-ui'
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import VueDraggableResizable from 'vue3-draggable-resizable'
|
||||
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
|
||||
|
||||
@ -12,6 +12,9 @@ const props = defineProps<{
|
||||
selectedRegionIds: Set<string>
|
||||
imgSrc: string
|
||||
loading: boolean
|
||||
showGrid?: boolean
|
||||
showRuler?: boolean
|
||||
showCoordinates?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -46,6 +49,95 @@ watch(() => props.imgSrc, () => {
|
||||
const dragStartPositions = ref<Map<string, { x: number, y: number }>>(new Map())
|
||||
const mouseDownPos = ref<{ x: number, y: number } | null>(null)
|
||||
|
||||
// --- 鼠标坐标显示 ---
|
||||
const mouseCoord = ref({ x: 0, y: 0 })
|
||||
const showMouseCoord = ref(false)
|
||||
|
||||
function onCanvasMouseMove(e: MouseEvent) {
|
||||
if (!props.showCoordinates) {
|
||||
showMouseCoord.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 获取画布元素
|
||||
const canvasEl = e.currentTarget as HTMLElement
|
||||
if (!canvasEl)
|
||||
return
|
||||
|
||||
const rect = canvasEl.getBoundingClientRect()
|
||||
|
||||
// 计算相对于画布左上角的像素坐标 (考虑缩放 scale)
|
||||
// 注意:e.clientX/Y 是视口坐标,rect.left/top 也是视口坐标
|
||||
// 鼠标在 canvas 元素内的像素位置
|
||||
const x = (e.clientX - rect.left) / scale.value
|
||||
const y = (e.clientY - rect.top) / scale.value
|
||||
|
||||
// 转换为毫米 (1mm = 10px)
|
||||
mouseCoord.value = {
|
||||
x: Math.round(x / 10),
|
||||
y: Math.round(y / 10),
|
||||
}
|
||||
showMouseCoord.value = true
|
||||
}
|
||||
|
||||
function onCanvasMouseLeave() {
|
||||
showMouseCoord.value = false
|
||||
}
|
||||
|
||||
// --- 网格系统 ---
|
||||
// A4 纸张宽度 210mm
|
||||
const A4_WIDTH_MM = 210
|
||||
|
||||
// 计算 10mm 在当前画布宽度下的像素值
|
||||
const gridSizePx = computed(() => {
|
||||
if (!props.templateInfo.width)
|
||||
return 0
|
||||
const pixelsPerMm = props.templateInfo.width / A4_WIDTH_MM
|
||||
return pixelsPerMm * 10 // 10mm
|
||||
})
|
||||
|
||||
// --- 标尺生成逻辑 ---
|
||||
const rulerPathH = computed(() => {
|
||||
if (!gridSizePx.value || !props.templateInfo.width)
|
||||
return ''
|
||||
|
||||
let d = ''
|
||||
// 10mm 一格
|
||||
const step = gridSizePx.value
|
||||
const count = Math.ceil(props.templateInfo.width / step)
|
||||
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const x = i * step
|
||||
// 大刻度 (10mm)
|
||||
d += `M${x} 0 V20 `
|
||||
// 中间加 1 个 5mm 刻度
|
||||
const half = x + step / 2
|
||||
if (half < props.templateInfo.width) {
|
||||
d += `M${half} 12 V20 `
|
||||
}
|
||||
}
|
||||
return d
|
||||
})
|
||||
|
||||
const rulerPathV = computed(() => {
|
||||
if (!gridSizePx.value || !props.templateInfo.height)
|
||||
return ''
|
||||
|
||||
let d = ''
|
||||
const step = gridSizePx.value
|
||||
const count = Math.ceil(props.templateInfo.height / step)
|
||||
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const y = i * step
|
||||
d += `M0 ${y} H20 `
|
||||
const half = y + step / 2
|
||||
if (half < props.templateInfo.height) {
|
||||
d += `M12 ${half} H20 `
|
||||
}
|
||||
}
|
||||
return d
|
||||
})
|
||||
|
||||
// --- 拖拽与调整大小逻辑 ---
|
||||
|
||||
/**
|
||||
@ -316,85 +408,165 @@ onUnmounted(() => {
|
||||
width: `${templateInfo.width}px`,
|
||||
height: `${templateInfo.height}px`,
|
||||
}"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
>
|
||||
<!-- 背景图片 -->
|
||||
<img
|
||||
v-if="imgSrc" :src="imgSrc"
|
||||
class="pointer-events-none absolute inset-0 h-full w-full select-none object-contain"
|
||||
<!-- 标尺 (外部) -->
|
||||
<template v-if="showRuler && gridSizePx > 0">
|
||||
<!-- 横向标尺 -->
|
||||
<div class="absolute left-0 h-[20px] w-full border-b border-gray-300 bg-white/80 backdrop-blur-sm -top-[20px]">
|
||||
<svg width="100%" height="20" class="overflow-visible">
|
||||
<path :d="rulerPathH" stroke="#666" stroke-width="1" fill="none" />
|
||||
<!-- 刻度值 -->
|
||||
<text
|
||||
v-for="i in Math.ceil(templateInfo.width / gridSizePx)"
|
||||
:key="`h-${i}`"
|
||||
:x="i * gridSizePx + 2"
|
||||
y="10"
|
||||
font-size="22"
|
||||
fill="#666"
|
||||
>{{ i * 10 }}</text>
|
||||
<text x="2" y="10" font-size="10" fill="#666">0</text>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 纵向标尺 -->
|
||||
<div class="absolute top-0 h-full w-[20px] border-r border-gray-300 bg-white/80 backdrop-blur-sm -left-[20px]">
|
||||
<svg width="20" height="100%" class="overflow-visible">
|
||||
<path :d="rulerPathV" stroke="#666" stroke-width="1" fill="none" />
|
||||
<!-- 刻度值 -->
|
||||
<text
|
||||
v-for="i in Math.ceil(templateInfo.height / gridSizePx)"
|
||||
:key="`v-${i}`"
|
||||
x="2"
|
||||
:y="i * gridSizePx + 10"
|
||||
font-size="22"
|
||||
fill="#666"
|
||||
transform-origin="lef"
|
||||
style="writing-mode: vertical-lr;"
|
||||
>{{ i * 10 }}</text>
|
||||
<text x="2" y="10" font-size="10" fill="#666" style="writing-mode: vertical-lr;">0</text>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 实际画布交互层 -->
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseleave="onCanvasMouseLeave"
|
||||
>
|
||||
<!-- 鼠标坐标提示 (跟随鼠标或固定角落) -->
|
||||
<div
|
||||
v-if="showMouseCoord"
|
||||
class="pointer-events-none absolute left-0 top-0 z-50 rounded bg-black/75 px-2 py-1 text-80px text-white backdrop-blur-sm transition-opacity"
|
||||
:style="{
|
||||
transform: `translate(${mouseCoord.x * 10 + 15}px, ${mouseCoord.y * 10 + 15}px)`,
|
||||
}"
|
||||
>
|
||||
X: {{ mouseCoord.x }}mm, Y: {{ mouseCoord.y }}mm
|
||||
</div>
|
||||
|
||||
<!-- 对齐参考线 -->
|
||||
<div
|
||||
v-if="guides.x !== null"
|
||||
class="pointer-events-none absolute bottom-0 top-0 z-50 border-l border-red-500 border-dashed"
|
||||
:style="{ left: `${guides.x}px` }"
|
||||
/>
|
||||
<div
|
||||
v-if="guides.y !== null"
|
||||
class="pointer-events-none absolute left-0 right-0 z-50 border-t border-red-500 border-dashed"
|
||||
:style="{ top: `${guides.y}px` }"
|
||||
/>
|
||||
<!-- 背景图片 -->
|
||||
<img
|
||||
v-if="imgSrc" :src="imgSrc"
|
||||
class="pointer-events-none absolute inset-0 h-full w-full select-none object-contain"
|
||||
>
|
||||
|
||||
<!-- 可拖拽区域 -->
|
||||
<!-- 注意: VueDraggableResizable 需要 w/h/x/y。
|
||||
<!-- 网格辅助层 (10mm 格子) -->
|
||||
<svg
|
||||
v-if="showGrid && gridSizePx > 0"
|
||||
class="pointer-events-none absolute inset-0 z-1 h-full w-full"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="gridPattern"
|
||||
:width="gridSizePx"
|
||||
:height="gridSizePx"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
:d="`M ${gridSizePx} 0 L 0 0 0 ${gridSizePx}`"
|
||||
fill="none"
|
||||
stroke="rgba(255, 0, 0, 0.3)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#gridPattern)" />
|
||||
</svg>
|
||||
|
||||
<!-- 对齐参考线 -->
|
||||
<div
|
||||
v-if="guides.x !== null"
|
||||
class="pointer-events-none absolute bottom-0 top-0 z-50 border-l border-red-500 border-dashed"
|
||||
:style="{ left: `${guides.x}px` }"
|
||||
/>
|
||||
<div
|
||||
v-if="guides.y !== null"
|
||||
class="pointer-events-none absolute left-0 right-0 z-50 border-t border-red-500 border-dashed"
|
||||
:style="{ top: `${guides.y}px` }"
|
||||
/>
|
||||
|
||||
<!-- 可拖拽区域 -->
|
||||
<!-- 注意: VueDraggableResizable 需要 w/h/x/y。
|
||||
:parent="true" 限制在容器内。
|
||||
-->
|
||||
<VueDraggableResizable
|
||||
v-for="region in regions" :key="region.id" :x="region.x" :y="region.y"
|
||||
:w="region.w" :h="region.h" :parent="true" :active="selectedRegionIds.has(region.id)"
|
||||
:draggable="true" :resizable="true" class="region-box" class-active="active-region"
|
||||
@activated="onActivated(region.id)" @drag-start="onDragStart(region, $event)"
|
||||
@dragging="onDragging(region, $event)" @drag-end="onDragEnd(region, $event)"
|
||||
@resize-end="onResizeEnd(region, $event)"
|
||||
>
|
||||
<div
|
||||
class="region-content group relative h-full w-full flex items-center justify-center transition-transform"
|
||||
:style="{ transform: `rotate(${region.rotation || 0}deg)` }"
|
||||
@mousedown="onRegionMouseDown(region, $event)"
|
||||
@mouseup="onRegionMouseUp(region, $event)"
|
||||
<VueDraggableResizable
|
||||
v-for="region in regions" :key="region.id" :x="region.x" :y="region.y"
|
||||
:w="region.w" :h="region.h" :parent="true" :active="selectedRegionIds.has(region.id)"
|
||||
:draggable="true" :resizable="true" class="region-box" class-active="active-region"
|
||||
@activated="onActivated(region.id)" @drag-start="onDragStart(region, $event)"
|
||||
@dragging="onDragging(region, $event)" @drag-end="onDragEnd(region, $event)"
|
||||
@resize-end="onResizeEnd(region, $event)"
|
||||
>
|
||||
<!-- 视觉边框 (旋转) -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 border-2 transition-colors"
|
||||
:class="selectedRegionIds.has(region.id) ? 'border-orange-500 bg-orange-500/10' : 'border-transparent group-hover:border-orange-300'"
|
||||
/>
|
||||
|
||||
<!-- 标签 -->
|
||||
<span class="relative z-10 select-none text-orange-600 font-bold">{{ region.label }}</span>
|
||||
|
||||
<!-- 旋转手柄 -->
|
||||
<div
|
||||
v-if="selectedRegionIds.has(region.id)"
|
||||
class="absolute left-1/2 z-50 h-5 w-5 flex cursor-pointer items-center justify-center border border-gray-400 rounded-full bg-white shadow-sm -top-6 -translate-x-1/2 hover:bg-blue-50"
|
||||
title="旋转" @mousedown.stop="onRotateStart(region, $event)"
|
||||
class="region-content group relative h-full w-full flex items-center justify-center transition-transform"
|
||||
:style="{ transform: `rotate(${region.rotation || 0}deg)` }"
|
||||
@mousedown="onRegionMouseDown(region, $event)"
|
||||
@mouseup="onRegionMouseUp(region, $event)"
|
||||
>
|
||||
<Icon icon="carbon:rotate-clockwise" class="text-xs text-gray-600" />
|
||||
<!-- 视觉边框 (旋转) -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 border-2 transition-colors"
|
||||
:class="selectedRegionIds.has(region.id) ? 'border-orange-500 bg-orange-500/10' : 'border-transparent group-hover:border-orange-300'"
|
||||
/>
|
||||
|
||||
<!-- 标签 -->
|
||||
<span class="relative z-10 select-none text-orange-600 font-bold">{{ region.label }}</span>
|
||||
|
||||
<!-- 旋转手柄 -->
|
||||
<div
|
||||
v-if="selectedRegionIds.has(region.id)"
|
||||
class="absolute left-1/2 z-50 h-5 w-5 flex cursor-pointer items-center justify-center border border-gray-400 rounded-full bg-white shadow-sm -top-6 -translate-x-1/2 hover:bg-blue-50"
|
||||
title="旋转" @mousedown.stop="onRotateStart(region, $event)"
|
||||
>
|
||||
<Icon icon="carbon:rotate-clockwise" class="text-xs text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggableResizable>
|
||||
|
||||
<!-- 无图片时的上传占位符 (如果需要可以在这里添加插槽,或者由父组件控制) -->
|
||||
<!-- 这里我们移除内部上传,改为依赖父组件传入图片 -->
|
||||
<div
|
||||
v-if="!imgSrc"
|
||||
class="pointer-events-none absolute inset-0 z-10 flex flex-col cursor-pointer items-center justify-center bg-white/50 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 p-8 text-gray-400">
|
||||
<SvgIcon icon="carbon:image" class="h-50 w-50" />
|
||||
<div class="text-5xl text-gray-500">
|
||||
请在上方点击上传答题卡底稿
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggableResizable>
|
||||
|
||||
<!-- 无图片时的上传占位符 (如果需要可以在这里添加插槽,或者由父组件控制) -->
|
||||
<!-- 这里我们移除内部上传,改为依赖父组件传入图片 -->
|
||||
<div
|
||||
v-if="!imgSrc"
|
||||
class="pointer-events-none absolute inset-0 z-10 flex flex-col cursor-pointer items-center justify-center bg-white/50 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 p-8 text-gray-400">
|
||||
<SvgIcon icon="carbon:image" class="h-50 w-50" />
|
||||
<div class="text-5xl text-gray-500">
|
||||
请在上方点击上传答题卡底稿
|
||||
</div>
|
||||
<!-- 加载遮罩 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-white/80 backdrop-blur-sm"
|
||||
>
|
||||
<NSpin size="large" description="正在解析渲染高清底图..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载遮罩 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-white/80 backdrop-blur-sm"
|
||||
>
|
||||
<NSpin size="large" description="正在解析渲染高清底图..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -99,7 +99,7 @@ const localGenSettings = computed({
|
||||
</NForm>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-gray-50 p-4">
|
||||
<div class="max-h-[520px] flex-1 overflow-auto bg-gray-50 p-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">已生成布局列表</span>
|
||||
<NButton v-if="regions.length" text type="error" size="tiny" @click="$emit('clearAll')">
|
||||
@ -116,7 +116,7 @@ const localGenSettings = computed({
|
||||
<div
|
||||
v-for="region in regions"
|
||||
:key="region.id"
|
||||
class="group-item relative border rounded bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
class="group-item relative max-h-[120px] overflow-auto border rounded bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
|
||||
@ -100,19 +100,16 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
||||
width: 430,
|
||||
render: row => (
|
||||
<div class="flex-center gap-8px">
|
||||
<NButton type="primary" ghost size="small" onClick={() => edit(row.ID)}>
|
||||
基础信息
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
预览
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" ghost onClick={() => handlePageInfo(row.ID)}>
|
||||
页面信息
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
铺码
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
预览
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => handlePageInfo(row.ID)}>
|
||||
页面信息
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
<NButton size="small" onClick={() => { handlePrint(row) }}>
|
||||
打印
|
||||
</NButton>
|
||||
<NPopconfirm onPositiveClick={() => handleDelete((row as any).ID || row.id)}>
|
||||
@ -135,7 +132,6 @@ const {
|
||||
drawerVisible,
|
||||
operateType,
|
||||
editingData,
|
||||
handleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
onDeleted,
|
||||
@ -154,16 +150,21 @@ async function handleDelete(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function edit(id: number) {
|
||||
handleEdit(id)
|
||||
}
|
||||
|
||||
function handlePageInfo(id: number) {
|
||||
routerPushByKey('template_template-detail', { query: { id: id.toString() } })
|
||||
}
|
||||
function handleAdd() {
|
||||
routerPushByKey('template_template-detail')
|
||||
}
|
||||
|
||||
async function handlePrint(row: Api.Template.CommonRecord) {
|
||||
const { BackGroundUrl } = row
|
||||
if (!BackGroundUrl) {
|
||||
window.$message?.error('打印模板不存在')
|
||||
return
|
||||
}
|
||||
window.open(BackGroundUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user