feat(admin): 新增实时结果页面并优化竞赛创建逻辑

- 新增实时结果页面,包含分组切换、队伍卡片展示和AI识别结果交互
- 实现模拟数据轮询、人工修正状态保持和分数自动计算功能
- 修复竞赛创建时createdTime字段设置为空字符串的问题,改为使用startTime
- 修正teamGroupId字段类型,确保为数字类型
- 统一日期时间选择器格式为ISO标准格式(yyyy-MM-dd'T'HH:mm:ss)
This commit is contained in:
2026-01-30 14:04:07 +08:00
parent beaaee72b4
commit b39761d58e
6 changed files with 448 additions and 76 deletions

View File

@ -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),
})
})
})

View File

@ -89,7 +89,7 @@ defineExpose({ validate, reset })
</div>
<NDatePicker
v-model:formatted-value="formData.startTime" type="datetime" clearable class="w-full"
value-format="yyyy-MM-dd HH:mm:ss"
value-format="yyyy-MM-dd'T'HH:mm:ss"
placeholder="选择开始时间" :theme-overrides="{
peers: {
Input: {
@ -112,7 +112,7 @@ defineExpose({ validate, reset })
</div>
<NDatePicker
v-model:formatted-value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
value-format="yyyy-MM-dd HH:mm:ss"
value-format="yyyy-MM-dd'T'HH:mm:ss"
:theme-overrides="{
peers: {
Input: {

View File

@ -1,9 +1,226 @@
<script lang="ts" setup>
// 结果列表
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<GroupItem[]>(Array.from({ length: 8 }).map((_, i) => ({
id: i + 1,
name: `${i + 1}`,
})))
const currentGroupId = ref<number | string>(1) // 当前选中的组别 ID
const teamsData = ref<TeamResult[]>([]) // 当前组别下的队伍数据
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<TeamID, Map<CharID, boolean>>
const manualCorrections = ref<Record<string, Record<string, boolean>>>({})
function mergeAIResults(currentData: TeamResult[], incomingData: Record<string, AICharacter[]>) {
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<string, AICharacter[]> = {}
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()
})
</script>
<template>
<h1 class="m-0 text-xl text-gray-800 font-bold">
实时结果
</h1>
<div class="h-full flex flex-col gap-2 overflow-hidden bg-[#F9FAFB] p-6">
<!-- Top Bar -->
<div class="flex items-center justify-between rounded-2xl bg-white px-6 py-4 shadow-sm">
<div class="flex items-center gap-8">
<h1 class="m-0 flex items-center gap-2 text-xl text-gray-800 font-bold">
<div class="i-carbon-chart-line-data text-2xl text-blue-600" />
实时结果
</h1>
<GroupTabs
:groups="groups"
:model-value="currentGroupId"
@update:model-value="handleGroupChange"
/>
</div>
<div class="flex gap-4">
<div class="flex items-center rounded-full bg-blue-50 px-4 py-1.5 text-sm text-blue-600 font-bold">
当前进度 1/10
</div>
<NButton secondary strong @click="handleNext">
下一题
</NButton>
<NButton type="primary" strong class="px-6" @click="handlePublish">
发布结果
</NButton>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 overflow-hidden">
<div class="grid grid-cols-4 h-full gap-6">
<TeamResultCard
v-for="team in teamsData"
:key="team.id"
:data="team"
@toggle-correct="(charId) => handleToggleCorrect(team.id, charId)"
/>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.n-card__content) {
padding: 0;
}
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
export interface GroupItem {
id: number | string
name: string
}
defineProps<{
groups: GroupItem[]
modelValue: number | string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', id: number | string): void
}>()
</script>
<template>
<div class="scrollbar-hide flex gap-3 overflow-x-auto pb-2">
<NButton
v-for="group in groups"
:key="group.id"
round
size="large"
:type="modelValue === group.id ? 'primary' : 'default'"
:secondary="modelValue !== group.id"
class="px-6 font-bold transition-all"
:class="{ 'shadow-lg shadow-blue-500/30': modelValue === group.id }"
@click="emit('update:modelValue', group.id)"
>
{{ group.name }}
</NButton>
</div>
</template>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { NImage } from 'naive-ui'
export interface AICharacter {
id: string
char: string
isCorrect: boolean
}
export interface TeamResult {
id: number | string
name: string
correctCount: number
score: number
imageUrl: string
status: 'pending' | 'analyzing' | 'done'
aiData: AICharacter[]
}
// eslint-disable-next-line unused-imports/no-unused-vars
const props = defineProps<{
data: TeamResult
}>()
const emit = defineEmits<{
(e: 'toggle-correct', charId: string): void
(e: 'update-char', charId: string, newChar: string): void
}>()
// 切换正确状态
function handleCharClick(item: AICharacter) {
// eslint-disable-next-line vue/custom-event-name-casing
emit('toggle-correct', item.id)
}
</script>
<template>
<div class="h-full flex flex-col overflow-hidden border border-gray-100 rounded-xl bg-white shadow-sm">
<!-- 头部信息 -->
<div class="flex items-center justify-between border-b border-gray-100 bg-gray-50 px-4 py-3">
<h3 class="m-0 text-lg text-gray-800 font-bold">
{{ data.name }}
</h3>
<div class="flex gap-3 text-sm">
<span class="text-green-600 font-bold">正确 {{ data.correctCount }} </span>
<span class="text-blue-600 font-bold">积分 {{ data.score }} </span>
</div>
</div>
<!-- 图片区域 -->
<div class="relative aspect-[3/4] w-full flex items-center justify-center overflow-hidden border-b border-gray-100 bg-gray-100">
<!-- 图片居中 -->
<NImage
v-if="data.imageUrl"
:src="data.imageUrl"
class="h-full w-full flex items-center justify-center"
:img-props="{ class: 'w-full h-full object-contain object-center' }"
preview-disabled
/>
<div v-else class="h-full w-full flex items-center justify-center text-gray-400">
等待上传答题卡...
</div>
<!-- 加载遮罩 -->
<!-- <div
v-if="data.status === 'analyzing'"
class="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px]"
>
<NSpin size="large" />
</div> -->
</div>
<!-- AI 结果网格 -->
<div class="min-h-[200px] flex-1 overflow-y-auto p-5">
<div v-if="data.aiData.length === 0" class="h-full flex items-center justify-center text-sm text-gray-400">
{{ data.status === 'pending' ? '等待识别...' : '暂无数据' }}
</div>
<div v-else class="grid grid-cols-8 gap-2">
<!--
grid-cols-5 控制宽度 (宽度由网格列数控制)
h-10 控制高度 (高度由固定高度类控制)
-->
<div
v-for="item in data.aiData"
:key="item.id"
class="h-14 w-14 flex cursor-pointer select-none items-center justify-center rounded-lg text-lg font-bold transition-all active:scale-95 hover:scale-105"
:class="[
item.isCorrect
? 'bg-green-100 text-green-600 border border-green-200'
: 'bg-red-100 text-red-600 border border-red-200',
]"
@click="handleCharClick(item)"
>
{{ item.char }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Custom scrollbar for the grid area if needed */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 2px;
}
</style>

View File

@ -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: 7099
Intrinsic size: 2100 × 2969 px
Intrinsic aspect ratio: 21002969
File size: 526 kB
Current source: https://oss.qyzhjy.com/temp/1769655687535/book.jpg
Rendered size: 2100 × 2970 px
Rendered aspect ratio: 7099
Intrinsic size: 2100 × 2969 px
Intrinsic aspect ratio: 21002969
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 刻度。
- 您可以使用我在顶部新增的 “显示网格” 按钮来直观验证这一点。