feat(admin): 新增实时结果页面并优化竞赛创建逻辑
- 新增实时结果页面,包含分组切换、队伍卡片展示和AI识别结果交互 - 实现模拟数据轮询、人工修正状态保持和分数自动计算功能 - 修复竞赛创建时createdTime字段设置为空字符串的问题,改为使用startTime - 修正teamGroupId字段类型,确保为数字类型 - 统一日期时间选择器格式为ISO标准格式(yyyy-MM-dd'T'HH:mm:ss)
This commit is contained in:
@ -105,7 +105,7 @@ async function submitForm() {
|
|||||||
endTime,
|
endTime,
|
||||||
teams: groupCount * teamCount,
|
teams: groupCount * teamCount,
|
||||||
teamGroupNumber: groupCount,
|
teamGroupNumber: groupCount,
|
||||||
createdTime: '',
|
createdTime: startTime,
|
||||||
roomID: roomId || 0,
|
roomID: roomId || 0,
|
||||||
backgroundImg: poster,
|
backgroundImg: poster,
|
||||||
}
|
}
|
||||||
@ -124,7 +124,7 @@ async function submitForm() {
|
|||||||
penSerial: `${group.id}-${index + 1}`,
|
penSerial: `${group.id}-${index + 1}`,
|
||||||
nameList: 'to do?',
|
nameList: 'to do?',
|
||||||
schoolName: name,
|
schoolName: name,
|
||||||
teamGroupId: group.id,
|
teamGroupId: group.id && Number(group.id),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -89,7 +89,7 @@ defineExpose({ validate, reset })
|
|||||||
</div>
|
</div>
|
||||||
<NDatePicker
|
<NDatePicker
|
||||||
v-model:formatted-value="formData.startTime" type="datetime" clearable class="w-full"
|
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="{
|
placeholder="选择开始时间" :theme-overrides="{
|
||||||
peers: {
|
peers: {
|
||||||
Input: {
|
Input: {
|
||||||
@ -112,7 +112,7 @@ defineExpose({ validate, reset })
|
|||||||
</div>
|
</div>
|
||||||
<NDatePicker
|
<NDatePicker
|
||||||
v-model:formatted-value="formData.endTime" type="datetime" clearable class="w-full" placeholder="选择结束时间"
|
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="{
|
:theme-overrides="{
|
||||||
peers: {
|
peers: {
|
||||||
Input: {
|
Input: {
|
||||||
|
|||||||
@ -1,9 +1,226 @@
|
|||||||
<script lang="ts" setup>
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
<div class="h-full flex flex-col gap-2 overflow-hidden bg-[#F9FAFB] p-6">
|
||||||
实时结果
|
<!-- Top Bar -->
|
||||||
</h1>
|
<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>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.n-card__content) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
45
apps/admin/src/views/results/modules/GroupTabs.vue
Normal file
45
apps/admin/src/views/results/modules/GroupTabs.vue
Normal 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>
|
||||||
114
apps/admin/src/views/results/modules/TeamResultCard.vue
Normal file
114
apps/admin/src/views/results/modules/TeamResultCard.vue
Normal 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>
|
||||||
@ -8,10 +8,10 @@
|
|||||||
|
|
||||||
## 2. 目录结构
|
## 2. 目录结构
|
||||||
|
|
||||||
- `index.vue`: **主入口文件**。负责整体状态管理、组件组装(Canvas + Settings)、数据初始化、保存逻辑以及核心的“生成区域”逻辑。
|
* `index.vue`: **主入口文件**。负责整体状态管理、组件组装(Canvas + Settings)、数据初始化、保存逻辑以及核心的“生成区域”逻辑。
|
||||||
- `hooks/useUpload.ts`: **上传逻辑封装**。负责文件处理(PDF 转图片)、OSS 上传、Token 获取等。
|
* `hooks/useUpload.ts`: **上传逻辑封装**。负责文件处理(PDF 转图片)、OSS 上传、Token 获取等。
|
||||||
- `modules/TemplateCanvas.vue`: **画布组件**。负责渲染底图、绘制答题区域、处理区域的交互(拖拽、缩放、旋转、多选/单选、对齐辅助线)。
|
* `modules/TemplateCanvas.vue`: **画布组件**。负责渲染底图、绘制答题区域、处理区域的交互(拖拽、缩放、旋转、多选/单选、对齐辅助线)。
|
||||||
- `modules/TemplateSettings.vue`: **设置面板组件**。负责展示模板基础信息、提供区域生成的参数配置(行列、宽高、间距)、展示已生成的区域列表。
|
* `modules/TemplateSettings.vue`: **设置面板组件**。负责展示模板基础信息、提供区域生成的参数配置(行列、宽高、间距)、展示已生成的区域列表。
|
||||||
|
|
||||||
## 3. 详细业务流程
|
## 3. 详细业务流程
|
||||||
|
|
||||||
@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
1. **加载页面**: 进入 `index.vue`。
|
1. **加载页面**: 进入 `index.vue`。
|
||||||
2. **获取详情**: `onMounted` 钩子调用 `initData`。
|
2. **获取详情**: `onMounted` 钩子调用 `initData`。
|
||||||
- 检查 URL 参数 `id`。
|
* 检查 URL 参数 `id`。
|
||||||
- 如果有 `id`,调用 `fetchTemplateDetail(id)` 获取模板详情。
|
* 如果有 `id`,调用 `fetchTemplateDetail(id)` 获取模板详情。
|
||||||
- **数据回填**:
|
* **数据回填**:
|
||||||
- 将 `Name`, `Width`, `Height` 回填到 `templateInfo`。
|
* 将 `Name`, `Width`, `Height` 回填到 `templateInfo`。
|
||||||
- 将 `BackGroundUrl` 回填到 `imgSrc` 和 `templateInfo.backGroundUrl`。
|
* 将 `BackGroundUrl` 回填到 `imgSrc` 和 `templateInfo.backGroundUrl`。
|
||||||
- 解析 `TempContent` (JSON 字符串) 回填到 `regions` 列表。
|
* 解析 `TempContent` (JSON 字符串) 回填到 `regions` 列表。
|
||||||
|
|
||||||
### 3.2 上传底图 (Upload Background)
|
### 3.2 上传底图 (Upload Background)
|
||||||
|
|
||||||
@ -32,18 +32,18 @@
|
|||||||
|
|
||||||
1. **触发上传**: 用户点击“上传底稿”。
|
1. **触发上传**: 用户点击“上传底稿”。
|
||||||
2. **上传检查 (`handleUploadCheck`)**:
|
2. **上传检查 (`handleUploadCheck`)**:
|
||||||
- 如果已有底图,弹出确认框,提示“更换底图将清空当前已生成的布局”。
|
* 如果已有底图,弹出确认框,提示“更换底图将清空当前已生成的布局”。
|
||||||
- 确认后打开文件选择器。
|
* 确认后打开文件选择器。
|
||||||
3. **文件处理 (`handleUpload`)**:
|
3. **文件处理 (`handleUpload`)**:
|
||||||
- **PDF 处理**: 使用 `pdfjs-dist` 渲染 PDF 第一页,按 A4 比例(2100px 宽)转换为 Canvas,再导出为 Blob。
|
* **PDF 处理**: 使用 `pdfjs-dist` 渲染 PDF 第一页,按 A4 比例(2100px 宽)转换为 Canvas,再导出为 Blob。
|
||||||
- **图片处理**: 直接读取图片文件。
|
* **图片处理**: 直接读取图片文件。
|
||||||
4. **OSS 上传**:
|
4. **OSS 上传**:
|
||||||
- 调用 `getAliOssTokenAxios` 获取 STS Token。
|
* 调用 `getAliOssTokenAxios` 获取 STS Token。
|
||||||
- 使用 `ali-oss` 客户端将文件上传至 OSS。
|
* 使用 `ali-oss` 客户端将文件上传至 OSS。
|
||||||
5. **状态更新**:
|
5. **状态更新**:
|
||||||
- 更新 `imgSrc` 为 OSS 返回的 URL。
|
* 更新 `imgSrc` 为 OSS 返回的 URL。
|
||||||
- 更新 `templateInfo` 的宽高。
|
* 更新 `templateInfo` 的宽高。
|
||||||
- **清空现有区域** (`regions`) 和选中状态 (`selectedRegionIds`)。
|
* **清空现有区域** (`regions`) 和选中状态 (`selectedRegionIds`)。
|
||||||
|
|
||||||
### 3.3 区域生成 (Region Generation)
|
### 3.3 区域生成 (Region Generation)
|
||||||
|
|
||||||
@ -51,66 +51,64 @@
|
|||||||
|
|
||||||
1. **配置参数**: 用户在 `TemplateSettings` 面板输入行列数、宽高、间距。
|
1. **配置参数**: 用户在 `TemplateSettings` 面板输入行列数、宽高、间距。
|
||||||
2. **计算编号**:
|
2. **计算编号**:
|
||||||
- 遍历现有 `regions`,解析 `label` (如 "Q1", "Q2"),找到最大编号 `maxNum`。
|
* 遍历现有 `regions`,解析 `label` (如 "Q1", "Q2"),找到最大编号 `maxNum`。
|
||||||
- 新生成的区域编号从 `maxNum + 1` 开始递增。
|
* 新生成的区域编号从 `maxNum + 1` 开始递增。
|
||||||
3. **批量创建**:
|
3. **批量创建**:
|
||||||
- 根据行 (`rows`)、列 (`cols`) 双重循环。
|
* 根据行 (`rows`)、列 (`cols`) 双重循环。
|
||||||
- 计算每个区域的 `x, y` 坐标:`start + index * (size + gap)`。
|
* 计算每个区域的 `x, y` 坐标:`start + index * (size + gap)`。
|
||||||
- 为这批区域生成同一个 `groupId` (UUID)。
|
* 为这批区域生成同一个 `groupId` (UUID)。
|
||||||
4. **自动选中**:
|
4. **自动选中**:
|
||||||
- 将新生成的区域加入 `regions` 列表。
|
* 将新生成的区域加入 `regions` 列表。
|
||||||
- 如果生成了多个区域,自动将它们全部加入 `selectedRegionIds`,方便用户立即进行批量拖拽。
|
* 如果生成了多个区域,自动将它们全部加入 `selectedRegionIds`,方便用户立即进行批量拖拽。
|
||||||
|
|
||||||
### 3.4 画布交互 (Canvas Interaction)
|
### 3.4 画布交互 (Canvas Interaction)
|
||||||
|
|
||||||
逻辑在 `modules/TemplateCanvas.vue` 中,核心依赖 `vue3-draggable-resizable`:
|
逻辑在 `modules/TemplateCanvas.vue` 中,核心依赖 `vue3-draggable-resizable`:
|
||||||
|
|
||||||
1. **渲染**:
|
1. **渲染**:
|
||||||
- 底图作为背景。
|
* 底图作为背景。
|
||||||
- 遍历 `regions` 渲染 `VueDraggableResizable` 组件。
|
* 遍历 `regions` 渲染 `VueDraggableResizable` 组件。
|
||||||
2. **选择逻辑**:
|
2. **选择逻辑**:
|
||||||
- **单选**: 点击某个区域,清除其他选中,只选中当前。
|
* **单选**: 点击某个区域,清除其他选中,只选中当前。
|
||||||
- **多选**: 按住 Shift/Ctrl/Meta 点击,切换当前区域的选中状态。
|
* **多选**: 按住 Shift/Ctrl/Meta 点击,切换当前区域的选中状态。
|
||||||
- **取消选中**: 点击画布空白处 (`onCanvasMouseDown`),清空 `selectedRegionIds`。
|
* **取消选中**: 点击画布空白处 (`onCanvasMouseDown`),清空 `selectedRegionIds`。
|
||||||
- **从多选切换单选**: 在多选状态下,**松开鼠标** (`onRegionMouseUp`) 且没有发生拖拽(位移 < 3px)时,切换为单选当前区域。
|
* **从多选切换单选**: 在多选状态下,**松开鼠标** (`onRegionMouseUp`) 且没有发生拖拽(位移 < 3px)时,切换为单选当前区域。
|
||||||
3. **拖拽与移动 (`onDragging`)**:
|
3. **拖拽与移动 (`onDragging`)**:
|
||||||
- **单体移动**: 拖拽单个元素。
|
* **单体移动**: 拖拽单个元素。
|
||||||
- **批量移动**: 如果当前拖拽的元素在选中集合中,计算其位移差 (`deltaX`, `deltaY`),同步更新所有选中元素的坐标。
|
* **批量移动**: 如果当前拖拽的元素在选中集合中,计算其位移差 (`deltaX`, `deltaY`),同步更新所有选中元素的坐标。
|
||||||
- **辅助线**: 计算当前拖拽元素中心点与画布中心点的距离,小于阈值时显示吸附辅助线。
|
* **辅助线**: 计算当前拖拽元素中心点与画布中心点的距离,小于阈值时显示吸附辅助线。
|
||||||
4. **缩放与旋转**:
|
4. **缩放与旋转**:
|
||||||
- **缩放**: 通过组件自带句柄调整 `w, h`。
|
* **缩放**: 通过组件自带句柄调整 `w, h`。
|
||||||
- **旋转**: 自定义旋转手柄,计算鼠标角度变化,更新 `rotation` 属性。
|
* **旋转**: 自定义旋转手柄,计算鼠标角度变化,更新 `rotation` 属性。
|
||||||
|
|
||||||
### 3.5 保存 (Save)
|
### 3.5 保存 (Save)
|
||||||
|
|
||||||
逻辑在 `index.vue` 的 `handleSave` 中:
|
逻辑在 `index.vue` 的 `handleSave` 中:
|
||||||
|
|
||||||
1. **数据组装**:
|
1. **数据组装**:
|
||||||
- 收集 `templateInfo` (名称、宽高、背景图)。
|
* 收集 `templateInfo` (名称、宽高、背景图)。
|
||||||
- 将 `regions` 数组序列化为 JSON 字符串 (`tempContent`)。
|
* 将 `regions` 数组序列化为 JSON 字符串 (`tempContent`)。
|
||||||
2. **API 调用**: 调用 `fetchAddTemplate` 提交数据。
|
2. **API 调用**: 调用 `fetchAddTemplate` 提交数据。
|
||||||
3. **反馈**: 成功后提示并返回上一页。
|
3. **反馈**: 成功后提示并返回上一页。
|
||||||
|
|
||||||
## 4. 数据结构说明
|
## 4. 数据结构说明
|
||||||
|
|
||||||
### Region (区域)
|
### Region (区域)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface Region {
|
interface Region {
|
||||||
id: string // 唯一标识
|
id: string // 唯一标识
|
||||||
groupId: string // 批次ID (同一次生成的区域共享)
|
groupId: string // 批次ID (同一次生成的区域共享)
|
||||||
x: number // X坐标
|
x: number // X坐标
|
||||||
y: number // Y坐标
|
y: number // Y坐标
|
||||||
w: number // 宽度
|
w: number // 宽度
|
||||||
h: number // 高度
|
h: number // 高度
|
||||||
label: string // 标签 (如 Q1)
|
label: string // 标签 (如 Q1)
|
||||||
selected: boolean // (前端临时状态)
|
selected: boolean // (前端临时状态)
|
||||||
rotation?: number // 旋转角度
|
rotation?: number // 旋转角度
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### TemplateInfo (模板信息)
|
### TemplateInfo (模板信息)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface TemplateInfo {
|
interface TemplateInfo {
|
||||||
name: string
|
name: string
|
||||||
@ -121,25 +119,24 @@ interface TemplateInfo {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 核心原理
|
### 核心原理
|
||||||
|
|
||||||
我们在前端渲染时,是基于 A4 标准尺寸 (210mm × 297mm) 进行比例换算的。
|
我们在前端渲染时,是基于 A4 标准尺寸 (210mm × 297mm) 进行比例换算的。
|
||||||
|
|
||||||
- 校验逻辑 :如果系统认为当前画布宽度对应 210mm,那么我们可以在画布上绘制一个 10mm × 10mm 的标准网格。
|
- 校验逻辑 :如果系统认为当前画布宽度对应 210mm,那么我们可以在画布上绘制一个 10mm × 10mm 的标准网格。
|
||||||
- 测试方法 :只需要上传一张 带有刻度尺 或 已知网格 的标准 A4 图片/PDF,开启系统网格,观察系统的红线是否与图片上的刻度重合,即可验证位置和比例是否精确。
|
- 测试方法 :只需要上传一张 带有刻度尺 或 已知网格 的标准 A4 图片/PDF,开启系统网格,观察系统的红线是否与图片上的刻度重合,即可验证位置和比例是否精确。
|
||||||
|
|
||||||
### 网格辅助层和开关控制,具体修改如下:
|
### 网格辅助层和开关控制,具体修改如下:
|
||||||
|
|
||||||
1. TemplateCanvas.vue (画布组件)
|
1. TemplateCanvas.vue (画布组件)
|
||||||
|
|
||||||
- 新增了 showGrid 属性和网格渲染层。
|
- 新增了 showGrid 属性和网格渲染层。
|
||||||
- 自动计算:根据当前画布宽度( templateInfo.width )与 A4 宽度(210mm)的比例,动态计算出 10mm 在屏幕上的像素值。
|
- 自动计算:根据当前画布宽度( templateInfo.width )与 A4 宽度(210mm)的比例,动态计算出 10mm 在屏幕上的像素值。
|
||||||
- 使用 SVG pattern 技术绘制高精度的 10mm 标准红线网格。
|
- 使用 SVG pattern 技术绘制高精度的 10mm 标准红线网格。
|
||||||
|
|
||||||
2. index.vue (主页面)
|
2. index.vue (主页面)
|
||||||
|
|
||||||
- 在顶部工具栏增加了 "显示网格/隐藏网格" 按钮。
|
- 在顶部工具栏增加了 "显示网格/隐藏网格" 按钮。
|
||||||
- 现在的操作栏顺序为:返回 -> 标题 -> 网格开关 -> 上传按钮 -> 保存按钮。
|
- 现在的操作栏顺序为:返回 -> 标题 -> 网格开关 -> 上传按钮 -> 保存按钮。
|
||||||
|
|
||||||
### 如何进行测试(操作步骤)
|
|
||||||
|
|
||||||
|
### 如何进行测试(操作步骤)
|
||||||
1. 准备测试底图 :找UI设计一张 A4 尺寸 (210x297mm) 的图片或 PDF,在上面画上 10mm 间隔的标尺或网格。(系统已支持直接上传 PDF,会自动渲染为高清底图)。
|
1. 准备测试底图 :找UI设计一张 A4 尺寸 (210x297mm) 的图片或 PDF,在上面画上 10mm 间隔的标尺或网格。(系统已支持直接上传 PDF,会自动渲染为高清底图)。
|
||||||
2. 上传底图 :在系统中点击“上传底稿”上传该文件。
|
2. 上传底图 :在系统中点击“上传底稿”上传该文件。
|
||||||
3. 开启网格 :点击顶部新增的 "显示网格" 按钮。
|
3. 开启网格 :点击顶部新增的 "显示网格" 按钮。
|
||||||
@ -148,27 +145,27 @@ interface TemplateInfo {
|
|||||||
- 如果重合,说明系统的坐标映射与物理 A4 纸张是完全对应的。
|
- 如果重合,说明系统的坐标映射与物理 A4 纸张是完全对应的。
|
||||||
- 如果不重合(例如系统网格偏大或偏小),则说明上传的图片本身比例不是标准的 A4 比例,或者存在边缘留白问题。
|
- 如果不重合(例如系统网格偏大或偏小),则说明上传的图片本身比例不是标准的 A4 比例,或者存在边缘留白问题。
|
||||||
|
|
||||||
### A4 尺寸与像素换算说明
|
|
||||||
|
|
||||||
|
|
||||||
|
### A4 尺寸与像素换算说明
|
||||||
下面是页面渲染在页面的真实图片的信息:
|
下面是页面渲染在页面的真实图片的信息:
|
||||||
Rendered size: 2100 × 2970 px
|
Rendered size: 2100 × 2970 px
|
||||||
Rendered aspect ratio: 70∶99
|
Rendered aspect ratio: 70∶99
|
||||||
Intrinsic size: 2100 × 2969 px
|
Intrinsic size: 2100 × 2969 px
|
||||||
Intrinsic aspect ratio: 2100∶2969
|
Intrinsic aspect ratio: 2100∶2969
|
||||||
File size: 526 kB
|
File size: 526 kB
|
||||||
Current source: https://oss.qyzhjy.com/temp/1769655687535/book.jpg
|
Current source: https://oss.qyzhjy.com/temp/1769655687535/book.jpg
|
||||||
|
|
||||||
比如生成出来的图片尺寸 2100px × 2970px , ,这是系统专门针对 A4 纸张优化的标准分辨率。
|
比如生成出来的图片尺寸 2100px × 2970px , ,这是系统专门针对 A4 纸张优化的标准分辨率。
|
||||||
|
1. 换算公式
|
||||||
1. 换算公式
|
我们的代码(在 useUpload.ts 中)在处理 PDF 转图片时,强制将宽度设定为 2100px 。基于 A4 标准宽度 210mm ,换算关系如下:
|
||||||
我们的代码(在 useUpload.ts 中)在处理 PDF 转图片时,强制将宽度设定为 2100px 。基于 A4 标准宽度 210mm ,换算关系如下:
|
[ o bj ec tO bj ec t ] 1 mm = 10 px
|
||||||
[ 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 ] 210 mm × 10 = 2100 px
|
||||||
- 高度 : [ o bj ec tO bj ec t ] 297 mm × 10 = 2970 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. 如何验证比例是否正确?
|
- 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)。
|
2. 系统知道这是 A4 纸(210mm)。
|
||||||
3. 系统自动计算出 10mm = (2100 / 21) = 100px 。
|
3. 系统自动计算出 10mm = (2100 / 21) = 100px 。
|
||||||
4. 系统在屏幕上绘制间隔为 100px 的网格线。
|
4. 系统在屏幕上绘制间隔为 100px 的网格线。
|
||||||
验证方法: 如果您上传了一张分辨率较低的图片(例如 1050px 宽),系统会自动计算出 1mm = 5px ,网格线会变为 50px 间隔。 只要网格线能与您图片上的物理刻度对齐,就说明换算和位置是绝对精准的。
|
验证方法: 如果您上传了一张分辨率较低的图片(例如 1050px 宽),系统会自动计算出 1mm = 5px ,网格线会变为 50px 间隔。 只要网格线能与您图片上的物理刻度对齐,就说明换算和位置是绝对精准的。
|
||||||
5. 结论
|
3. 结论
|
||||||
|
|
||||||
- 您当前的图片 2100 x 2970 是 完美标准 。
|
- 您当前的图片 2100 x 2970 是 完美标准 。
|
||||||
- 如果您上传其他尺寸,只要长宽比接近 A4 (1:1.414),我们的网格系统都能自动适配并准确显示 10mm 刻度。
|
- 如果您上传其他尺寸,只要长宽比接近 A4 (1:1.414),我们的网格系统都能自动适配并准确显示 10mm 刻度。
|
||||||
- 您可以使用我在顶部新增的 “显示网格” 按钮来直观验证这一点。
|
- 您可以使用我在顶部新增的 “显示网格” 按钮来直观验证这一点。
|
||||||
|
|||||||
Reference in New Issue
Block a user