feat(template): 新增答题卡模板编辑器及相关功能

- 新增模板详情页面,支持上传PDF/图片作为底稿,并生成可拖拽调整的区域
- 新增模板类型定义、上传钩子及画布组件,支持批量生成区域、旋转、对齐参考线等功能
- 更新模板列表页,适配新的API接口并增加删除功能
- 更新OSS工具函数,修复上传凭证处理逻辑
- 更新公共类型定义,调整分页查询返回结构
- 移除旧的书籍相关API文件,替换为模板API
- 更新依赖项,添加pdfjs-dist、vue3-draggable-resizable等库
- 隐藏页面底部,优化界面显示
This commit is contained in:
2026-01-28 18:00:17 +08:00
parent 78e51d96f6
commit 16d7ecd172
17 changed files with 1207 additions and 266 deletions

View File

@ -0,0 +1,187 @@
import type { UploadCustomRequestOptions } from 'naive-ui'
import type { Region, TemplateInfo } from '../types'
import * as pdfjsLib from 'pdfjs-dist'
import { onMounted, ref, type Ref } from 'vue'
import { getAliOssTokenAxios } from '@/service/api/upload'
import { browserPathJoin } from '@/utils/date'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
export function useUpload(
imgSrc: Ref<string>,
templateInfo: Ref<TemplateInfo>,
regions: Ref<Region[]>,
selectedRegionIds: Ref<Set<string>>,
) {
const loading = ref(false)
onMounted(() => {
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString()
})
/**
* 渲染 PDF 页面
*/
async function renderPdfPage(file: File): Promise<{ url: string, width: number, height: number, blob: Blob }> {
const arrayBuffer = await file.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
const page = await pdf.getPage(1)
// 这里的 scale 决定了分辨率。A4 @ 72dpi ~= 595x842.
// 2100 / 595 ~= 3.5.
const viewportBase = page.getViewport({ scale: 1 })
const scale = 2100 / viewportBase.width
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
throw new Error('Canvas context not found')
}
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({
canvasContext: context,
viewport,
canvas,
}).promise
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve({
url: URL.createObjectURL(blob),
width: viewport.width,
height: viewport.height,
blob,
})
}
else {
reject(new Error('Canvas to Blob failed'))
}
}, 'image/jpeg', 0.8)
})
}
/**
* 上传文件到 OSS
*/
async function uploadToOSS(file: File | Blob, fileName: string): Promise<string> {
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
if (tokenError || !tokenData) {
throw new Error('获取上传凭证失败')
}
// 初始化OSS客户端
const client = initOSSClient(tokenData.data)
const path = browserPathJoin(`temp/${Date.now()}`, fileName)
// 上传文件
await uploadFileToOSS(client, file as any, path)
return browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
}
/**
* 触发上传前的检查
* @param openFileDialog 打开文件选择器的回调
*/
function handleUploadCheck(openFileDialog: () => void) {
if (imgSrc.value) {
window?.$dialog?.warning({
title: '确认更换',
content: '更换底图将清空当前已生成的布局,是否继续?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
openFileDialog()
},
})
}
else {
openFileDialog()
}
}
/**
* 处理上传
*/
function handleUpload(options: UploadCustomRequestOptions) {
const { file, onFinish, onError } = options
const processFile = async () => {
loading.value = true
try {
const currentFile = file.file as File
let width = 0
let height = 0
let fileToUpload: File | Blob = currentFile
let uploadFileName = currentFile.name
if (file.type === 'application/pdf') {
const result = await renderPdfPage(currentFile)
width = result.width
height = result.height
fileToUpload = result.blob
uploadFileName = currentFile.name.replace('.pdf', '.jpg')
}
else {
// Image
await new Promise<void>((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(currentFile)
reader.onload = () => {
const url = reader.result as string
const img = new Image()
img.onload = () => {
width = img.width
height = img.height
resolve()
}
img.onerror = reject
img.src = url
}
reader.onerror = reject
})
}
// 上传到 OSS
const ossUrl = await uploadToOSS(fileToUpload, uploadFileName)
// 更新状态
imgSrc.value = ossUrl
templateInfo.value.width = Math.round(width)
templateInfo.value.height = Math.round(height)
templateInfo.value.backGroundUrl = ossUrl
// Clear regions
regions.value = []
selectedRegionIds.value.clear()
onFinish()
window?.$message?.success('上传成功')
}
catch (e) {
console.error(e)
onError()
window?.$message?.error('文件处理失败')
}
finally {
loading.value = false
}
}
processFile()
}
return {
loading,
handleUpload,
handleUploadCheck,
}
}

