feat(admin): 新增模板详情页书籍编辑功能

- 新增模板详情页,支持导入PDF书籍、管理目录和页面
- 实现书籍页面布局编辑功能,可添加/编辑普通题目和特殊区域
- 添加题目管理功能,支持单选题、多选题、判断题等题型
- 集成OSS上传服务,支持题目图片裁剪和上传
- 实现事件总线机制,用于组件间通信
- 添加工具函数:路径拼接、版本比较等
- 更新环境配置,启用强制登录并调整API地址
This commit is contained in:
2026-01-27 11:10:58 +08:00
parent f6d2a643e8
commit c3d23224ba
30 changed files with 3905 additions and 9 deletions

View File

@ -0,0 +1,138 @@
import { ref } from 'vue';
import pLimit from 'p-limit';
import { type BookPageQuestion, type QuestionsImages, updateBookPageLayoutAxios } from '@/service/api/book';
import { getAliOssTokenAxios } from '@/service/api/upload';
import { initOSSClient } from '@/utils/oss';
import type { CropImageOptions, CurrBookPageAllInfo, QuestionInfo } from './types';
import { compareVersion } from '@/utils/common';
// Crop image function implementation
const cropImage = async (url: string, client: any, options: CropImageOptions, id: number): Promise<QuestionsImages> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;
img.onload = async () => {
try {
const canvas = document.createElement('canvas');
canvas.width = options.width;
canvas.height = options.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas context not available'));
return;
}
// Draw cropped image
ctx.drawImage(
img,
options.x,
options.y,
options.width,
options.height,
0,
0,
options.width,
options.height
);
// Convert to Blob
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error('Canvas to Blob failed'));
return;
}
const file = new File([blob], `question_${id}_${Date.now()}.png`, { type: 'image/png' });
const path = `book/questions/${id}/${file.name}`;
try {
// Upload to OSS
// Assuming client.put or multipartUpload.
// Since initOSSClient returns an OSS client, we can use it.
// Using put for small files (cropped images are usually small)
const result = await client.put(path, file);
// Return the result format expected by the API
// The API expects a map of ID to URL? Or array of objects?
// Based on usage: `tasks.push(...)` and `imageFiles.push(...list)`
// And `updateBookPageLayoutAxios` takes `questionsImages: imageFiles`
// Let's return an object with the ID as key and URL as value, or just the URL object if QuestionsImages is a type.
// Looking at previous usage `urlObj[item.id!] = item`, it seems `QuestionsImages` might be `{ [id]: url }`.
resolve({ [id]: result.url });
} catch (err) {
reject(err);
}
}, 'image/png');
} catch (err) {
reject(err);
}
};
img.onerror = (err) => {
reject(new Error('Image load failed'));
};
});
};
/** 提交 */
export function usePageLayoutSubmit() {
const submmitLoading = ref(false);
async function pageLayoutSubmit(currBookPage: CurrBookPageAllInfo) {
try {
submmitLoading.value = true;
const urlObj: Record<number, BookPageQuestion> = {};
currBookPage!.currBookPageData.questions.forEach((item) => {
if (item.id) {
urlObj[item.id!] = item;
}
});
// 获取阿里云OSS STS
const aliOssSTS = await getAliOssTokenAxios();
const client = initOSSClient(aliOssSTS);
// 截取图片并上传 5个并发
const imageFiles: QuestionsImages[] = [];
const limit = pLimit(5);
const tasks: Promise<QuestionsImages>[] = [];
currBookPage!.currBookPageData.layout.forEach((item) => {
const question: QuestionInfo = item.question;
if (currBookPage!.currBookPageData.url) {
const cropImageOptions: CropImageOptions = { x: question.x, y: question.y, width: question.w, height: question.h };
const url = currBookPage!.currBookPageData.url;
//截取图片并添加到上传队列
tasks.push(limit(() => cropImage(url, client, cropImageOptions, item.questionId!)));
}
});
const list = await Promise.all(tasks);
imageFiles.push(...list);
currBookPage!.currBookPageData.layout = currBookPage!.currBookPageData.layout.sort((a, b) => {
return compareVersion(a.question.no, b.question.no, '-');
});
await updateBookPageLayoutAxios({
id: currBookPage!.currBookPageData!.bookPageId!,
layout: JSON.stringify(currBookPage!.currBookPageData.layout),
questionsImages: imageFiles,
});
submmitLoading.value = false;
return Promise.resolve();
} catch (error) {
console.log('error====', error);
submmitLoading.value = false;
return Promise.reject(error);
}
}
return {
pageLayoutSubmit,
submmitLoading,
};
}
export function getPDFPageWidth(width: number) {
return width;
}