diff --git a/apps/admin/src/views/competition/competition-add/index.vue b/apps/admin/src/views/competition/competition-add/index.vue index 05901b4..dd1660c 100644 --- a/apps/admin/src/views/competition/competition-add/index.vue +++ b/apps/admin/src/views/competition/competition-add/index.vue @@ -105,7 +105,7 @@ async function submitForm() { endTime, teams: groupCount * teamCount, teamGroupNumber: groupCount, - createdTime: '', + createdTime: startTime, roomID: roomId || 0, backgroundImg: poster, } @@ -124,7 +124,7 @@ async function submitForm() { penSerial: `${group.id}-${index + 1}`, nameList: 'to do?', schoolName: name, - teamGroupId: group.id, + teamGroupId: group.id && Number(group.id), }) }) }) diff --git a/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue b/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue index 7422bbe..470e274 100644 --- a/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue +++ b/apps/admin/src/views/competition/competition-add/modules/BasicInfo.vue @@ -89,7 +89,7 @@ defineExpose({ validate, reset }) -// 结果列表 +import { useIntervalFn } from '@vueuse/core' +import { NButton } from 'naive-ui' +import { onUnmounted, ref } from 'vue' +import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue' +import TeamResultCard, { type AICharacter, type TeamResult } from './modules/TeamResultCard.vue' + +// --- 模拟数据和状态 --- + +const groups = ref(Array.from({ length: 8 }).map((_, i) => ({ + id: i + 1, + name: `第 ${i + 1} 组`, +}))) + +const currentGroupId = ref(1) // 当前选中的组别 ID +const teamsData = ref([]) // 当前组别下的队伍数据 +const isPolling = ref(true) // 是否正在轮询 + +// 初始化某组的队伍结构 +function initGroupTeams(groupId: number | string) { + return Array.from({ length: 4 }).map((_, i) => ({ + id: `${groupId}-${i + 1}`, + name: `第 ${i + 1} 队`, + correctCount: 0, + score: 0, + imageUrl: 'https://oss.qyzhjy.com/temp/1769653751992/a4.jpg', + status: 'pending' as const, + aiData: [], + })) +} + +// 初始化数据 +teamsData.value = initGroupTeams(currentGroupId.value) + +// --- 轮询 / 模拟逻辑 --- + +// 存储人工修正记录,以便在更新时保留 +// Map> +const manualCorrections = ref>>({}) + +function mergeAIResults(currentData: TeamResult[], incomingData: Record) { + currentData.forEach((team) => { + const newChars = incomingData[team.id] || [] + if (newChars.length === 0) + return + + team.status = 'analyzing' + + // 合并逻辑: + // 1. 现有字符:如果需要更新,但要保留人工修正的状态? + // 目前假设 AI 返回的结果是稳定的,但可能会有更多字符。 + // 2. 新字符:追加。 + + // 简单方法:替换列表但应用人工覆盖 + const mergedChars = newChars.map((char) => { + const teamCorrections = manualCorrections.value[team.id] || {} + if (typeof teamCorrections[char.id] !== 'undefined') { + return { ...char, isCorrect: teamCorrections[char.id] } + } + return char + }) + + team.aiData = mergedChars + + // 重新计算分数/数量 + team.correctCount = mergedChars.filter(c => c.isCorrect).length + team.score = team.correctCount * 1.5 // 假设每个字符 1.5 分 + + // 如果数据足够(模拟结束条件) + if (team.aiData.length >= 20) { + team.status = 'done' + } + }) +} + +// 模拟 API 轮询 +// eslint-disable-next-line unused-imports/no-unused-vars +const { pause, resume } = useIntervalFn(() => { + if (!isPolling.value) + return + + // 模拟获取当前队伍的数据 + // 实际中: const res = await api.getResults(currentGroupId.value) + + // 模拟增量更新 + const incomingMockData: Record = {} + + teamsData.value.forEach((team) => { + if (team.status === 'done') { + incomingMockData[team.id] = team.aiData + return + } + + // 添加 1-2 个随机字符 + const currentLen = team.aiData.length + const nextLen = Math.min(currentLen + Math.floor(Math.random() * 3), 25) + + const newChars: AICharacter[] = [] + for (let i = 0; i < nextLen; i++) { + // 如果已存在,保留字符,否则生成新的 + if (i < currentLen) { + // 我们使用上一次模拟的原始字符(尚未应用人工修正, + // 实际上我们是在模拟“服务器”视图,它还不知道本地编辑) + // 实际上为了模拟简单,我们就生成“新”列表。 + newChars.push({ + id: `char-${i}`, + char: ['龙', '年', '大', '吉', '万', '事', '如', '意', '恭', '喜', '发', '财'][i % 12], + isCorrect: i % 5 !== 0, // 模拟一些错误 + }) + } + else { + newChars.push({ + id: `char-${i}`, + char: ['龙', '年', '大', '吉', '万', '事', '如', '意', '恭', '喜', '发', '财'][i % 12], + isCorrect: Math.random() > 0.2, // 80% 正确率 + }) + } + } + incomingMockData[team.id] = newChars + }) + + mergeAIResults(teamsData.value, incomingMockData) +}, 2000) + +// --- 事件处理 --- + +function handleGroupChange(id: number | string) { + currentGroupId.value = id + // 重置新组的数据 + teamsData.value = initGroupTeams(id) + // 清除还是保留人工修正?通常新一轮会清除,但如果是重新访问可能会保留? + // 用户未指定。我将保留在以 teamID 为键的 map 中(groupId-teamIdx)。 + + // 如果停止了轮询,是否恢复? + // 在实际应用中,可能需要为该组重新启动轮询。 +} + +function handleToggleCorrect(teamId: number | string, charId: string) { + const team = teamsData.value.find(t => t.id === teamId) + if (!team) + return + + const char = team.aiData.find(c => c.id === charId) + if (!char) + return + + // 切换状态 + char.isCorrect = !char.isCorrect + + // 更新计数 + team.correctCount = team.aiData.filter(c => c.isCorrect).length + team.score = team.correctCount * 1.5 + + // 保存到人工修正 map + if (!manualCorrections.value[team.id]) { + manualCorrections.value[team.id] = {} + } + manualCorrections.value[team.id][charId] = char.isCorrect +} + +function handleNext() { + // “下一步”按钮的逻辑(也许是下一组?) + const idx = groups.value.findIndex(g => g.id === currentGroupId.value) + if (idx !== -1 && idx < groups.value.length - 1) { + handleGroupChange(groups.value[idx + 1].id) + } +} + +function handlePublish() { + window.$message?.success('结果已发布') +} + +// 清理 +onUnmounted(() => { + pause() +}) + + diff --git a/apps/admin/src/views/results/modules/GroupTabs.vue b/apps/admin/src/views/results/modules/GroupTabs.vue new file mode 100644 index 0000000..e6cd0cb --- /dev/null +++ b/apps/admin/src/views/results/modules/GroupTabs.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/admin/src/views/results/modules/TeamResultCard.vue b/apps/admin/src/views/results/modules/TeamResultCard.vue new file mode 100644 index 0000000..a2f85b6 --- /dev/null +++ b/apps/admin/src/views/results/modules/TeamResultCard.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/apps/admin/src/views/template/template-detail/README.md b/apps/admin/src/views/template/template-detail/README.md index 1cd2586..acc90b6 100644 --- a/apps/admin/src/views/template/template-detail/README.md +++ b/apps/admin/src/views/template/template-detail/README.md @@ -8,10 +8,10 @@ ## 2. 目录结构 -- `index.vue`: **主入口文件**。负责整体状态管理、组件组装(Canvas + Settings)、数据初始化、保存逻辑以及核心的“生成区域”逻辑。 -- `hooks/useUpload.ts`: **上传逻辑封装**。负责文件处理(PDF 转图片)、OSS 上传、Token 获取等。 -- `modules/TemplateCanvas.vue`: **画布组件**。负责渲染底图、绘制答题区域、处理区域的交互(拖拽、缩放、旋转、多选/单选、对齐辅助线)。 -- `modules/TemplateSettings.vue`: **设置面板组件**。负责展示模板基础信息、提供区域生成的参数配置(行列、宽高、间距)、展示已生成的区域列表。 +* `index.vue`: **主入口文件**。负责整体状态管理、组件组装(Canvas + Settings)、数据初始化、保存逻辑以及核心的“生成区域”逻辑。 +* `hooks/useUpload.ts`: **上传逻辑封装**。负责文件处理(PDF 转图片)、OSS 上传、Token 获取等。 +* `modules/TemplateCanvas.vue`: **画布组件**。负责渲染底图、绘制答题区域、处理区域的交互(拖拽、缩放、旋转、多选/单选、对齐辅助线)。 +* `modules/TemplateSettings.vue`: **设置面板组件**。负责展示模板基础信息、提供区域生成的参数配置(行列、宽高、间距)、展示已生成的区域列表。 ## 3. 详细业务流程 @@ -19,12 +19,12 @@ 1. **加载页面**: 进入 `index.vue`。 2. **获取详情**: `onMounted` 钩子调用 `initData`。 - - 检查 URL 参数 `id`。 - - 如果有 `id`,调用 `fetchTemplateDetail(id)` 获取模板详情。 - - **数据回填**: - - 将 `Name`, `Width`, `Height` 回填到 `templateInfo`。 - - 将 `BackGroundUrl` 回填到 `imgSrc` 和 `templateInfo.backGroundUrl`。 - - 解析 `TempContent` (JSON 字符串) 回填到 `regions` 列表。 + * 检查 URL 参数 `id`。 + * 如果有 `id`,调用 `fetchTemplateDetail(id)` 获取模板详情。 + * **数据回填**: + * 将 `Name`, `Width`, `Height` 回填到 `templateInfo`。 + * 将 `BackGroundUrl` 回填到 `imgSrc` 和 `templateInfo.backGroundUrl`。 + * 解析 `TempContent` (JSON 字符串) 回填到 `regions` 列表。 ### 3.2 上传底图 (Upload Background) @@ -32,18 +32,18 @@ 1. **触发上传**: 用户点击“上传底稿”。 2. **上传检查 (`handleUploadCheck`)**: - - 如果已有底图,弹出确认框,提示“更换底图将清空当前已生成的布局”。 - - 确认后打开文件选择器。 + * 如果已有底图,弹出确认框,提示“更换底图将清空当前已生成的布局”。 + * 确认后打开文件选择器。 3. **文件处理 (`handleUpload`)**: - - **PDF 处理**: 使用 `pdfjs-dist` 渲染 PDF 第一页,按 A4 比例(2100px 宽)转换为 Canvas,再导出为 Blob。 - - **图片处理**: 直接读取图片文件。 + * **PDF 处理**: 使用 `pdfjs-dist` 渲染 PDF 第一页,按 A4 比例(2100px 宽)转换为 Canvas,再导出为 Blob。 + * **图片处理**: 直接读取图片文件。 4. **OSS 上传**: - - 调用 `getAliOssTokenAxios` 获取 STS Token。 - - 使用 `ali-oss` 客户端将文件上传至 OSS。 + * 调用 `getAliOssTokenAxios` 获取 STS Token。 + * 使用 `ali-oss` 客户端将文件上传至 OSS。 5. **状态更新**: - - 更新 `imgSrc` 为 OSS 返回的 URL。 - - 更新 `templateInfo` 的宽高。 - - **清空现有区域** (`regions`) 和选中状态 (`selectedRegionIds`)。 + * 更新 `imgSrc` 为 OSS 返回的 URL。 + * 更新 `templateInfo` 的宽高。 + * **清空现有区域** (`regions`) 和选中状态 (`selectedRegionIds`)。 ### 3.3 区域生成 (Region Generation) @@ -51,66 +51,64 @@ 1. **配置参数**: 用户在 `TemplateSettings` 面板输入行列数、宽高、间距。 2. **计算编号**: - - 遍历现有 `regions`,解析 `label` (如 "Q1", "Q2"),找到最大编号 `maxNum`。 - - 新生成的区域编号从 `maxNum + 1` 开始递增。 + * 遍历现有 `regions`,解析 `label` (如 "Q1", "Q2"),找到最大编号 `maxNum`。 + * 新生成的区域编号从 `maxNum + 1` 开始递增。 3. **批量创建**: - - 根据行 (`rows`)、列 (`cols`) 双重循环。 - - 计算每个区域的 `x, y` 坐标:`start + index * (size + gap)`。 - - 为这批区域生成同一个 `groupId` (UUID)。 + * 根据行 (`rows`)、列 (`cols`) 双重循环。 + * 计算每个区域的 `x, y` 坐标:`start + index * (size + gap)`。 + * 为这批区域生成同一个 `groupId` (UUID)。 4. **自动选中**: - - 将新生成的区域加入 `regions` 列表。 - - 如果生成了多个区域,自动将它们全部加入 `selectedRegionIds`,方便用户立即进行批量拖拽。 + * 将新生成的区域加入 `regions` 列表。 + * 如果生成了多个区域,自动将它们全部加入 `selectedRegionIds`,方便用户立即进行批量拖拽。 ### 3.4 画布交互 (Canvas Interaction) 逻辑在 `modules/TemplateCanvas.vue` 中,核心依赖 `vue3-draggable-resizable`: 1. **渲染**: - - 底图作为背景。 - - 遍历 `regions` 渲染 `VueDraggableResizable` 组件。 + * 底图作为背景。 + * 遍历 `regions` 渲染 `VueDraggableResizable` 组件。 2. **选择逻辑**: - - **单选**: 点击某个区域,清除其他选中,只选中当前。 - - **多选**: 按住 Shift/Ctrl/Meta 点击,切换当前区域的选中状态。 - - **取消选中**: 点击画布空白处 (`onCanvasMouseDown`),清空 `selectedRegionIds`。 - - **从多选切换单选**: 在多选状态下,**松开鼠标** (`onRegionMouseUp`) 且没有发生拖拽(位移 < 3px)时,切换为单选当前区域。 + * **单选**: 点击某个区域,清除其他选中,只选中当前。 + * **多选**: 按住 Shift/Ctrl/Meta 点击,切换当前区域的选中状态。 + * **取消选中**: 点击画布空白处 (`onCanvasMouseDown`),清空 `selectedRegionIds`。 + * **从多选切换单选**: 在多选状态下,**松开鼠标** (`onRegionMouseUp`) 且没有发生拖拽(位移 < 3px)时,切换为单选当前区域。 3. **拖拽与移动 (`onDragging`)**: - - **单体移动**: 拖拽单个元素。 - - **批量移动**: 如果当前拖拽的元素在选中集合中,计算其位移差 (`deltaX`, `deltaY`),同步更新所有选中元素的坐标。 - - **辅助线**: 计算当前拖拽元素中心点与画布中心点的距离,小于阈值时显示吸附辅助线。 + * **单体移动**: 拖拽单个元素。 + * **批量移动**: 如果当前拖拽的元素在选中集合中,计算其位移差 (`deltaX`, `deltaY`),同步更新所有选中元素的坐标。 + * **辅助线**: 计算当前拖拽元素中心点与画布中心点的距离,小于阈值时显示吸附辅助线。 4. **缩放与旋转**: - - **缩放**: 通过组件自带句柄调整 `w, h`。 - - **旋转**: 自定义旋转手柄,计算鼠标角度变化,更新 `rotation` 属性。 + * **缩放**: 通过组件自带句柄调整 `w, h`。 + * **旋转**: 自定义旋转手柄,计算鼠标角度变化,更新 `rotation` 属性。 ### 3.5 保存 (Save) 逻辑在 `index.vue` 的 `handleSave` 中: 1. **数据组装**: - - 收集 `templateInfo` (名称、宽高、背景图)。 - - 将 `regions` 数组序列化为 JSON 字符串 (`tempContent`)。 + * 收集 `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) + 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 @@ -121,25 +119,24 @@ interface TemplateInfo { ``` ### 核心原理 - 我们在前端渲染时,是基于 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. 开启网格 :点击顶部新增的 "显示网格" 按钮。 @@ -148,27 +145,27 @@ interface TemplateInfo { - 如果重合,说明系统的坐标映射与物理 A4 纸张是完全对应的。 - 如果不重合(例如系统网格偏大或偏小),则说明上传的图片本身比例不是标准的 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 +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 - 这个比例非常便于计算: + 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 纸即可。 +是的,只要确保**比例(Aspect Ratio)**是对的,且系统能正确识别这是 A4 纸即可。 我在代码中已经为您实现了 动态网格系统 ,它不依赖固定的像素值,而是根据当前图片宽度反向计算: @@ -176,9 +173,8 @@ Current source: https://oss.qyzhjy.com/temp/1769655687535/book.jpg 2. 系统知道这是 A4 纸(210mm)。 3. 系统自动计算出 10mm = (2100 / 21) = 100px 。 4. 系统在屏幕上绘制间隔为 100px 的网格线。 - 验证方法: 如果您上传了一张分辨率较低的图片(例如 1050px 宽),系统会自动计算出 1mm = 5px ,网格线会变为 50px 间隔。 只要网格线能与您图片上的物理刻度对齐,就说明换算和位置是绝对精准的。 -5. 结论 - +验证方法: 如果您上传了一张分辨率较低的图片(例如 1050px 宽),系统会自动计算出 1mm = 5px ,网格线会变为 50px 间隔。 只要网格线能与您图片上的物理刻度对齐,就说明换算和位置是绝对精准的。 + 3. 结论 - 您当前的图片 2100 x 2970 是 完美标准 。 - 如果您上传其他尺寸,只要长宽比接近 A4 (1:1.414),我们的网格系统都能自动适配并准确显示 10mm 刻度。 - 您可以使用我在顶部新增的 “显示网格” 按钮来直观验证这一点。