View File

@ -1,9 +1,180 @@
<!-- eslint-disable no-console -->
<script lang="ts" setup>
//
import type { GenSettings, Region, TemplateInfo } from './types'
import { Icon } from '@iconify/vue'
import { NButton, NUpload, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRouterPush } from '@/hooks/common/router'
import { fetchAddTemplate } from '@/service/api/template'
import { useUpload } from './hooks/useUpload'
import TemplateCanvas from './modules/TemplateCanvas.vue'
import TemplateSettings from './modules/TemplateSettings.vue'
const route = useRoute()
const message = useMessage()
const { routerBack } = useRouterPush()
// 模版基础信息
const templateInfo = ref<TemplateInfo>({
name: '期末大考标准答题卡',
width: 2100,
height: 2994,
})
const imgSrc = ref('')
// 生成配置
const genSettings = ref<GenSettings>({
cols: 1,
rows: 1,
w: 296,
h: 309,
gapX: 5,
gapY: 0,
startX: 0,
startY: 0,
})
// 区域列表
const regions = ref<Region[]>([])
// 选中区域集合
const selectedRegionIds = ref<Set<string>>(new Set())
// Use Hook
const { loading, handleUpload, handleUploadCheck } = useUpload(imgSrc, templateInfo, regions, selectedRegionIds)
const uploadRef = ref<any>(null)
function handleUploadTrigger() {
handleUploadCheck(() => {
uploadRef.value?.openOpenFileDialog()
})
}
// --- Actions ---
/**
* 处理区域生成
*/
function handleGenerate() {
const groupId = crypto.randomUUID()
const newRegions: Region[] = []
const currentX = genSettings.value.startX
const currentY = genSettings.value.startY
for (let r = 0; r < genSettings.value.rows; r++) {
for (let c = 0; c < genSettings.value.cols; c++) {
newRegions.push({
id: crypto.randomUUID(),
groupId,
x: currentX + c * (genSettings.value.w + genSettings.value.gapX),
y: currentY + r * (genSettings.value.h + genSettings.value.gapY),
w: genSettings.value.w,
h: genSettings.value.h,
label: `Q${newRegions.length + 1}`, // Placeholder label
selected: false,
})
}
}
regions.value.push(...newRegions)
if (newRegions.length > 1) {
const newIds = new Set(newRegions.map(r => r.id))
selectedRegionIds.value = newIds
}
window?.$message?.success(`已生成 ${newRegions.length} 个区域`)
}
/**
* 删除区域
*/
function deleteRegion(id: string) {
regions.value = regions.value.filter(r => r.id !== id)
selectedRegionIds.value.delete(id)
}
/**
* 清空所有
*/
function clearAll() {
regions.value = []
selectedRegionIds.value.clear()
}
/**
* 保存模板
*/
async function handleSave() {
const id = Number(route.query.id) || 0
const params: Api.Template.AddTemplateParams = {
id,
pageNo: '1',
name: templateInfo.value.name,
backGroundUrl: templateInfo.value.backGroundUrl || '',
width: Math.round(templateInfo.value.width),
height: Math.round(templateInfo.value.height),
tempContent: JSON.stringify(regions.value),
}
const { error } = await fetchAddTemplate(params)
if (!error) {
message.success('保存成功')
routerBack()
}
}
</script>
<template>
<div class="">
<!-- page only one root element -->
<div class="h-full flex flex-col bg-gray-100">
<!-- Header -->
<div class="h-14 flex shrink-0 items-center justify-between border-b bg-white px-4">
<div class="flex items-center gap-2">
<NButton quaternary circle @click="routerBack()">
<template #icon>
<Icon icon="carbon:arrow-left" />
</template>
</NButton>
<span class="text-lg font-medium">答题卡排版编辑器 V2.0</span>
</div>
<div class="flex items-center gap-3">
<NUpload
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>
<Icon icon="carbon:upload" />
</template>
上传底稿
</NButton>
<NButton type="primary" :loading="loading" :disabled="!templateInfo.backGroundUrl" @click="handleSave">
完成设计并保存
</NButton>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Left: Canvas Area -->
<TemplateCanvas
v-model:template-info="templateInfo" v-model:regions="regions"
v-model:selected-region-ids="selectedRegionIds" :img-src="imgSrc" :loading="loading"
/>
<!-- Right: Settings Panel -->
<TemplateSettings
v-model:template-info="templateInfo" v-model:gen-settings="genSettings"
:regions="regions" @generate="handleGenerate" @delete-region="deleteRegion" @clear-all="clearAll"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,406 @@
<script lang="ts" setup>
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 VueDraggableResizable from 'vue3-draggable-resizable'
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
const props = defineProps<{
templateInfo: TemplateInfo
regions: Region[]
selectedRegionIds: Set<string>
imgSrc: string
loading: boolean
}>()
const emit = defineEmits<{
(e: 'update:templateInfo', info: TemplateInfo): void
(e: 'update:regions', regions: Region[]): void
(e: 'update:selectedRegionIds', ids: Set<string>): void
}>()
// 画布状态
const scale = ref(0.4) // CSS 缩放级别
// 对齐参考线
const guides = ref({
x: null as number | null,
y: null as number | null,
})
function resetZoom() {
scale.value = 0.4
}
// 监听图片变化,自动重置缩放
watch(() => props.imgSrc, () => {
if (props.imgSrc) {
nextTick(() => {
resetZoom()
})
}
})
// 拖拽初始状态记录
const dragStartPositions = ref<Map<string, { x: number, y: number }>>(new Map())
// --- 拖拽与调整大小逻辑 ---
/**
* 处理区域点击/选择
* 支持多选:按住 Shift/Ctrl/Meta 键
*/
function onRegionMouseDown(region: Region, e: MouseEvent) {
const isModifierDown = e.shiftKey || e.ctrlKey || e.metaKey
const isSelected = props.selectedRegionIds.has(region.id)
const newIds = new Set(props.selectedRegionIds)
if (isModifierDown) {
// 多选模式:切换选中状态
if (isSelected) {
newIds.delete(region.id)
}
else {
newIds.add(region.id)
}
emit('update:selectedRegionIds', newIds)
}
else {
// 单选模式
// 如果点击的是未选中的区域,则清除其他选中,只选中当前
// 如果点击的是已选中的区域(可能是拖拽群组的一部分),则不清除,保留当前选中集合
if (!isSelected) {
emit('update:selectedRegionIds', new Set([region.id]))
}
}
}
// 移除 onActivated 的选中逻辑,完全由 onRegionMouseDown 接管
function onActivated(_id: string) {
// 空实现,避免覆盖多选逻辑
}
/**
* 拖拽开始
* 记录所有选中区域的初始位置,用于计算相对位移
*/
function onDragStart(region: Region, _payload: any) {
// 确保当前拖拽的区域在选中列表中(防御性编程)
if (!props.selectedRegionIds.has(region.id)) {
const newIds = new Set(props.selectedRegionIds)
newIds.add(region.id)
emit('update:selectedRegionIds', newIds)
}
// 记录所有选中区域的初始位置
dragStartPositions.value.clear()
props.regions.forEach((r) => {
if (props.selectedRegionIds.has(r.id)) {
dragStartPositions.value.set(r.id, { x: r.x, y: r.y })
}
})
}
function onDragging(region: Region, payload: any) {
// Payload 是 { x, y } (当前拖拽元素的实时坐标)
const { x, y } = payload
// 1. 计算对齐参考线 (仅针对当前拖拽的主元素)
const w = region.w
const h = region.h
const centerX = props.templateInfo.width / 2
const centerY = props.templateInfo.height / 2
const objCenterX = x + w / 2
const objCenterY = y + h / 2
const threshold = 10
let showGuideX = null
let showGuideY = null
if (Math.abs(objCenterX - centerX) < threshold) {
showGuideX = centerX
}
if (Math.abs(objCenterY - centerY) < threshold) {
showGuideY = centerY
}
guides.value = { x: showGuideX, y: showGuideY }
// 2. 批量移动逻辑
const startPos = dragStartPositions.value.get(region.id)
if (!startPos)
return
// 计算当前拖拽元素的位移增量
const deltaX = x - startPos.x
const deltaY = y - startPos.y
// 应用位移到所有选中区域
const newRegions = props.regions.map((r) => {
if (props.selectedRegionIds.has(r.id)) {
const initial = dragStartPositions.value.get(r.id)
if (initial) {
return {
...r,
x: initial.x + deltaX,
y: initial.y + deltaY,
}
}
}
return r
})
// 实时更新 props.regions 以实现视觉同步移动
emit('update:regions', newRegions)
}
function onDragEnd(_region: Region, _payload: any) {
guides.value = { x: null, y: null }
// 清理初始位置记录
dragStartPositions.value.clear()
// 确保最后一次状态更新 (通常 onDragging 已经处理了,但为了保险)
// 注意payload 仅包含当前拖拽元素的最终位置
// 这里的逻辑其实已经在 onDragging 中通过 delta 完成了所有元素的更新
// 所以这里主要做清理工作,或者再次确认最终位置(可选)
}
function onResizeEnd(region: Region, payload: any) {
const { x, y, w, h } = payload
const newRegions = props.regions.map(r =>
r.id === region.id
? {
...r,
x: Math.round(x),
y: Math.round(y),
w: Math.round(w),
h: Math.round(h),
}
: r,
)
emit('update:regions', newRegions)
}
/**
* 处理旋转
*/
function onRotateStart(region: Region, e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
const el = (e.target as HTMLElement).closest('.region-content')
if (!el)
return
const rect = el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX)
const initialRotation = region.rotation || 0
const onMove = (moveEvent: MouseEvent) => {
const currentAngle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX)
const delta = currentAngle - startAngle
const deg = (delta * 180) / Math.PI
// 更新本地旋转状态
const newRotation = (initialRotation + deg) % 360
// 我们需要更新区域属性以实时查看更改
// 因为我们不能修改 props所以发出 update 事件
// 但在每次移动时发出事件可能会很重?
// 对于旋转来说通常是可以的。
const newRegions = props.regions.map(r =>
r.id === region.id ? { ...r, rotation: newRotation } : r,
)
emit('update:regions', newRegions)
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
/**
* 处理对象删除 (通过 UI 或按键)
*/
function handleDelete() {
if (props.selectedRegionIds.size > 0) {
const newRegions = props.regions.filter(r => !props.selectedRegionIds.has(r.id))
emit('update:regions', newRegions)
emit('update:selectedRegionIds', new Set())
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Delete' || e.key === 'Backspace') {
handleDelete()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<template>
<div class="checker-bg relative flex flex-1 overflow-hidden bg-[#eef0f4]">
<NScrollbar>
<div class="min-h-full min-w-full flex items-center justify-center py-[10px]">
<!-- 缩放框容器 -->
<div
class="relative transition-all duration-200 ease-out" :style="{
width: `${templateInfo.width * scale}px`,
height: `${templateInfo.height * scale}px`,
}"
>
<!-- 纸张/画布 -->
<div
class="relative origin-top-left bg-white shadow-lg" :style="{
transform: `scale(${scale})`,
width: `${templateInfo.width}px`,
height: `${templateInfo.height}px`,
}"
>
<!-- 背景图片 -->
<img
v-if="imgSrc" :src="imgSrc"
class="pointer-events-none absolute inset-0 h-full w-full select-none object-contain"
>
<!-- 对齐参考线 -->
<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)"
>
<!-- 视觉边框 (旋转) -->
<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>
<!-- 加载遮罩 -->
<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>
</NScrollbar>
<!-- 缩放控制 -->
<div
class="absolute bottom-4 left-4 z-10 flex items-center gap-2 rounded-full bg-white px-4 py-1 shadow ring-1 ring-black/5"
>
<NButton size="small" quaternary circle title="适应屏幕" @click="resetZoom">
<Icon icon="carbon:fit-to-screen" />
</NButton>
<div class="mx-1 h-3 w-px bg-gray-300" />
<NButton size="small" quaternary circle @click="scale = Math.max(scale - 0.05, 0.1)">
-
</NButton>
<span class="font-variant-numeric w-12 text-center text-xs tabular-nums">{{ Math.round(scale * 100) }}%</span>
<NButton size="small" quaternary circle @click="scale = Math.min(scale + 0.05, 3)">
+
</NButton>
<div class="mx-1 h-3 w-px bg-gray-300" />
<NButton size="small" quaternary circle title="删除选中" type="error" @click="handleDelete">
<Icon icon="carbon:trash-can" />
</NButton>
</div>
</div>
</template>
<style scoped>
.checker-bg {
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
background-size: 20px 20px;
}
/* Custom styles for the draggable component */
:deep(.vdr-container) {
border: 1px dashed #4caf50;
background-color: rgba(76, 175, 80, 0.1);
}
:deep(.active-region) {
border: 1px dashed rgba(76, 175, 80, 0.5);
z-index: 10;
}
/* Hide default handles until active? No, usually they show when active */
:deep(.vdr-handle) {
/* Customize handles if needed */
background-color: #ffffff;
border: 1px solid #4caf50;
border-radius: 50%;
width: 8px;
height: 8px;
}
</style>

