feat(template): 新增答题卡模板编辑器及相关功能
- 新增模板详情页面,支持上传PDF/图片作为底稿,并生成可拖拽调整的区域 - 新增模板类型定义、上传钩子及画布组件,支持批量生成区域、旋转、对齐参考线等功能 - 更新模板列表页,适配新的API接口并增加删除功能 - 更新OSS工具函数,修复上传凭证处理逻辑 - 更新公共类型定义,调整分页查询返回结构 - 移除旧的书籍相关API文件,替换为模板API - 更新依赖项,添加pdfjs-dist、vue3-draggable-resizable等库 - 隐藏页面底部,优化界面显示
This commit is contained in:
187
apps/admin/src/views/template/template-detail/hooks/useUpload.ts
Normal file
187
apps/admin/src/views/template/template-detail/hooks/useUpload.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
35
apps/admin/src/views/template/template-detail/types.ts
Normal file
35
apps/admin/src/views/template/template-detail/types.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user