From 82b1dd7ebe3b36dcb4c965e5817fa47cc29b08e9 Mon Sep 17 00:00:00 2001 From: rjl <98n9@163.com> Date: Thu, 29 Jan 2026 11:55:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(template):=20=E5=A2=9E=E5=BC=BA=E7=AD=94?= =?UTF-8?q?=E9=A2=98=E5=8D=A1=E7=BC=96=E8=BE=91=E5=99=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增模板详情编辑功能,支持从后端加载并回填数据 - 在画布中增加网格、标尺和坐标显示辅助工具 - 优化模板列表操作按钮布局,新增打印功能 - 扩展响应类型定义,增加 success 字段和 TemplateDetail 接口 - 添加 ESLint 配置排除 Markdown 文件,并完善事件总线类型 - 新增请求工具使用文档,说明错误处理机制 --- apps/admin/src/service/api/template.ts | 4 +- apps/admin/src/service/request/readme.md | 76 +++++ apps/admin/src/typings/api/template.d.ts | 18 ++ apps/admin/src/typings/app.d.ts | 4 +- apps/admin/src/utils/event-bus.ts | 21 +- .../views/template/template-detail/README.md | 184 +++++++++++ .../views/template/template-detail/index.vue | 115 ++++++- .../modules/TemplateCanvas.vue | 304 ++++++++++++++---- .../modules/TemplateSettings.vue | 4 +- .../views/template/template-list/index.vue | 29 +- eslint.config.js | 1 + 11 files changed, 654 insertions(+), 106 deletions(-) create mode 100644 apps/admin/src/service/request/readme.md create mode 100644 apps/admin/src/views/template/template-detail/README.md diff --git a/apps/admin/src/service/api/template.ts b/apps/admin/src/service/api/template.ts index c064e7f..541bf79 100644 --- a/apps/admin/src/service/api/template.ts +++ b/apps/admin/src/service/api/template.ts @@ -12,7 +12,7 @@ export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) { } /** - * 新增模板 + * 新增模板, 更新模板 */ export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) { return request>>({ @@ -26,7 +26,7 @@ export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) { * 获取模板详情 */ export function fetchTemplateDetail(id: number) { - return request>>({ + return request>({ url: `/Base/ActivityMain/GetTemplete_PagByID?id=${id}`, method: 'get', }) diff --git a/apps/admin/src/service/request/readme.md b/apps/admin/src/service/request/readme.md new file mode 100644 index 0000000..5fae13b --- /dev/null +++ b/apps/admin/src/service/request/readme.md @@ -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 + } + } +} +``` diff --git a/apps/admin/src/typings/api/template.d.ts b/apps/admin/src/typings/api/template.d.ts index d0ad796..ee85fa4 100644 --- a/apps/admin/src/typings/api/template.d.ts +++ b/apps/admin/src/typings/api/template.d.ts @@ -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 */ diff --git a/apps/admin/src/typings/app.d.ts b/apps/admin/src/typings/app.d.ts index 9604b58..6915cf4 100644 --- a/apps/admin/src/typings/app.d.ts +++ b/apps/admin/src/typings/app.d.ts @@ -633,11 +633,13 @@ declare namespace App { /** The backend service response data */ interface Response { /** 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 */ diff --git a/apps/admin/src/utils/event-bus.ts b/apps/admin/src/utils/event-bus.ts index d279226..7fa134e 100644 --- a/apps/admin/src/utils/event-bus.ts +++ b/apps/admin/src/utils/event-bus.ts @@ -1,23 +1,26 @@ +/* eslint-disable ts/no-unsafe-function-type */ export class EventBus { - private listeners: Record = {}; + private listeners: Record = {} 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' diff --git a/apps/admin/src/views/template/template-detail/README.md b/apps/admin/src/views/template/template-detail/README.md new file mode 100644 index 0000000..1cd2586 --- /dev/null +++ b/apps/admin/src/views/template/template-detail/README.md @@ -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 刻度。 +- 您可以使用我在顶部新增的 “显示网格” 按钮来直观验证这一点。 diff --git a/apps/admin/src/views/template/template-detail/index.vue b/apps/admin/src/views/template/template-detail/index.vue index 9f10602..ebc49e7 100644 --- a/apps/admin/src/views/template/template-detail/index.vue +++ b/apps/admin/src/views/template/template-detail/index.vue @@ -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({ - name: '期末大考标准答题卡', + name: '', width: 2100, - height: 2994, + height: 2997, }) const imgSrc = ref('') // 生成配置 const genSettings = ref({ - cols: 1, + cols: 6, rows: 1, w: 296, h: 309, @@ -42,6 +42,11 @@ const regions = ref([]) // 选中区域集合 const selectedRegionIds = ref>(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() +})