View File

@ -0,0 +1,140 @@
<script lang="ts" setup>
import type { GenSettings, Region, TemplateInfo } from '../types'
import { Icon } from '@iconify/vue'
import { NButton, NForm, NFormItem, NGrid, NGridItem, NInput, NInputNumber } from 'naive-ui'
import { computed } from 'vue'
const props = defineProps<{
templateInfo: TemplateInfo
genSettings: GenSettings
regions: Region[]
}>()
const emit = defineEmits<{
(e: 'update:templateInfo', info: TemplateInfo): void
(e: 'update:genSettings', settings: GenSettings): void
(e: 'generate'): void
(e: 'deleteRegion', id: string): void
(e: 'clearAll'): void
}>()
const localTemplateInfo = computed({
get: () => props.templateInfo,
set: val => emit('update:templateInfo', val),
})
const localGenSettings = computed({
get: () => props.genSettings,
set: val => emit('update:genSettings', val),
})
</script>
<template>
<div class="w-[360px] flex flex-col shrink-0 overflow-y-auto border-l bg-white">
<div class="border-b p-4">
<h3 class="mb-4 flex items-center gap-2 text-gray-700 font-medium">
<span class="h-4 w-1 rounded bg-gray-400" /> 模版基础信息
</h3>
<NForm label-placement="top" size="small">
<NFormItem label="模版名称">
<NInput v-model:value="localTemplateInfo.name" />
</NFormItem>
<NGrid :cols="2" :x-gap="12">
<NGridItem>
<NFormItem label="画布宽 (px)">
<NInputNumber v-model:value="localTemplateInfo.width" />
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem label="画布高 (px)">
<NInputNumber v-model:value="localTemplateInfo.height" />
</NFormItem>
</NGridItem>
</NGrid>
</NForm>
</div>
<div class="border-b p-4">
<h3 class="mb-4 flex items-center gap-2 text-gray-700 font-medium">
<span class="h-4 w-1 rounded bg-gray-400" /> 区域批量生成
</h3>
<NForm label-placement="top" size="small">
<NGrid :cols="2" :x-gap="12">
<NGridItem>
<NFormItem label="列数 (Cols)">
<NInputNumber v-model:value="localGenSettings.cols" :min="1" />
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem label="行数 (Rows)">
<NInputNumber v-model:value="localGenSettings.rows" :min="1" />
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem label="单框宽 (W)">
<NInputNumber v-model:value="localGenSettings.w" :min="1" />
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem label="单框高 (H)">
<NInputNumber v-model:value="localGenSettings.h" :min="1" />
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem label="左右间距">
<NInputNumber v-model:value="localGenSettings.gapX" :min="0" />
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem label="上下间距">
<NInputNumber v-model:value="localGenSettings.gapY" :min="0" />
</NFormItem>
</NGridItem>
</NGrid>
<NButton block type="primary" class="mt-4" :disabled="!templateInfo.backGroundUrl" @click="$emit('generate')">
<template #icon>
<Icon icon="carbon:add" />
</template>
新增题目组 (默认横向)
</NButton>
</NForm>
</div>
<div class="flex-1 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')">
清空全部
</NButton>
</div>
<div v-if="regions.length === 0" class="flex flex-col items-center justify-center py-10 text-gray-400">
<Icon icon="carbon:image" class="mb-2 text-4xl opacity-20" />
<span class="text-xs">列表为空点击上方生成区域</span>
</div>
<div class="space-y-2">
<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"
>
<div class="flex items-start justify-between">
<div>
<div class="text-sm font-medium">
{{ region.label }}
</div>
<div class="mt-1 text-xs text-gray-500">
w:{{ region.w }},h:{{ region.h }},x:{{ region.x }},y:{{ region.y }}
</div>
</div>
<NButton size="tiny" quaternary circle type="error" @click="$emit('deleteRegion', region.id)">
<template #icon>
<Icon icon="carbon:trash-can" />
</template>
</NButton>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,35 @@
export interface Region {
id: string
groupId: string
x: number
y: number
w: number
h: number
rotation?: number
label: string
selected: boolean
}
export interface TemplateInfo {
name: string
width: number
height: number
backGroundUrl?: string
}
export interface GenSettings {
cols: number
rows: number
w: number
h: number
gapX: number
gapY: number
startX: number
startY: number
}
export interface Group {
id: string
name: string
count: number
}

