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

@ -25,6 +25,7 @@
}, },
"dependencies": { "dependencies": {
"@better-scroll/core": "2.5.1", "@better-scroll/core": "2.5.1",
"@formkit/auto-animate": "^0.9.0",
"@iconify/vue": "5.0.0", "@iconify/vue": "5.0.0",
"@opentiny/fluent-editor": "^4.0.1", "@opentiny/fluent-editor": "^4.0.1",
"@sa/axios": "workspace:*", "@sa/axios": "workspace:*",
@ -32,7 +33,9 @@
"@sa/hooks": "workspace:*", "@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*", "@sa/materials": "workspace:*",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"@sapphire/snowflake": "^3.5.5",
"@vueuse/core": "14.1.0", "@vueuse/core": "14.1.0",
"@zumer/snapdom": "^2.0.1",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "1.11.19", "dayjs": "1.11.19",
@ -42,6 +45,7 @@
"naive-ui": "2.43.2", "naive-ui": "2.43.2",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"pdfjs-dist": "^5.4.530",
"pinia": "3.0.4", "pinia": "3.0.4",
"quill-toolbar-tip": "^0.1.0", "quill-toolbar-tip": "^0.1.0",
"tailwind-merge": "3.4.0", "tailwind-merge": "3.4.0",
@ -49,6 +53,7 @@
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.2.7", "vue-i18n": "11.2.7",
"vue-router": "4.6.4", "vue-router": "4.6.4",
"vue3-draggable-resizable": "^1.6.5",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -274,7 +274,7 @@ async function uploadFile(myFile: FileItem): Promise<FileItem> {
throw new Error('获取上传凭证失败') throw new Error('获取上传凭证失败')
} }
// 初始化OSS客户端 // 初始化OSS客户端
const client = initOSSClient(tokenData) const client = initOSSClient(tokenData?.data || {})
let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}` let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}`
path = browserPathJoin(path, myFile.file!.name) path = browserPathJoin(path, myFile.file!.name)
// 上传文件 // 上传文件
@ -397,8 +397,7 @@ defineExpose({ reset, getLoadState: () => upLoading.value })
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 p-1" class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 p-1"
> >
<NProgress <NProgress
type="circle" :percentage="item.percentage" type="circle" :percentage="item.percentage" :color="{ stops: ['#E3F2FD', '#2080f0'] }"
:color="{ stops: ['#E3F2FD', '#2080f0'] }"
style="width: 60px; height: 60px" style="width: 60px; height: 60px"
> >
<template #default> <template #default>

View File

