feat(template): 新增答题卡模板编辑器及相关功能
- 新增模板详情页面,支持上传PDF/图片作为底稿,并生成可拖拽调整的区域 - 新增模板类型定义、上传钩子及画布组件,支持批量生成区域、旋转、对齐参考线等功能 - 更新模板列表页,适配新的API接口并增加删除功能 - 更新OSS工具函数,修复上传凭证处理逻辑 - 更新公共类型定义,调整分页查询返回结构 - 移除旧的书籍相关API文件,替换为模板API - 更新依赖项,添加pdfjs-dist、vue3-draggable-resizable等库 - 隐藏页面底部,优化界面显示
This commit is contained in:
@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@opentiny/fluent-editor": "^4.0.1",
|
||||
"@sa/axios": "workspace:*",
|
||||
@ -32,7 +33,9 @@
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@sapphire/snowflake": "^3.5.5",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@zumer/snapdom": "^2.0.1",
|
||||
"ali-oss": "^6.23.0",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.19",
|
||||
@ -42,6 +45,7 @@
|
||||
"naive-ui": "2.43.2",
|
||||
"nprogress": "0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"pinia": "3.0.4",
|
||||
"quill-toolbar-tip": "^0.1.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
@ -49,6 +53,7 @@
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.2.7",
|
||||
"vue-router": "4.6.4",
|
||||
"vue3-draggable-resizable": "^1.6.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -274,7 +274,7 @@ async function uploadFile(myFile: FileItem): Promise<FileItem> {
|
||||
throw new Error('获取上传凭证失败')
|
||||
}
|
||||
// 初始化OSS客户端
|
||||
const client = initOSSClient(tokenData)
|
||||
const client = initOSSClient(tokenData?.data || {})
|
||||
let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}`
|
||||
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"
|
||||
>
|
||||
<NProgress
|
||||
type="circle" :percentage="item.percentage"
|
||||
:color="{ stops: ['#E3F2FD', '#2080f0'] }"
|
||||
type="circle" :percentage="item.percentage" :color="{ stops: ['#E3F2FD', '#2080f0'] }"
|
||||
style="width: 60px; height: 60px"
|
||||
>
|
||||
<template #default>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -1,10 +1,43 @@
|
||||
import { request } from '../request'
|
||||
|
||||
/** get template list */
|
||||
/**
|
||||
* 获取模板列表
|
||||
*/
|
||||
export function fetchTemplateList(data?: Api.Template.TemplateSearchParams) {
|
||||
return request<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>({
|
||||
url: '/admin/v1/book/list',
|
||||
return request<App.Service.Response<Api.Common.PaginatingQueryRecord<Api.Template.CommonRecord>>>({
|
||||
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',
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export interface AliOssSTS {
|
||||
|
||||
/** 得到上传的token */
|
||||
export function getAliOssTokenAxios() {
|
||||
return request<AliOssSTS>({
|
||||
return request<App.Service.Response<AliOssSTS>>({
|
||||
url: `${import.meta.env.VITE_BASE_UPLOAD_URL}/admin/v1/oss/sts`,
|
||||
method: 'get',
|
||||
})
|
||||
@ -23,7 +23,7 @@ export function getAliOssTokenAxios() {
|
||||
|
||||
/** 删除阿里云oss 文件 */
|
||||
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`,
|
||||
method: 'post',
|
||||
data,
|
||||
|
||||
@ -38,7 +38,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
mixChildMenuWidth: 200, // 侧边栏混合子菜单宽度
|
||||
autoSelectFirstMenu: false, // 是否自动选择第一个菜单
|
||||
},
|
||||
footer: { visible: true, fixed: false, height: 48, right: true }, // 是否固定底部
|
||||
footer: { visible: false, fixed: false, height: 48, right: true }, // 是否固定底部
|
||||
watermark: {
|
||||
visible: false, // 是否显示水印
|
||||
text: 'reader-starAdmin', // 水印文本
|
||||
|
||||
4
apps/admin/src/typings/api/common.d.ts
vendored
4
apps/admin/src/typings/api/common.d.ts
vendored
@ -12,12 +12,12 @@ declare namespace Api {
|
||||
/** page pageSize */
|
||||
pageSize: number
|
||||
/** total count */
|
||||
total: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** common params of paginating query list data */
|
||||
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
|
||||
list: T[]
|
||||
data: T[]
|
||||
}
|
||||
|
||||
/** common search params of table */
|
||||
|
||||
17
apps/admin/src/typings/api/template.d.ts
vendored
17
apps/admin/src/typings/api/template.d.ts
vendored
@ -15,6 +15,23 @@ declare namespace Api {
|
||||
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 */
|
||||
interface TemplateSearchParams extends PaginatingCommonParams {
|
||||
/** competition activity name */
|
||||
|
||||
@ -5,18 +5,19 @@ import { type AliOssSTS, getAliOssTokenAxios } from '@/service/api/upload'
|
||||
export function initOSSClient(token: AliOssSTS) {
|
||||
return new OSS({
|
||||
region: token.Region || 'oss-cn-shenzhen',
|
||||
accessKeyId: token.AccessKeyId,
|
||||
accessKeySecret: token.AccessKeySecret,
|
||||
stsToken: token.SecurityToken,
|
||||
bucket: token.BucketName,
|
||||
accessKeyId: token.AccessKeyId || '',
|
||||
accessKeySecret: token.AccessKeySecret || '',
|
||||
stsToken: token.SecurityToken || '',
|
||||
bucket: token.BucketName || '',
|
||||
refreshSTSTokenInterval: 600000, // 10分钟刷新一次token
|
||||
refreshSTSToken: async () => {
|
||||
// 这里可以添加获取新token的逻辑
|
||||
const res = await getAliOssTokenAxios()
|
||||
return {
|
||||
accessKeyId: res.AccessKeyId,
|
||||
accessKeySecret: res.AccessKeySecret,
|
||||
stsToken: res.SecurityToken,
|
||||
accessKeyId: res.data?.data?.AccessKeyId || '',
|
||||
accessKeySecret: res.data?.data?.AccessKeySecret || '',
|
||||
stsToken: res.data?.data?.SecurityToken || '',
|
||||
bucket: res.data?.data?.BucketName || '',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
1
apps/admin/tsconfig.tsbuildinfo
Normal file
1
apps/admin/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
167
pnpm-lock.yaml
generated
167
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ importers:
|
||||
'@better-scroll/core':
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@iconify/vue':
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0(vue@3.5.26(typescript@5.9.3))
|
||||
@ -53,9 +56,15 @@ importers:
|
||||
'@sa/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@sapphire/snowflake':
|
||||
specifier: ^3.5.5
|
||||
version: 3.5.5
|
||||
'@vueuse/core':
|
||||
specifier: 14.1.0
|
||||
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
|
||||
'@zumer/snapdom':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.2
|
||||
ali-oss:
|
||||
specifier: ^6.23.0
|
||||
version: 6.23.0
|
||||
@ -83,6 +92,9 @@ importers:
|
||||
path-browserify:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
pdfjs-dist:
|
||||
specifier: ^5.4.530
|
||||
version: 5.4.530
|
||||
pinia:
|
||||
specifier: 3.0.4
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||
@ -104,6 +116,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: 4.6.4
|
||||
version: 4.6.4(vue@3.5.26(typescript@5.9.3))
|
||||
vue3-draggable-resizable:
|
||||
specifier: ^1.6.5
|
||||
version: 1.6.5
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
@ -839,6 +854,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@formkit/auto-animate@0.9.0':
|
||||
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@ -912,6 +930,81 @@ packages:
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@ -1159,6 +1252,10 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
@ -1676,6 +1773,9 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@zumer/snapdom@2.0.2':
|
||||
resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@ -3938,6 +4038,10 @@ packages:
|
||||
pause-stream@0.0.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@ -5029,6 +5133,9 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue3-draggable-resizable@1.6.5:
|
||||
resolution: {integrity: sha512-31142E31fGNnq3HKqvmFLSsqIbhck7TyGuQWhUKrDw6DOcGAuRx4ddRjaxvT6fe7dgeKH53qAh+i0ZlWtPLl2g==}
|
||||
|
||||
vue@3.5.26:
|
||||
resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==}
|
||||
peerDependencies:
|
||||
@ -5678,6 +5785,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@formkit/auto-animate@0.9.0': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
@ -5755,6 +5864,54 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.7.1
|
||||
@ -5922,6 +6079,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@sapphire/snowflake@3.5.5': {}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
@ -6574,6 +6733,8 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
'@zumer/snapdom@2.0.2': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@ -9185,6 +9346,10 @@ snapshots:
|
||||
dependencies:
|
||||
through: 2.3.8
|
||||
|
||||
pdfjs-dist@5.4.530:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.88
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
perfect-debounce@2.0.0: {}
|
||||
@ -10422,6 +10587,8 @@ snapshots:
|
||||
'@vue/language-core': 3.2.1
|
||||
typescript: 5.9.3
|
||||
|
||||
vue3-draggable-resizable@1.6.5: {}
|
||||
|
||||
vue@3.5.26(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.26
|
||||
|
||||
Reference in New Issue
Block a user