View File

@ -2,8 +2,8 @@
import { NButton, NPopconfirm, NTag } from 'naive-ui'
import { reactive } from 'vue'
import { useRouterPush } from '@/hooks/common/router'
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { fetchTemplateList } from '@/service/api'
import { useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { fetchDeleteTemplate, fetchTemplateList } from '@/service/api'
import { useAppStore } from '@/store/modules/app'
import TemplateOperateDrawer from './modules/template-operate-drawer.vue'
import TemplateSearch from './modules/template-search.vue'
@ -23,11 +23,6 @@ const searchParams: Api.Template.TemplateSearchParams = reactive({
const templateTitle = '模板管理'
const competitionMap: Record<string, string> = {
1: '第九届阅读之星大赛',
2: '科普阅读大赛',
}
const statusMap: Record<string, string> = {
1: '铺码成功',
2: '未铺码',
@ -36,15 +31,12 @@ const statusMap: Record<string, string> = {
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchTemplateList(searchParams),
transform: (response) => {
const transformed = defaultTransform(response)
transformed.data.forEach((item: any, index) => {
item.code = `MD${String(index + 1).padStart(6, '0')}`
item.size = '210mmx297mm'
item.competitionId = (item.competitionId === '1' || item.competitionId === '2') ? item.competitionId : '1'
// Mock templateName if not present
if (!item.templateName)
item.templateName = item.userName || '测试模板'
})
const transformed = {
data: response.data?.data || [],
pageNum: searchParams.currentPage,
pageSize: searchParams.pageSize,
total: response.data?.data?.total || 0,
}
return transformed
},
onPaginationParamsChange: (params) => {
@ -65,12 +57,12 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
render: (_, index) => index + 1,
},
{
key: 'templateName',
key: 'Name',
title: '名称',
align: 'center',
},
{
key: 'code',
key: 'ID',
title: '模板ID',
align: 'center',
},
@ -79,7 +71,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
title: '关联比赛',
align: 'center',
render: (row: any) => {
const label = competitionMap[row.competitionId as string] || '未知比赛'
const label = row?.referenceCompetitionName || '未知比赛'
return <span>{label}</span>
},
},
@ -87,6 +79,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'size',
title: '设计尺寸',
align: 'center',
render: (row: any) => `${row.Width / 100 || '0'}mmx${row.Height / 100 || 0}mm`,
},
{
key: 'status',
@ -107,7 +100,7 @@ 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 type="primary" ghost size="small" onClick={() => edit(row.ID)}>
基础信息
</NButton>
<NButton size="small" onClick={() => { }}>
@ -116,13 +109,13 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
<NButton size="small" onClick={() => { }}>
预览
</NButton>
<NButton size="small" onClick={() => handlePageInfo(row.id)}>
<NButton size="small" onClick={() => handlePageInfo(row.ID)}>
页面信息
</NButton>
<NButton size="small" onClick={() => { }}>
打印
</NButton>
<NPopconfirm onPositiveClick={() => handleDelete()}>
<NPopconfirm onPositiveClick={() => handleDelete((row as any).ID || row.id)}>
{{
default: () => '确认删除?',
trigger: () => (
@ -142,7 +135,6 @@ const {
drawerVisible,
operateType,
editingData,
handleAdd,
handleEdit,
checkedRowKeys,
onBatchDeleted,
@ -155,8 +147,11 @@ async function handleBatchDelete() {
onBatchDeleted()
}
function handleDelete() {
onDeleted()
async function handleDelete(id: number) {
const { error } = await fetchDeleteTemplate(id)
if (!error) {
onDeleted()
}
}
function edit(id: number) {
@ -166,6 +161,9 @@ function edit(id: number) {
function handlePageInfo(id: number) {
routerPushByKey('template_template-detail', { query: { id: id.toString() } })
}
function handleAdd() {
routerPushByKey('template_template-detail')
}
</script>
<template>
@ -180,7 +178,7 @@ function handlePageInfo(id: number) {
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys" :columns="columns as any" :data="data" page-size="small"
striped :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
striped :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.ID"
:pagination="mobilePagination" class="sm:h-full"
/>
<TemplateOperateDrawer