@ -1,219 +0,0 @@
import { request } from '../request';
export interface BookPageQuestionAddInput {
bookId: number;
bookPageId: number;
no: string;
type: number;
score: number;
subjectId?: number;
answerTime: number;
}
export interface BookReviewInput {
bookId: number;
bookPageId: number;
id?: number; // questionId
[key: string]: any;
}
export interface QuestionFromInfo {
[key: string]: any;
}
export interface BookPageQuestion {
id: number;
type?: number;
[key: string]: any;
}
export interface QuestionsImages {
[key: string]: any;
}
export interface BookCatalog {
name: string;
type: number;
url?: string;
}
export interface BookCatalogImportInput {
bookId: number;
bookCatalogs: BookCatalog[];
index: number;
pdfUrl: string;
}
export interface BookBaseData {
width?: number;
height?: number;
}
export interface CatalogTree {
id: number;
name: string;
type: number;
verifyStatus?: number;
childs?: CatalogTree[];
[key: string]: any;
}
export function importBookPageAxios(data: BookCatalogImportInput) {
return request<any>({
url: '/admin/v1/book/page/import',
method: 'post',
data,
});
}
export function editBookCatalogAxios(data: any) {
return request<any>({
url: '/admin/v1/book/catalog/edit',
method: 'post',
data,
});
}
export function getBookCatalogTreeAxios(params: { bookId: number }) {
return request<any>({
url: '/admin/v1/book/catalog/tree',
method: 'get',
params,
});
}
export function getBookBaseInfoAxios(params: { id: number }) {
return request<any>({
url: '/admin/v1/book/base/info',
method: 'get',
params,
});
}
export function getBookPageDetailAxios(params: { pageId: number }) {
return request<any>({
url: '/admin/v1/book/page/detail',
method: 'get',
params,
});
}
export function setStartPageAxios(data: any) {
return request<any>({
url: '/admin/v1/book/page/start/set',
method: 'post',
data,
});
}
export function addBookQuestionAxios(data: BookPageQuestionAddInput) {
return request<any>({
url: '/admin/v1/book/question/add',
method: 'post',
data,
});
}
export function delBookQuestionAxios(id: number) {
return request<any>({
url: `/admin/v1/book/question/delete`,
method: 'post',
data: { id },
});
}
export function updateBookPageLayoutAxios(data: { id: number; layout: string; questionsImages?: any[] }) {
return request<any>({
url: '/admin/v1/book/page/layout/update',
method: 'post',
data,
});
}
export function getQuestionInfoAxios(id: number) {
return request<any>({
url: `/admin/v1/book/question/info`,
method: 'get',
params: { id },
});
}
export function editQuestionInfoAxios(data: any) {
return request<any>({
url: '/admin/v1/book/question/edit',
method: 'post',
data,
});
}
export function getQuestionBankNavAxios() {
return request<any>({
url: '/admin/v1/book/question/nav',
method: 'get',
});
}
export function getKnowledgePointDetailsAxios(params: any) {
return request<any>({
url: '/admin/v1/knowledge/point/details',
method: 'get',
params,
});
}
export function updateKeywordAnalysisAxios(data: any) {
return request<any>({
url: '/admin/v1/book/question/analysis/update',
method: 'post',
data,
});
}
export function releaseTaskAxios(data: any) {
return request<any>({
url: '/admin/v1/book/task/release',
method: 'post',
data,
});
}
export function fetchBookCategoryList(params: any) {
return request<any>({
url: '/admin/v1/book/category/list',
method: 'post', // Assuming post based on usage pattern, or get? Original didn't specify method in prop, usually list is get or post. Let's assume post for list with search params.
data: params,
});
}
export function addBookAreaAxios(data: any) {
return request<any>({
url: '/admin/v1/book/area/add',
method: 'post',
data,
});
}
export function updateBookAreaAxios(data: any) {
return request<any>({
url: '/admin/v1/book/area/update',
method: 'post',
data,
});
}
export function delBookAreaAxios(id: number) {
return request<any>({
url: '/admin/v1/book/area/delete',
method: 'post',
data: { id },
});
}
export function fetchSubjectList(params: any) {
return request<any>({
url: '/admin/v1/subject/pagelist',
method: 'post',
data: params,
});
}

View File

@ -1,10 +1,43 @@
import { request } from '../request' import { request } from '../request'
/** get template list */ /**
* 获取模板列表
*/
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) { export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
return request<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>({ return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
url: '/admin/v1/book/list', url: '/Base/ActivityMain/GetTemplete_Pag',
method: 'get',
params: data || {},
})
}
/**
* 新增模板
*/
export function fetchAddTemplate(data?: Api.Template.AddTemplateParams) {
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
url: '/Base/ActivityMain/AddOrUpdateTemplete_Pag',
method: 'post', method: 'post',
data: data || {}, data: data || {},
}) })
} }
/**
* 获取模板详情
*/
export function fetchTemplateDetail(id: number) {
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
url: `/Base/ActivityMain/GetTemplete_PagByID?id=${id}`,
method: 'get',
})
}
/**
* 删除模板
*/
export function fetchDeleteTemplate(id: number) {
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
url: `/Base/ActivityMain/DeleteTemplete_Pag?id=${id}`,
method: 'post',
})
}

View File

@ -15,7 +15,7 @@ export interface AliOssSTS {
/** 得到上传的token */ /** 得到上传的token */
export function getAliOssTokenAxios() { export function getAliOssTokenAxios() {
return request<AliOssSTS>({ return request<App.Service.Response<AliOssSTS>>({
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`, url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
method: 'get', method: 'get',
}) })
@ -23,7 +23,7 @@ export function getAliOssTokenAxios() {
/** 删除阿里云oss 文件 */ /** 删除阿里云oss 文件 */
export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) { export function deleteAliOssFileAxios(data: ReqDeleteAliOssFile) {
return request<boolean>({ return request<App.Service.Response<boolean>>({
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`, url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/delete`,
method: 'post', method: 'post',
data, data,

View File

@ -38,7 +38,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
mixChildMenuWidth: 200, // 侧边栏混合子菜单宽度 mixChildMenuWidth: 200, // 侧边栏混合子菜单宽度
autoSelectFirstMenu: false, // 是否自动选择第一个菜单 autoSelectFirstMenu: false, // 是否自动选择第一个菜单
}, },
footer: { visible: true, fixed: false, height: 48, right: true }, // 是否固定底部 footer: { visible: false, fixed: false, height: 48, right: true }, // 是否固定底部
watermark: { watermark: {
visible: false, // 是否显示水印 visible: false, // 是否显示水印
text: 'reader-starAdmin', // 水印文本 text: 'reader-starAdmin', // 水印文本

View File

@ -12,12 +12,12 @@ declare namespace Api {
/** page pageSize */ /** page pageSize */
pageSize: number pageSize: number
/** total count */ /** total count */
total: number total?: number
} }
/** common params of paginating query list data */ /** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams { interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
list: T[] data: T[]
} }
/** common search params of table */ /** common search params of table */

View File

@ -15,6 +15,23 @@ declare namespace Api {
total?: number total?: number
} }
interface AddTemplateParams {
/** template id */
id: number | null
/** page number */
pageNo: number | null | string
/** template name */
name: string
/** background url */
backGroundUrl: string
/** width */
width: number
/** height */
height: number
/** template content */
tempContent: string
}
/** template search params */ /** template search params */
interface TemplateSearchParams extends PaginatingCommonParams { interface TemplateSearchParams extends PaginatingCommonParams {
/** competition activity name */ /** competition activity name */

View File

@ -5,18 +5,19 @@ import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'
export function initOSSClient(token: AliOssSTS) { export function initOSSClient(token: AliOssSTS) {
return new OSS({ return new OSS({
region: token.Region || 'oss-cn-shenzhen', region: token.Region || 'oss-cn-shenzhen',
accessKeyId: token.AccessKeyId, accessKeyId: token.AccessKeyId || '',
accessKeySecret: token.AccessKeySecret, accessKeySecret: token.AccessKeySecret || '',
stsToken: token.SecurityToken, stsToken: token.SecurityToken || '',
bucket: token.BucketName, bucket: token.BucketName || '',
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
refreshSTSToken: async () => { refreshSTSToken: async () => {
// 这里可以添加获取新token的逻辑 // 这里可以添加获取新token的逻辑
const res = await getAliOssTokenAxios() const res = await getAliOssTokenAxios()
return { return {
accessKeyId: res.AccessKeyId, accessKeyId: res.data?.data?.AccessKeyId || '',
accessKeySecret: res.AccessKeySecret, accessKeySecret: res.data?.data?.AccessKeySecret || '',
stsToken: res.SecurityToken, stsToken: res.data?.data?.SecurityToken || '',
bucket: res.data?.data?.BucketName || '',
} }
}, },
}) })

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

File diff suppressed because one or more lines are too long

167
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
'@better-scroll/core': '@better-scroll/core':
specifier: 2.5.1 specifier: 2.5.1
version: 2.5.1 version: 2.5.1
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
'@iconify/vue': '@iconify/vue':
specifier: 5.0.0 specifier: 5.0.0
version: 5.0.0(vue@3.5.26(typescript@5.9.3)) version: 5.0.0(vue@3.5.26(typescript@5.9.3))
@ -53,9 +56,15 @@ importers:
'@sa/utils': '@sa/utils':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/utils version: link:../../packages/utils
'@sapphire/snowflake':
specifier: ^3.5.5
version: 3.5.5
'@vueuse/core': '@vueuse/core':
specifier: 14.1.0 specifier: 14.1.0
version: 14.1.0(vue@3.5.26(typescript@5.9.3)) version: 14.1.0(vue@3.5.26(typescript@5.9.3))
'@zumer/snapdom':
specifier: ^2.0.1
version: 2.0.2
ali-oss: ali-oss:
specifier: ^6.23.0 specifier: ^6.23.0
version: 6.23.0 version: 6.23.0
@ -83,6 +92,9 @@ importers:
path-browserify: path-browserify:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1 version: 1.0.1
pdfjs-dist:
specifier: ^5.4.530
version: 5.4.530
pinia: pinia:
specifier: 3.0.4 specifier: 3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
@ -104,6 +116,9 @@ importers:
vue-router: vue-router:
specifier: 4.6.4 specifier: 4.6.4
version: 4.6.4(vue@3.5.26(typescript@5.9.3)) version: 4.6.4(vue@3.5.26(typescript@5.9.3))
vue3-draggable-resizable:
specifier: ^1.6.5
version: 1.6.5
xlsx: xlsx:
specifier: ^0.18.5 specifier: ^0.18.5
version: 0.18.5 version: 0.18.5
@ -839,6 +854,9 @@ packages:
'@floating-ui/utils@0.2.10': '@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -912,6 +930,81 @@ packages:
'@juggle/resize-observer@3.4.0': '@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@napi-rs/canvas-android-arm64@0.1.88':
resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.88':
resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.88':
resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.88':
resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.88':
resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.88':
resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.88':
resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@ -1159,6 +1252,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@sapphire/snowflake@3.5.5':
resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@ -1676,6 +1773,9 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 vue: ^3.5.0
'@zumer/snapdom@2.0.2':
resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@ -3938,6 +4038,10 @@ packages:
pause-stream@0.0.11: pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
pdfjs-dist@5.4.530:
resolution: {integrity: sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@ -5029,6 +5133,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=5.0.0' typescript: '>=5.0.0'
vue3-draggable-resizable@1.6.5:
resolution: {integrity: sha512-31142E31fGNnq3HKqvmFLSsqIbhck7TyGuQWhUKrDw6DOcGAuRx4ddRjaxvT6fe7dgeKH53qAh+i0ZlWtPLl2g==}
vue@3.5.26: vue@3.5.26:
resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==}
peerDependencies: peerDependencies:
@ -5678,6 +5785,8 @@ snapshots:
'@floating-ui/utils@0.2.10': {} '@floating-ui/utils@0.2.10': {}
'@formkit/auto-animate@0.9.0': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7': '@humanfs/node@0.16.7':
@ -5755,6 +5864,54 @@ snapshots:
'@juggle/resize-observer@3.4.0': {} '@juggle/resize-observer@3.4.0': {}
'@napi-rs/canvas-android-arm64@0.1.88':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.88':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.88':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.88':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.88':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.88':
optional: true
'@napi-rs/canvas@0.1.88':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.88
'@napi-rs/canvas-darwin-arm64': 0.1.88
'@napi-rs/canvas-darwin-x64': 0.1.88
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88
'@napi-rs/canvas-linux-arm64-gnu': 0.1.88
'@napi-rs/canvas-linux-arm64-musl': 0.1.88
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.88
'@napi-rs/canvas-linux-x64-gnu': 0.1.88
'@napi-rs/canvas-linux-x64-musl': 0.1.88
'@napi-rs/canvas-win32-arm64-msvc': 0.1.88
'@napi-rs/canvas-win32-x64-msvc': 0.1.88
optional: true
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
dependencies: dependencies:
'@emnapi/core': 1.7.1 '@emnapi/core': 1.7.1
@ -5922,6 +6079,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.54.0': '@rollup/rollup-win32-x64-msvc@4.54.0':
optional: true optional: true
'@sapphire/snowflake@3.5.5': {}
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}
@ -6574,6 +6733,8 @@ snapshots:
dependencies: dependencies:
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
'@zumer/snapdom@2.0.2': {}
acorn-jsx@5.3.2(acorn@8.15.0): acorn-jsx@5.3.2(acorn@8.15.0):
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@ -9185,6 +9346,10 @@ snapshots:
dependencies: dependencies:
through: 2.3.8 through: 2.3.8
pdfjs-dist@5.4.530:
optionalDependencies:
'@napi-rs/canvas': 0.1.88
perfect-debounce@1.0.0: {} perfect-debounce@1.0.0: {}
perfect-debounce@2.0.0: {} perfect-debounce@2.0.0: {}
@ -10422,6 +10587,8 @@ snapshots:
'@vue/language-core': 3.2.1 '@vue/language-core': 3.2.1
typescript: 5.9.3 typescript: 5.9.3
vue3-draggable-resizable@1.6.5: {}
vue@3.5.26(typescript@5.9.3): vue@3.5.26(typescript@5.9.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.26 '@vue/compiler-dom': 3.5.26