chore: 清理未使用的模板编辑功能及相关依赖
移除模板编辑相关组件、工具函数和类型定义,并删除不再需要的第三方依赖包。此功能已不再使用,清理以减少项目体积和复杂度。 - 删除模板编辑页面及其所有子组件(left-tree、middle-book、right-topic等) - 移除PDF转换、图片裁剪、数学公式编辑等工具函数 - 清理富文本编辑器组件及相关类型定义 - 从package.json移除@formkit/auto-animate、@sapphire/snowflake、katex、mathlive等未使用依赖
This commit is contained in:
@ -25,7 +25,6 @@
|
|||||||
},
|
},
|
||||||
"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:*",
|
||||||
@ -33,17 +32,13 @@
|
|||||||
"@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",
|
||||||
"defu": "6.1.4",
|
"defu": "6.1.4",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"katex": "^0.16.27",
|
|
||||||
"mathlive": "^0.108.2",
|
|
||||||
"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",
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { NModal } from 'naive-ui'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'hide'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
|
|
||||||
function show() {
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
function hide() {
|
|
||||||
visible.value = false
|
|
||||||
emit('hide')
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<NModal v-model:show="visible" preset="card" title="第三方在线编辑器" style="width: 90vw" @after-leave="hide">
|
|
||||||
<div style="height: 80vh; overflow: hidden">
|
|
||||||
<iframe src="https://www.processon.com/latex" style="width: 100%; height: calc(100% + 50px); margin-top: -50px; border: none" />
|
|
||||||
</div>
|
|
||||||
</NModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { UpFile } from '../types'
|
|
||||||
import { NModal, NProgress, NScrollbar } from 'naive-ui'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** 上传文件列表 */
|
|
||||||
upFileList: UpFile[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
watch(
|
|
||||||
() => props.upFileList.length,
|
|
||||||
(len) => {
|
|
||||||
visible.value = len > 0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<NModal v-model:show="visible" preset="card" title="上传进度" style="width: 600px">
|
|
||||||
<NScrollbar style="max-height: 700px">
|
|
||||||
<div class="list">
|
|
||||||
<div v-for="item in props.upFileList" :key="item.id" class="item">
|
|
||||||
<NProgress type="circle" :percentage="item.progress" indicator-placement="inside" :radius="40" />
|
|
||||||
<span class="name">{{ item.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NScrollbar>
|
|
||||||
</NModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100px;
|
|
||||||
margin: 15px;
|
|
||||||
|
|
||||||
.name {
|
|
||||||
// 超出部分省略号
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
import type { IEditorConfig, MathliveModule, Range, ToolbarOptions } from '@opentiny/fluent-editor'
|
|
||||||
import type OSS from 'ali-oss'
|
|
||||||
import type { GetEditorConfigParams, MyToolbarOption, UpFile } from './types'
|
|
||||||
import FluentEditor, { generateToolbarTip } from '@opentiny/fluent-editor'
|
|
||||||
import katex from 'katex'
|
|
||||||
import QuillToolbarTip, { type QuillToolbarTipOptions } from 'quill-toolbar-tip'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getAliOssTokenAxios } from '@/service/api/upload'
|
|
||||||
import { browserPathJoin } from '@/utils/date'
|
|
||||||
import { base64ToFile } from '@/utils/file'
|
|
||||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
|
||||||
import { getSnowflake } from '@/utils/rest'
|
|
||||||
|
|
||||||
/** 自定义打开iframe的按钮的key */
|
|
||||||
export const MY_OPEN_PROCESSON_IFRAME = 'my-open-processon-iframe' as const
|
|
||||||
export const upFileList = ref<UpFile[]>([])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化编辑器
|
|
||||||
*/
|
|
||||||
export function initEditor(container: HTMLElement, params: IEditorConfig): FluentEditor {
|
|
||||||
// 需要依赖 katex
|
|
||||||
window.katex = katex as any
|
|
||||||
|
|
||||||
// 处理图片给每个图片添加类名和最大宽度
|
|
||||||
const ImageBlot: any = FluentEditor.import('formats/image')
|
|
||||||
class CustomImageBlot extends ImageBlot {
|
|
||||||
static create(value: any) {
|
|
||||||
const node: HTMLImageElement = super.create(value)
|
|
||||||
editImageAttribute(node, false)
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FluentEditor.register(CustomImageBlot, true)
|
|
||||||
|
|
||||||
// 处理视频给每个视频添加类名和最大宽度
|
|
||||||
const VideoBlot: any = FluentEditor.import('formats/video')
|
|
||||||
class CustomVideoBlot extends VideoBlot {
|
|
||||||
static create(value: any) {
|
|
||||||
const node: HTMLVideoElement = super.create(value)
|
|
||||||
editVideoAttribute(node, false)
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FluentEditor.register(CustomVideoBlot, true)
|
|
||||||
|
|
||||||
// 注册工具栏提示模块
|
|
||||||
FluentEditor.register({ 'modules/toolbar-tip': generateToolbarTip(QuillToolbarTip) }, true)
|
|
||||||
const Parchment = FluentEditor.import('parchment')
|
|
||||||
addToolbarIcon()
|
|
||||||
|
|
||||||
// 居中问题
|
|
||||||
const config = { scope: Parchment.Scope.BLOCK, whitelist: ['right', 'center', 'justify'] }
|
|
||||||
const Align = new Parchment.StyleAttributor('align', 'text-align', config)
|
|
||||||
FluentEditor.register(Align, true)
|
|
||||||
|
|
||||||
// 工具栏配置
|
|
||||||
return new FluentEditor(container, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 得到编辑器的配置
|
|
||||||
*/
|
|
||||||
export async function getEditorConfig(params: GetEditorConfigParams): Promise<IEditorConfig> {
|
|
||||||
const editorOptions: IEditorConfig = {
|
|
||||||
theme: 'snow',
|
|
||||||
modules: {
|
|
||||||
// 是否开启数学公式模块
|
|
||||||
'mathlive': true,
|
|
||||||
// 开启字数统计
|
|
||||||
// counter: {
|
|
||||||
// count: 2000,
|
|
||||||
// },
|
|
||||||
'toolbar': {
|
|
||||||
// 工具栏显示那些按钮
|
|
||||||
container: await getToolbarOption(params.toolbarOption || {}),
|
|
||||||
handlers: {
|
|
||||||
formula() {
|
|
||||||
// 点击数学公式按钮时打开弹出框
|
|
||||||
const mathlive = this.quill.getModule('mathlive') as MathliveModule
|
|
||||||
mathlive.createDialog('')
|
|
||||||
},
|
|
||||||
// 自定义打开iframe的按钮的点击事件的回调
|
|
||||||
[`${MY_OPEN_PROCESSON_IFRAME}`](_value: boolean) {
|
|
||||||
params[MY_OPEN_PROCESSON_IFRAME]() // 执行回调
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'uploader': {
|
|
||||||
// only allow image
|
|
||||||
mimetypes: ['*', 'video/*', 'audio/*'],
|
|
||||||
async handler(_range: Range, files: File[]): Promise<(string | false)[]> {
|
|
||||||
try {
|
|
||||||
const paths = await Promise.all(files.map((file, index) => upFileAxios(file, `${Date.now()}_${index}`)))
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
window.$message?.error(`上传失败: ${error.message}`)
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('error====', error)
|
|
||||||
return files.map(() => false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'clipboard': {
|
|
||||||
matchers: [
|
|
||||||
[
|
|
||||||
'img',
|
|
||||||
(node: HTMLImageElement, delta: any) => {
|
|
||||||
editImageAttribute(node, false)
|
|
||||||
return delta
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'video',
|
|
||||||
(node: HTMLVideoElement, delta: any) => {
|
|
||||||
editVideoAttribute(node, false)
|
|
||||||
return delta
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// 工具栏提示配置
|
|
||||||
'toolbar-tip': {
|
|
||||||
defaultTooltipOptions: {
|
|
||||||
tipHoverable: false,
|
|
||||||
},
|
|
||||||
tipTextMap: {
|
|
||||||
[MY_OPEN_PROCESSON_IFRAME]: '跳转第三方公式编辑器\n可在第三方编辑器编辑好公式后复制过来\n[注意]:需要复制到公式输入框内才能生效',
|
|
||||||
},
|
|
||||||
} satisfies Partial<QuillToolbarTipOptions>,
|
|
||||||
// 国际化配置
|
|
||||||
'i18n': { lang: 'zh-CN' },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return editorOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 得到工具栏配置(显示那些按钮)
|
|
||||||
*/
|
|
||||||
function getToolbarOption(params: MyToolbarOption): Promise<ToolbarOptions['container']> {
|
|
||||||
const { show, showUpFile, showUpVideo, customToolbar } = params
|
|
||||||
|
|
||||||
if (show === false) {
|
|
||||||
return Promise.resolve([] satisfies ToolbarOptions['container'])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customToolbar !== undefined) {
|
|
||||||
return Promise.resolve(customToolbar)
|
|
||||||
}
|
|
||||||
const va = ['image']
|
|
||||||
showUpVideo !== false && va.push('video') // 只要不等于false就添加 (undefined也添加)
|
|
||||||
showUpFile !== false && va.push('file') // 只要不等于false就添加 (undefined也添加)
|
|
||||||
// 工具栏配置
|
|
||||||
const TOOLBAR_CONFIG: ToolbarOptions['container'] = [
|
|
||||||
['undo', 'redo', 'clean', 'format-painter'],
|
|
||||||
[
|
|
||||||
{ header: [1, 2, 3, 4, 5, 6, false] },
|
|
||||||
{ size: [false, '12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] },
|
|
||||||
{ 'line-height': [false, '1.2', '1.5', '1.75', '2', '3', '4', '5'] },
|
|
||||||
{ font: [false, 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', '宋体', '黑体', '微软雅黑', '楷体', '仿宋', '等线'] },
|
|
||||||
],
|
|
||||||
['bold', 'italic', 'strike', 'underline', 'divider'],
|
|
||||||
[{ color: [] }, { background: [] }],
|
|
||||||
[{ align: '' }, { align: 'center' }, { align: 'right' }, { align: 'justify' }], // 这里可以为字符串
|
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }],
|
|
||||||
[{ script: 'sub' }, { script: 'super' }],
|
|
||||||
[{ indent: '-1' }, { indent: '+1' }],
|
|
||||||
['link', 'blockquote'],
|
|
||||||
[...va],
|
|
||||||
['fullscreen', 'formula', MY_OPEN_PROCESSON_IFRAME],
|
|
||||||
]
|
|
||||||
return Promise.resolve(TOOLBAR_CONFIG)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加打开iframe的弹出框的自定义按钮
|
|
||||||
*/
|
|
||||||
function addToolbarIcon() {
|
|
||||||
// 这里时增加按钮
|
|
||||||
const myOpenProcessonIframeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="currentColor" d="M6.5 1a1.5 1.5 0 0 0-1.415 1H4.5A1.5 1.5 0 0 0 3 3.5v10A1.5 1.5 0 0 0 4.5 15h1.651a1.5 1.5 0 0 1-.111-1H4.5a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h.585A1.5 1.5 0 0 0 6.5 4h3a1.5 1.5 0 0 0 1.415-1h.585a.5.5 0 0 1 .5.5v1.57q.346.079.681.247.178.09.319.217V3.5A1.5 1.5 0 0 0 11.5 2h-.585A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m3.451 5.277a1.916 1.916 0 0 1 2.778-1.568.5.5 0 0 1-.452.892.916.916 0 0 0-1.328.75l-.087 1.172H11.5a.5.5 0 0 1 0 1h-1.212l-.237 3.2a1.916 1.916 0 0 1-2.777 1.567.5.5 0 1 1 .452-.892.916.916 0 0 0 1.328-.75l.231-3.125H8.5a.5.5 0 1 1 0-1h.86zm5.403 4.077a.5.5 0 0 0-.708-.708l-1.057 1.057-.457-.734a.994.994 0 0 0-1.424-.281.5.5 0 0 0 .579.815l.576.926-1.217 1.217a.5.5 0 0 0 .708.708l1.052-1.053.457.735a.99.99 0 0 0 1.442.264.5.5 0 0 0-.599-.801l-.574-.924z"/></svg>`
|
|
||||||
const icons = FluentEditor.import('ui/icons') as Record<string, string>
|
|
||||||
icons[MY_OPEN_PROCESSON_IFRAME] = myOpenProcessonIframeIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件
|
|
||||||
*/
|
|
||||||
async function upFileAxios(file: File, id: string): Promise<string | false> {
|
|
||||||
const item: UpFile = { id, name: file.name, progress: 0 }
|
|
||||||
try {
|
|
||||||
upFileList.value.push(item)
|
|
||||||
const { data: tokenData } = await getAliOssTokenAxios()
|
|
||||||
if (!tokenData) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// 初始化OSS客户端
|
|
||||||
const client = initOSSClient(tokenData)
|
|
||||||
const path = browserPathJoin(`temp/${Date.now()}`, file!.name)
|
|
||||||
// 上传文件
|
|
||||||
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, file, path, (progress) => {
|
|
||||||
item.progress = Math.floor(progress * 10000) / 100
|
|
||||||
})
|
|
||||||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, res.name)
|
|
||||||
upFileList.value.splice(upFileList.value.indexOf(item), 1)
|
|
||||||
return Promise.resolve(url)
|
|
||||||
// oxlint-disable-next-line no-unused-vars
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
upFileList.value.splice(upFileList.value.indexOf(item), 1)
|
|
||||||
window.$message?.error(`上传失败: ${error.message}`)
|
|
||||||
return Promise.resolve(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 编辑视频属性 */
|
|
||||||
export function editVideoAttribute(node: HTMLVideoElement, isReplace = true) {
|
|
||||||
node.setAttribute('style', 'max-width: 100%;') // 强制内联样式
|
|
||||||
node.classList.add('my-ql-video')
|
|
||||||
isReplace && replaceFileUrl(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 编辑图片属性 */
|
|
||||||
export function editImageAttribute(node: HTMLImageElement, isReplace = true) {
|
|
||||||
node.setAttribute('style', 'max-width: 100%;') // 强制内联样式
|
|
||||||
node.classList.add('my-ql-image')
|
|
||||||
isReplace && replaceFileUrl(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* base64替换为url地址
|
|
||||||
* @param node
|
|
||||||
*/
|
|
||||||
export function replaceFileUrl(node: HTMLImageElement | HTMLVideoElement) {
|
|
||||||
const ex = getExtensionFromDataUrl(node.src)
|
|
||||||
if (ex) {
|
|
||||||
const file = base64ToFile(node.src, `${Date.now()}_${getSnowflake()}.${ex}`)
|
|
||||||
upFileAxios(file, file.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeToExtension: Record<string, string> = {
|
|
||||||
'image/png': 'png',
|
|
||||||
'image/jpeg': 'jpg',
|
|
||||||
'image/jpg': 'jpg',
|
|
||||||
'image/gif': 'gif',
|
|
||||||
'image/webp': 'webp',
|
|
||||||
'image/svg': 'webp',
|
|
||||||
'video/mp4': 'mp4',
|
|
||||||
'audio/mpeg': 'mp3',
|
|
||||||
'application/pdf': 'pdf',
|
|
||||||
'text/plain': 'txt',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 base64 获取文件扩展名
|
|
||||||
* @param dataUrl
|
|
||||||
* @param mimeMap
|
|
||||||
*/
|
|
||||||
function getExtensionFromDataUrl(dataUrl: string, mimeMap = mimeToExtension): string | null {
|
|
||||||
const base64Pattern = /^data:(image|video)\/([a-zA-Z]+);base64,([A-Za-z0-9+/]+={0,2})$/
|
|
||||||
if (base64Pattern.test(dataUrl)) {
|
|
||||||
const mimeType = dataUrl.split(',')?.[0]?.split(':')?.[1]?.split(';')[0]
|
|
||||||
return mimeType && mimeType in mimeMap ? mimeMap[mimeType]! : null
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { IEditorConfig, ToolbarOptions } from '@opentiny/fluent-editor/types/config/types'
|
|
||||||
import type { PropType } from 'vue'
|
|
||||||
import type MathIframeDailog from './components/math-iframe-dialog.vue'
|
|
||||||
import type { MyToolbarOption } from './types'
|
|
||||||
import FluentEditor from '@opentiny/fluent-editor'
|
|
||||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
|
||||||
import UpFileDialog from './components/up-file-dialog.vue'
|
|
||||||
import { editImageAttribute, editVideoAttribute, getEditorConfig, initEditor, MY_OPEN_PROCESSON_IFRAME, upFileList } from './editor-util'
|
|
||||||
import 'mathlive'
|
|
||||||
import 'mathlive/static.css'
|
|
||||||
import 'mathlive/fonts.css'
|
|
||||||
import '@opentiny/fluent-editor/style.css'
|
|
||||||
import 'katex/dist/katex.min.css'
|
|
||||||
import 'quill-toolbar-tip/dist/index.css'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 输入内容 */
|
|
||||||
modelValue: { type: String, default: '' },
|
|
||||||
/** 工具栏配置 */
|
|
||||||
toolbarOption: { type: Object as PropType<MyToolbarOption>, default: undefined },
|
|
||||||
})
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
|
||||||
const mathIframeDalogRef = useTemplateRef<InstanceType<typeof MathIframeDailog>>('mathIframeDalogRef')
|
|
||||||
const editorContainerRef = useTemplateRef<HTMLDivElement>('editorContainerRef')
|
|
||||||
const showToolbar = ref(true)
|
|
||||||
|
|
||||||
let editor: FluentEditor | undefined
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newValue) => {
|
|
||||||
if (editor && newValue) {
|
|
||||||
if (newValue.startsWith('<p><br></p>') || newValue.startsWith('<p></p>')) {
|
|
||||||
emit('update:modelValue', '')
|
|
||||||
editor!.root.innerHTML = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
|
||||||
if (editor!.root.innerHTML !== newValue) {
|
|
||||||
editor!.root.innerHTML = newValue
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// 得到编辑器配置
|
|
||||||
const editorOptions: IEditorConfig = await getEditorConfig({
|
|
||||||
toolbarOption: props.toolbarOption,
|
|
||||||
/** 点击自定义打开iframe的按钮的回调 */
|
|
||||||
[MY_OPEN_PROCESSON_IFRAME]: () => {
|
|
||||||
mathIframeDalogRef.value?.show()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const container = (editorOptions!.modules!.toolbar as ToolbarOptions).container || []
|
|
||||||
showToolbar.value = !(container && Array.isArray(container) && container.length > 0)
|
|
||||||
|
|
||||||
// 初始化编辑器
|
|
||||||
editor = initEditor(editorRef.value!, editorOptions)
|
|
||||||
// 设置内容
|
|
||||||
editor.root.innerHTML = props.modelValue
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const ImageBlot: any = FluentEditor.import('formats/image')
|
|
||||||
// 处理图片给每个图片添加类名和最大宽度
|
|
||||||
editor?.scroll.descendants(ImageBlot).forEach((imageBlot: any) => {
|
|
||||||
const node = imageBlot.domNode as HTMLImageElement
|
|
||||||
editImageAttribute(node, true)
|
|
||||||
})
|
|
||||||
// 处理视频给每个视频添加类名和最大宽度
|
|
||||||
const VideoBlot: any = FluentEditor.import('formats/video')
|
|
||||||
editor?.scroll.descendants(VideoBlot).forEach((videoBlot: any) => {
|
|
||||||
const node = videoBlot.domNode as HTMLVideoElement
|
|
||||||
editVideoAttribute(node, true)
|
|
||||||
})
|
|
||||||
}, 10)
|
|
||||||
|
|
||||||
editor?.on('text-change', () => {
|
|
||||||
nextTick(() => {
|
|
||||||
emit('update:modelValue', editor!.root.innerHTML)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 弹出框关闭时弹出mathlive输入框
|
|
||||||
*/
|
|
||||||
function hideIframeDialog() {
|
|
||||||
const butt = (editorContainerRef.value?.querySelector('.ql-toolbar.ql-snow .ql-formats button.ql-formula') || null) as HTMLButtonElement | null
|
|
||||||
butt?.click()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="editorContainerRef" class="rest-basic-editor" :class="{ 'hide-toolbar': showToolbar }">
|
|
||||||
<div ref="editorRef" />
|
|
||||||
<MathIframeDialog ref="mathIframeDalogRef" @hide="hideIframeDialog" />
|
|
||||||
<UpFileDialog :up-file-list="upFileList" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
/* stylelint-disable selector-attribute-name-disallowed-list */
|
|
||||||
.ql-toolbar.ql-snow {
|
|
||||||
// 行高
|
|
||||||
.ql-picker.ql-line-height .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-line-height .ql-picker-item:before {
|
|
||||||
content: '行高(系统默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-line-height.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-line-height.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: attr(data-value) '倍';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字体
|
|
||||||
.ql-picker.ql-font .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-font .ql-picker-item:before {
|
|
||||||
content: '字体(系统默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-font.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-font.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: attr(data-value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字号
|
|
||||||
.ql-picker.ql-size .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-size .ql-picker-item:before {
|
|
||||||
content: '字号(系统默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-size.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-size.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: attr(data-value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
.ql-picker.ql-header .ql-picker-label:before,
|
|
||||||
.ql-picker.ql-header .ql-picker-item:before {
|
|
||||||
content: '标题(默认)';
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-formats .ql-header.ql-picker .ql-picker-label[data-value]:before,
|
|
||||||
.ql-formats .ql-header.ql-picker .ql-picker-item[data-value]:before {
|
|
||||||
content: '标题(H' attr(data-value) ')';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.rest-basic-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
&.hide-toolbar :deep(.ql-toolbar) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep() {
|
|
||||||
.ql-container.ql-snow .toolbar-tip__tooltip {
|
|
||||||
position: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-container.ql-snow {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-editor {
|
|
||||||
overflow-y: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-toolbar.ql-snow > .ql-formats {
|
|
||||||
position: relative;
|
|
||||||
padding: 6px 10px;
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
content: '';
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
transparent 26%,
|
|
||||||
#dcdada 20%,
|
|
||||||
#dcdada 80%,
|
|
||||||
transparent 74%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ql-picker {
|
|
||||||
width: auto;
|
|
||||||
|
|
||||||
.ql-picker-label {
|
|
||||||
&:before {
|
|
||||||
width: auto;
|
|
||||||
padding-right: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
right: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-left: 0;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
display: none;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .ql-picker.ql-expanded .ql-picker-options {
|
|
||||||
z-index: 3;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import type { ToolbarOptions } from '@opentiny/fluent-editor'
|
|
||||||
import type { MY_OPEN_PROCESSON_IFRAME } from './editor-util'
|
|
||||||
|
|
||||||
export interface MyToolbarOption {
|
|
||||||
/** 是否显示工具栏 */
|
|
||||||
show?: boolean
|
|
||||||
/** 是否显示上传文件按钮 */
|
|
||||||
showUpFile?: boolean
|
|
||||||
/** 是否显示上传视频按钮 */
|
|
||||||
showUpVideo?: boolean
|
|
||||||
/** 自定义工具栏 */
|
|
||||||
customToolbar?: ToolbarOptions['container']
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetEditorConfigParams {
|
|
||||||
toolbarOption?: MyToolbarOption
|
|
||||||
[MY_OPEN_PROCESSON_IFRAME]: () => void
|
|
||||||
}
|
|
||||||
export interface UpFile {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
progress: number
|
|
||||||
}
|
|
||||||
@ -1,327 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="el"
|
|
||||||
class="rest-draggable-resizable"
|
|
||||||
:class="{ active: active }"
|
|
||||||
:style="style"
|
|
||||||
@mousedown.stop="handleMouseDown"
|
|
||||||
@touchstart.stop="handleTouchStart"
|
|
||||||
>
|
|
||||||
<slot></slot>
|
|
||||||
<div
|
|
||||||
v-for="handle in handles"
|
|
||||||
:key="handle"
|
|
||||||
class="handle"
|
|
||||||
:class="`handle-${handle}`"
|
|
||||||
@mousedown.stop.prevent="handleResizeStart(handle, $event)"
|
|
||||||
@touchstart.stop.prevent="handleResizeTouchStart(handle, $event)"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
x: { type: Number, required: true },
|
|
||||||
y: { type: Number, required: true },
|
|
||||||
w: { type: Number, required: true },
|
|
||||||
h: { type: Number, required: true },
|
|
||||||
minW: { type: Number, default: 20 },
|
|
||||||
minH: { type: Number, default: 20 },
|
|
||||||
active: { type: Boolean, default: false },
|
|
||||||
draggable: { type: Boolean, default: true },
|
|
||||||
resizable: { type: Boolean, default: true },
|
|
||||||
parent: { type: [Boolean, Object], default: false }, // boolean or HTMLElement
|
|
||||||
handles: {
|
|
||||||
type: Array,
|
|
||||||
default: () => ['tl', 'tm', 'tr', 'mr', 'br', 'bm', 'bl', 'ml'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits([
|
|
||||||
'update:x',
|
|
||||||
'update:y',
|
|
||||||
'update:w',
|
|
||||||
'update:h',
|
|
||||||
'activated',
|
|
||||||
'deactivated',
|
|
||||||
'dragging',
|
|
||||||
'resizing',
|
|
||||||
'drag-end',
|
|
||||||
'resize-end',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const el = ref<HTMLElement | null>(null);
|
|
||||||
const active = ref(props.active);
|
|
||||||
const zIndex = ref(1);
|
|
||||||
|
|
||||||
const style = computed(() => ({
|
|
||||||
left: `${props.x}px`,
|
|
||||||
top: `${props.y}px`,
|
|
||||||
width: `${props.w}px`,
|
|
||||||
height: `${props.h}px`,
|
|
||||||
zIndex: zIndex.value,
|
|
||||||
position: 'absolute' as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// State
|
|
||||||
let isDragging = false;
|
|
||||||
let isResizing = false;
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
let startLeft = 0;
|
|
||||||
let startTop = 0;
|
|
||||||
let startWidth = 0;
|
|
||||||
let startHeight = 0;
|
|
||||||
let currentHandle = '';
|
|
||||||
|
|
||||||
// Parent limits
|
|
||||||
const parentWidth = ref(0);
|
|
||||||
const parentHeight = ref(0);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.active,
|
|
||||||
(val) => {
|
|
||||||
active.value = val;
|
|
||||||
if (val) emit('activated');
|
|
||||||
else emit('deactivated');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function getParentSize() {
|
|
||||||
if (props.parent && el.value?.parentElement) {
|
|
||||||
// If parent prop is an element, use it, otherwise use direct parent
|
|
||||||
const parentEl = (props.parent instanceof HTMLElement ? props.parent : el.value.parentElement);
|
|
||||||
parentWidth.value = parentEl.clientWidth;
|
|
||||||
parentHeight.value = parentEl.clientHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dragging
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (!props.draggable) return;
|
|
||||||
|
|
||||||
// Clicked on handle? handled by handleResizeStart
|
|
||||||
// But this is on the main div, so it should be fine due to stop propagation on handles?
|
|
||||||
// Wait, handles have @mousedown.stop.prevent, so this won't fire for handles.
|
|
||||||
|
|
||||||
// Activate
|
|
||||||
if (!active.value) {
|
|
||||||
active.value = true;
|
|
||||||
emit('activated');
|
|
||||||
}
|
|
||||||
|
|
||||||
isDragging = true;
|
|
||||||
startX = e.clientX;
|
|
||||||
startY = e.clientY;
|
|
||||||
startLeft = props.x;
|
|
||||||
startTop = props.y;
|
|
||||||
|
|
||||||
getParentSize();
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseMove(e: MouseEvent) {
|
|
||||||
if (isDragging) {
|
|
||||||
let dx = e.clientX - startX;
|
|
||||||
let dy = e.clientY - startY;
|
|
||||||
|
|
||||||
let newLeft = startLeft + dx;
|
|
||||||
let newTop = startTop + dy;
|
|
||||||
|
|
||||||
// Constraints
|
|
||||||
if (props.parent) {
|
|
||||||
if (newLeft < 0) newLeft = 0;
|
|
||||||
if (newTop < 0) newTop = 0;
|
|
||||||
if (newLeft + props.w > parentWidth.value) newLeft = parentWidth.value - props.w;
|
|
||||||
if (newTop + props.h > parentHeight.value) newTop = parentHeight.value - props.h;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:x', newLeft);
|
|
||||||
emit('update:y', newTop);
|
|
||||||
emit('dragging', newLeft, newTop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseUp() {
|
|
||||||
if (isDragging) {
|
|
||||||
isDragging = false;
|
|
||||||
emit('drag-end');
|
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resizing
|
|
||||||
function handleResizeStart(handle: string, e: MouseEvent) {
|
|
||||||
if (!props.resizable) return;
|
|
||||||
|
|
||||||
isResizing = true;
|
|
||||||
currentHandle = handle;
|
|
||||||
startX = e.clientX;
|
|
||||||
startY = e.clientY;
|
|
||||||
startLeft = props.x;
|
|
||||||
startTop = props.y;
|
|
||||||
startWidth = props.w;
|
|
||||||
startHeight = props.h;
|
|
||||||
|
|
||||||
getParentSize();
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleResizeMove);
|
|
||||||
window.addEventListener('mouseup', handleResizeUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResizeMove(e: MouseEvent) {
|
|
||||||
if (isResizing) {
|
|
||||||
const dx = e.clientX - startX;
|
|
||||||
const dy = e.clientY - startY;
|
|
||||||
|
|
||||||
let newLeft = startLeft;
|
|
||||||
let newTop = startTop;
|
|
||||||
let newWidth = startWidth;
|
|
||||||
let newHeight = startHeight;
|
|
||||||
|
|
||||||
if (currentHandle.includes('r')) {
|
|
||||||
newWidth = startWidth + dx;
|
|
||||||
}
|
|
||||||
if (currentHandle.includes('l')) {
|
|
||||||
newWidth = startWidth - dx;
|
|
||||||
newLeft = startLeft + dx;
|
|
||||||
}
|
|
||||||
if (currentHandle.includes('b')) {
|
|
||||||
newHeight = startHeight + dy;
|
|
||||||
}
|
|
||||||
if (currentHandle.includes('t')) {
|
|
||||||
newHeight = startHeight - dy;
|
|
||||||
newTop = startTop + dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Min size
|
|
||||||
if (newWidth < props.minW) {
|
|
||||||
newWidth = props.minW;
|
|
||||||
if (currentHandle.includes('l')) newLeft = startLeft + startWidth - props.minW;
|
|
||||||
}
|
|
||||||
if (newHeight < props.minH) {
|
|
||||||
newHeight = props.minH;
|
|
||||||
if (currentHandle.includes('t')) newTop = startTop + startHeight - props.minH;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parent constraints
|
|
||||||
if (props.parent) {
|
|
||||||
if (newLeft < 0) {
|
|
||||||
newWidth += newLeft;
|
|
||||||
newLeft = 0;
|
|
||||||
}
|
|
||||||
if (newTop < 0) {
|
|
||||||
newHeight += newTop;
|
|
||||||
newTop = 0;
|
|
||||||
}
|
|
||||||
if (newLeft + newWidth > parentWidth.value) {
|
|
||||||
newWidth = parentWidth.value - newLeft;
|
|
||||||
}
|
|
||||||
if (newTop + newHeight > parentHeight.value) {
|
|
||||||
newHeight = parentHeight.value - newTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:x', newLeft);
|
|
||||||
emit('update:y', newTop);
|
|
||||||
emit('update:w', newWidth);
|
|
||||||
emit('update:h', newHeight);
|
|
||||||
emit('resizing', newLeft, newTop, newWidth, newHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResizeUp() {
|
|
||||||
if (isResizing) {
|
|
||||||
isResizing = false;
|
|
||||||
emit('resize-end');
|
|
||||||
window.removeEventListener('mousemove', handleResizeMove);
|
|
||||||
window.removeEventListener('mouseup', handleResizeUp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch support (basic)
|
|
||||||
function handleTouchStart(e: TouchEvent) {
|
|
||||||
// Simplified: treat first touch as mouse down
|
|
||||||
if (e.touches.length > 0) {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
handleMouseDown({
|
|
||||||
clientX: touch.clientX,
|
|
||||||
clientY: touch.clientY,
|
|
||||||
} as MouseEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResizeTouchStart(handle: string, e: TouchEvent) {
|
|
||||||
if (e.touches.length > 0) {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
handleResizeStart(handle, {
|
|
||||||
clientX: touch.clientX,
|
|
||||||
clientY: touch.clientY,
|
|
||||||
} as MouseEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global click to deactivate
|
|
||||||
function handleGlobalClick(e: MouseEvent) {
|
|
||||||
if (el.value && !el.value.contains(e.target as Node)) {
|
|
||||||
if (active.value) {
|
|
||||||
active.value = false;
|
|
||||||
emit('deactivated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('mousedown', handleGlobalClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('mousedown', handleGlobalClick);
|
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
window.removeEventListener('mousemove', handleResizeMove);
|
|
||||||
window.removeEventListener('mouseup', handleResizeUp);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rest-draggable-resizable {
|
|
||||||
position: absolute;
|
|
||||||
box-sizing: border-box;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rest-draggable-resizable.active {
|
|
||||||
border: 1px dashed var(--primary-color, #1890ff);
|
|
||||||
z-index: 10 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle {
|
|
||||||
position: absolute;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--primary-color, #1890ff);
|
|
||||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.1);
|
|
||||||
display: none;
|
|
||||||
z-index: 11;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active .handle {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-tl { top: -4px; left: -4px; cursor: nw-resize; }
|
|
||||||
.handle-tm { top: -4px; left: 50%; margin-left: -4px; cursor: n-resize; }
|
|
||||||
.handle-tr { top: -4px; right: -4px; cursor: ne-resize; }
|
|
||||||
.handle-mr { top: 50%; right: -4px; margin-top: -4px; cursor: e-resize; }
|
|
||||||
.handle-br { bottom: -4px; right: -4px; cursor: se-resize; }
|
|
||||||
.handle-bm { bottom: -4px; left: 50%; margin-left: -4px; cursor: s-resize; }
|
|
||||||
.handle-bl { bottom: -4px; left: -4px; cursor: sw-resize; }
|
|
||||||
.handle-ml { top: 50%; left: -4px; margin-top: -4px; cursor: w-resize; }
|
|
||||||
</style>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
|
|
||||||
import type { QuestionInfo } from './types';
|
|
||||||
|
|
||||||
export const topicTypeList = [
|
|
||||||
{ label: '单选题', value: 1 },
|
|
||||||
{ label: '多选题', value: 2 },
|
|
||||||
{ label: '判断题', value: 3 },
|
|
||||||
{ label: '填空题', value: 4 },
|
|
||||||
{ label: '简答题', value: 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const topicType2List = [
|
|
||||||
{ label: '普通题', value: 100 },
|
|
||||||
{ label: '大题号', value: 101 },
|
|
||||||
{ label: '子题号', value: 102 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function defAnswerInfo(x: number) {
|
|
||||||
return {
|
|
||||||
x: x,
|
|
||||||
y: 0,
|
|
||||||
w: 100,
|
|
||||||
h: 50,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defQuestionInfo(no: string, type: number, y: number, pageWidth: number): QuestionInfo {
|
|
||||||
return {
|
|
||||||
no,
|
|
||||||
type,
|
|
||||||
x: 0,
|
|
||||||
y: y,
|
|
||||||
w: pageWidth,
|
|
||||||
h: 100, // Default height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,74 +1,9 @@
|
|||||||
<template>
|
<script lang="ts" setup>
|
||||||
<div class="h-full flex flex-col bg-white p-[15px] pt-0">
|
//
|
||||||
<div class="flex items-center justify-between p-x-[15px] py-[15px]">
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<n-button text class="text-lg mr-2" @click="router.back()">
|
|
||||||
<template #icon>
|
|
||||||
<div class="i-icon-park-outline-left" />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
<div class="text-sm font-bold color-[#333333]">
|
|
||||||
<span>编辑书籍</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<n-button type="primary" round :loading="submmitLoading" :disabled="submmitLoading" @click="submmit()">
|
|
||||||
<template #icon>
|
|
||||||
<div class="i-solar-check-circle-linear" />
|
|
||||||
</template>
|
|
||||||
保存
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-divider class="!my-0" />
|
|
||||||
<div class="flex flex-col flex-1 min-h-0 pt-[15px]">
|
|
||||||
<div class="box-border flex flex-1 flex-row w-full h-full min-h-0 px-[15px]">
|
|
||||||
<!-- 目录 -->
|
|
||||||
<left-tree @switchBookPage="switchBookPage" />
|
|
||||||
<!-- 内容 -->
|
|
||||||
<middle-book :currBookPage="currBookPage" />
|
|
||||||
<!-- 题目 -->
|
|
||||||
<right-topic-list :currBookPage="currBookPage" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useMessage, NButton, NDivider } from 'naive-ui';
|
|
||||||
import LeftTree from './modules/left-tree/left-tree.vue';
|
|
||||||
import RightTopicList from './modules/right-topic/right-topic-list.vue';
|
|
||||||
import MiddleBook from './modules/middle-book/middle-book.vue';
|
|
||||||
import type { CurrBookPageAllInfo } from './types';
|
|
||||||
import { usePageLayoutSubmit } from './util';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
const currBookPage = ref<CurrBookPageAllInfo | undefined>();
|
|
||||||
|
|
||||||
/** 切换页面 */
|
|
||||||
function switchBookPage(data: CurrBookPageAllInfo) {
|
|
||||||
currBookPage.value = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pageLayoutSubmit, submmitLoading } = usePageLayoutSubmit();
|
|
||||||
|
|
||||||
async function submmit() {
|
|
||||||
try {
|
|
||||||
submmitLoading.value = true;
|
|
||||||
await pageLayoutSubmit(currBookPage.value!);
|
|
||||||
submmitLoading.value = false;
|
|
||||||
message.success('保存成功');
|
|
||||||
} catch (error: any) {
|
|
||||||
submmitLoading.value = false;
|
|
||||||
message.error(error.msg || '保存失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
/* 使用 UnoCSS 类替代 SCSS */
|
<div class="">
|
||||||
</style>
|
<!-- page only one root element -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@ -1,194 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NModal v-model:show="showModal" title="导入书籍" preset="card" style="width: 400px">
|
|
||||||
<NForm
|
|
||||||
ref="formRef"
|
|
||||||
:model="formData"
|
|
||||||
:rules="rules"
|
|
||||||
label-placement="top"
|
|
||||||
label-width="90px"
|
|
||||||
>
|
|
||||||
<NFormItem path="startPageNumber">
|
|
||||||
<template #label>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<NTooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<NIcon size="14" color="#ff9a2e">
|
|
||||||
<icon-solar-question-circle-linear />
|
|
||||||
</NIcon>
|
|
||||||
</template>
|
|
||||||
用于排除部分书籍(封面/目录/引言)等没有页码的页面
|
|
||||||
</NTooltip>
|
|
||||||
<span>第1页开始于:</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<NInputNumber
|
|
||||||
v-model:value="formData.startPageNumber"
|
|
||||||
:min="1"
|
|
||||||
placeholder="请输入"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<template #prefix>第</template>
|
|
||||||
<template #suffix>张PDF</template>
|
|
||||||
</NInputNumber>
|
|
||||||
</NFormItem>
|
|
||||||
</NForm>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<NButton
|
|
||||||
type="primary"
|
|
||||||
:loading="importLoading"
|
|
||||||
:disabled="importLoading"
|
|
||||||
@click="handleImport"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-archive-up-minimlistic-outline />
|
|
||||||
</template>
|
|
||||||
开始导入
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, reactive, computed } from 'vue';
|
|
||||||
import { useMessage, NModal, NForm, NFormItem, NInputNumber, NTooltip, NIcon, NButton } from 'naive-ui';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { selectFiles } from '@/utils/file';
|
|
||||||
import { importBookPageAxios, type BookBaseData, type BookCatalog, type BookCatalogImportInput } from '@/service/api/book';
|
|
||||||
import { convertPDFToImageFiles, getPDFPageWidth } from '@/views/template/template-detail/pdf-util';
|
|
||||||
import type { ImageInfo } from '@/views/template/template-detail/types';
|
|
||||||
import { browserPathJoin } from '@/utils/common';
|
|
||||||
import { getAliOssTokenAxios } from '@/service/api/upload';
|
|
||||||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss';
|
|
||||||
import { getSnowflake } from '@/utils/rest';
|
|
||||||
|
|
||||||
type ShowData = {
|
|
||||||
bookInfo: BookBaseData;
|
|
||||||
affirmCallBack: () => void;
|
|
||||||
eventElement?: any; // Kept for compatibility but unused in Modal
|
|
||||||
};
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
const route = useRoute();
|
|
||||||
const showModal = ref(false);
|
|
||||||
const importLoading = ref(false);
|
|
||||||
const formRef = ref();
|
|
||||||
const bookId = ref<number>(Number(route.query.bookId));
|
|
||||||
|
|
||||||
let bookInfo: BookBaseData | undefined = undefined;
|
|
||||||
let affirmCallBack: (() => void) | undefined = undefined;
|
|
||||||
|
|
||||||
const formData = ref({
|
|
||||||
startPageNumber: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
startPageNumber: [
|
|
||||||
{ required: true, type: 'number', message: '请输入起始页', trigger: ['blur', 'change'] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function show(_data: ShowData) {
|
|
||||||
affirmCallBack = _data.affirmCallBack;
|
|
||||||
bookInfo = _data.bookInfo;
|
|
||||||
formData.value.startPageNumber = 1;
|
|
||||||
showModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
showModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImport() {
|
|
||||||
try {
|
|
||||||
await formRef.value?.validate();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('请填写正确的信息');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { list, pdf } = await pdfImport();
|
|
||||||
if (list.length === 0 && pdf.size > 0) {
|
|
||||||
// Stub behavior: if list is empty but file selected, it might be the missing dependency issue
|
|
||||||
// But if pdf-util returns empty list, we should probably stop or proceed if it was intended?
|
|
||||||
// The original code proceeded to importPDFAndBookPages.
|
|
||||||
// My stub returns empty list.
|
|
||||||
// Let's proceed but it will likely upload empty images or just the PDF.
|
|
||||||
// Actually, original code uses `imageList` to map to `bookPages`.
|
|
||||||
// If `imageList` is empty, `bookPages` is empty.
|
|
||||||
// `importBookPageAxios` will be called with empty catalogs.
|
|
||||||
// This is fine for now as a stub.
|
|
||||||
}
|
|
||||||
await importPDFAndBookPages(list, pdf);
|
|
||||||
|
|
||||||
importLoading.value = false;
|
|
||||||
message.success('导入成功');
|
|
||||||
showModal.value = false;
|
|
||||||
} catch (error: any) {
|
|
||||||
importLoading.value = false;
|
|
||||||
message.error(error.message || '添加失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pdfImport(): Promise<{ list: ImageInfo[]; pdf: File }> {
|
|
||||||
try {
|
|
||||||
const files = await selectFiles('.pdf', false);
|
|
||||||
if (!files || files.length === 0) throw new Error('未选择文件');
|
|
||||||
|
|
||||||
if (!bookInfo?.width || !bookInfo?.height) {
|
|
||||||
throw new Error('导入书籍为空,或书籍宽高不合法');
|
|
||||||
}
|
|
||||||
|
|
||||||
importLoading.value = true;
|
|
||||||
// Note: getPDFPageWidth is also stubbed
|
|
||||||
const width = await getPDFPageWidth(files[0]);
|
|
||||||
const imageList = await convertPDFToImageFiles(files[0]);
|
|
||||||
|
|
||||||
return { list: imageList.list, pdf: files[0] };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('从pdf导入失败', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importPDFAndBookPages(imageList: ImageInfo[], pdf: File) {
|
|
||||||
try {
|
|
||||||
const aliOssSTS = await getAliOssTokenAxios();
|
|
||||||
const client = initOSSClient(aliOssSTS);
|
|
||||||
const filePath = `temp/${getSnowflake()}_pdf/${pdf.name}`;
|
|
||||||
await uploadFileToOSS(client, pdf, filePath);
|
|
||||||
const pdfUrl = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL || '', filePath);
|
|
||||||
|
|
||||||
const bookPages: BookCatalog[] = imageList.map((item, index) => {
|
|
||||||
const pageNum = 1 + index;
|
|
||||||
const name = pageNum < formData.value.startPageNumber ? `封面/目录/引言` : `第${pageNum - formData.value.startPageNumber + 1}页`;
|
|
||||||
return { name, type: 1, url: item.url } satisfies BookCatalog;
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: BookCatalogImportInput = {
|
|
||||||
bookId: bookId.value!,
|
|
||||||
bookCatalogs: bookPages,
|
|
||||||
index: formData.value.startPageNumber,
|
|
||||||
pdfUrl
|
|
||||||
};
|
|
||||||
await importBookPageAxios(data);
|
|
||||||
|
|
||||||
affirmCallBack?.();
|
|
||||||
return Promise.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('error========', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* No specific styles needed with UnoCSS */
|
|
||||||
</style>
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NDropdown
|
|
||||||
placement="bottom-start"
|
|
||||||
trigger="manual"
|
|
||||||
:x="x"
|
|
||||||
:y="y"
|
|
||||||
:options="options"
|
|
||||||
:show="showDropdown"
|
|
||||||
@clickoutside="onClickOutside"
|
|
||||||
@select="handleSelect"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, h } from 'vue';
|
|
||||||
import { NDropdown, useDialog, useMessage, NInput } from 'naive-ui';
|
|
||||||
import { editBookCatalogAxios, type CatalogTree } from '@/service/api/book';
|
|
||||||
import IconSolarPen2Linear from '~icons/solar/pen-2-linear';
|
|
||||||
|
|
||||||
const showDropdown = ref(false);
|
|
||||||
const x = ref(0);
|
|
||||||
const y = ref(0);
|
|
||||||
const currentData = ref<CatalogTree | null>(null);
|
|
||||||
|
|
||||||
const dialog = useDialog();
|
|
||||||
const message = useMessage();
|
|
||||||
const emit = defineEmits(['rename', 'delete', 'add']);
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
label: '重命名',
|
|
||||||
key: 'rename',
|
|
||||||
icon: () => h(IconSolarPen2Linear)
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type ShowData = {
|
|
||||||
treeData: CatalogTree;
|
|
||||||
event: MouseEvent;
|
|
||||||
bookId: number;
|
|
||||||
renameCallBack?: (name: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let renameCallBack: ((name: string) => void) | undefined;
|
|
||||||
|
|
||||||
function show(data: ShowData) {
|
|
||||||
data.event.preventDefault();
|
|
||||||
showDropdown.value = false;
|
|
||||||
currentData.value = data.treeData;
|
|
||||||
renameCallBack = data.renameCallBack;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
x.value = data.event.clientX;
|
|
||||||
y.value = data.event.clientY;
|
|
||||||
showDropdown.value = true;
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClickOutside() {
|
|
||||||
showDropdown.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelect(key: string) {
|
|
||||||
showDropdown.value = false;
|
|
||||||
if (key === 'rename') {
|
|
||||||
handleRename();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRename() {
|
|
||||||
if (!currentData.value) return;
|
|
||||||
|
|
||||||
let tempName = currentData.value.name;
|
|
||||||
|
|
||||||
const d = dialog.create({
|
|
||||||
title: '重命名',
|
|
||||||
content: () => {
|
|
||||||
return h(NInput, {
|
|
||||||
defaultValue: tempName,
|
|
||||||
placeholder: '请输入目录名称',
|
|
||||||
onUpdateValue: (v) => tempName = v
|
|
||||||
})
|
|
||||||
},
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
if (!tempName) {
|
|
||||||
message.warning('请输入名称');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
d.loading = true;
|
|
||||||
// Assuming API expects id and name. The original code had `editBookCatalogAxios`.
|
|
||||||
// I'll call API here or use callback.
|
|
||||||
// Original code logic was mixed. I'll use API here for simplicity if callback is just for UI update.
|
|
||||||
|
|
||||||
// Check if we should call API or just callback
|
|
||||||
await editBookCatalogAxios({
|
|
||||||
id: currentData.value!.id,
|
|
||||||
name: tempName,
|
|
||||||
type: currentData.value!.type,
|
|
||||||
bookId: currentData.value!.bookId // Assuming bookId is in data or passed
|
|
||||||
});
|
|
||||||
|
|
||||||
if (renameCallBack) {
|
|
||||||
renameCallBack(tempName);
|
|
||||||
} else if (currentData.value) {
|
|
||||||
currentData.value.name = tempName;
|
|
||||||
}
|
|
||||||
message.success('修改成功');
|
|
||||||
return true;
|
|
||||||
} catch(e: any) {
|
|
||||||
message.error(e.message || '修改失败');
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
d.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ show });
|
|
||||||
</script>
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<div class="box-border flex items-center justify-between w-full px-[15px] py-[10px]">
|
|
||||||
<span class="font-bold text-base">目录</span>
|
|
||||||
<NButton v-if="treeData.length <= 0" type="primary" ghost size="small" @click="importBook">
|
|
||||||
<template #icon>
|
|
||||||
<div class="i-icon-park-outline-file-pdf" />
|
|
||||||
</template>
|
|
||||||
导入书籍
|
|
||||||
</NButton>
|
|
||||||
<NButton v-else type="primary" ghost size="small" @click="setStartPage">
|
|
||||||
<template #icon>
|
|
||||||
<div class="i-icon-park-outline-file-pdf" />
|
|
||||||
</template>
|
|
||||||
重置起始页
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 overflow-hidden relative">
|
|
||||||
<NSpin :show="pageLoading" class="h-full">
|
|
||||||
<NScrollbar class="h-full">
|
|
||||||
<NTree
|
|
||||||
block-line
|
|
||||||
:data="treeData"
|
|
||||||
key-field="id"
|
|
||||||
label-field="name"
|
|
||||||
children-field="childs"
|
|
||||||
:default-expanded-keys="[1]"
|
|
||||||
:node-props="nodeProps"
|
|
||||||
@update:selected-keys="handleSelectedKeysChange"
|
|
||||||
>
|
|
||||||
<template #label="{ option }">
|
|
||||||
<div class="flex items-center justify-between w-full pr-2 group">
|
|
||||||
<span class="truncate">{{ option.name }}</span>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span v-if="option.type === 1 && option.verifyStatus === 1" class="text-xs text-green-500 ml-2">校验成功</span>
|
|
||||||
<span v-else-if="option.type === 1 && option.verifyStatus === 2" class="text-xs text-red-500 ml-2">校验失败</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NTree>
|
|
||||||
</NScrollbar>
|
|
||||||
<div v-if="!treeData.length && !pageLoading" class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<NEmpty description="暂无数据">
|
|
||||||
<template #extra>
|
|
||||||
<NButton size="small" @click="getBaseDataAxios">
|
|
||||||
重新加载
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</div>
|
|
||||||
</NSpin>
|
|
||||||
</div>
|
|
||||||
<ImportBubble ref="importBubbleRef" />
|
|
||||||
<MenuBubble ref="menuBubbleRef" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, shallowReactive, onMounted, useTemplateRef } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { useMessage, NButton, NSpin, NScrollbar, NTree, NEmpty, type TreeOption } from 'naive-ui';
|
|
||||||
import MenuBubble from './components/menu-bubble.vue';
|
|
||||||
import ImportBubble from './components/import-bubble.vue';
|
|
||||||
import {
|
|
||||||
type BookBaseData,
|
|
||||||
type CatalogTree,
|
|
||||||
getBookBaseInfoAxios,
|
|
||||||
getBookCatalogTreeAxios,
|
|
||||||
getBookPageDetailAxios,
|
|
||||||
setStartPageAxios,
|
|
||||||
} from '@/service/api/book';
|
|
||||||
import type { CurrBookPageAllInfo } from '@/views/template/template-detail/types';
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'switchBookPage', data: CurrBookPageAllInfo): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const message = useMessage();
|
|
||||||
const bookId = ref<number>(Number(route.query.bookId));
|
|
||||||
const menuBubbleRef = useTemplateRef('menuBubbleRef');
|
|
||||||
const importBubbleRef = useTemplateRef('importBubbleRef');
|
|
||||||
const treeData = ref<CatalogTree[]>([]);
|
|
||||||
const bookData = ref<BookBaseData>({});
|
|
||||||
const currPage = ref<number | string>(-1);
|
|
||||||
const pageLoading = ref(false);
|
|
||||||
|
|
||||||
const treeAxiosData = shallowReactive<Record<number, CurrBookPageAllInfo>>({});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (bookId.value) {
|
|
||||||
getBaseDataAxios();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getBaseDataAxios() {
|
|
||||||
try {
|
|
||||||
pageLoading.value = true;
|
|
||||||
const res = await getBookBaseInfoAxios({ id: bookId.value });
|
|
||||||
if (res.data) {
|
|
||||||
bookData.value = res.data;
|
|
||||||
}
|
|
||||||
await getTreeData();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTreeData() {
|
|
||||||
try {
|
|
||||||
const res = await getBookCatalogTreeAxios({ bookId: bookId.value });
|
|
||||||
if (res.data) {
|
|
||||||
treeData.value = res.data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeProps({ option }: { option: TreeOption }) {
|
|
||||||
return {
|
|
||||||
onClick() {
|
|
||||||
nodeClick(option);
|
|
||||||
},
|
|
||||||
onContextmenu(e: MouseEvent) {
|
|
||||||
nodeContextmenu(e, option);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function nodeClick(option: TreeOption) {
|
|
||||||
const data = option as unknown as CatalogTree;
|
|
||||||
// If it has children or not type 1 (page?), maybe don't load page details?
|
|
||||||
// Logic from original code:
|
|
||||||
// if (data.childs?.length || data.type !== 1 || currPage.value === data.id) return;
|
|
||||||
|
|
||||||
if (data.childs?.length || data.type !== 1) {
|
|
||||||
// It's a folder or non-page node
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currPage.value === data.id) return;
|
|
||||||
|
|
||||||
currPage.value = data.id;
|
|
||||||
if (treeAxiosData[data.id]) {
|
|
||||||
emit('switchBookPage', treeAxiosData[data.id]!);
|
|
||||||
} else {
|
|
||||||
await getCurrPageData(data.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectedKeysChange(keys: Array<string | number>) {
|
|
||||||
// Optional: sync selection state if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCurrPageData(pageId: number) {
|
|
||||||
pageLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await getBookPageDetailAxios({ pageId });
|
|
||||||
if (res.data) {
|
|
||||||
treeAxiosData[pageId] = res.data;
|
|
||||||
emit('switchBookPage', res.data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
message.error('获取页面详情失败');
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function importBook() {
|
|
||||||
importBubbleRef.value?.show({
|
|
||||||
bookInfo: bookData.value,
|
|
||||||
affirmCallBack: () => {
|
|
||||||
getTreeData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setStartPage() {
|
|
||||||
importBubbleRef.value?.show({
|
|
||||||
bookInfo: bookData.value,
|
|
||||||
affirmCallBack: () => {
|
|
||||||
getTreeData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeContextmenu(e: MouseEvent, option: TreeOption) {
|
|
||||||
e.preventDefault();
|
|
||||||
menuBubbleRef.value?.show({
|
|
||||||
event: e,
|
|
||||||
treeData: option as unknown as CatalogTree,
|
|
||||||
bookId: bookId.value,
|
|
||||||
renameCallBack: (name: string) => {
|
|
||||||
option.name = name;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* UnoCSS handled styles */
|
|
||||||
</style>
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-dropdown
|
|
||||||
:show="showDropdown"
|
|
||||||
:x="x"
|
|
||||||
:y="y"
|
|
||||||
:options="options"
|
|
||||||
placement="bottom-start"
|
|
||||||
trigger="manual"
|
|
||||||
@select="handleSelect"
|
|
||||||
@clickoutside="hide"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, h } from 'vue';
|
|
||||||
import { NDropdown } from 'naive-ui';
|
|
||||||
import SolarTrashBinTrashLinear from '~icons/solar/trash-bin-trash-linear';
|
|
||||||
import SolarEyeOutline from '~icons/solar/eye-outline';
|
|
||||||
import SolarLayersMinimalisticLinear from '~icons/solar/layers-minimalistic-linear';
|
|
||||||
|
|
||||||
type ShowData = {
|
|
||||||
eventElement: MouseEvent;
|
|
||||||
showRename: boolean;
|
|
||||||
/** 置于底层 回调 */
|
|
||||||
editIndexBack?: () => void;
|
|
||||||
/** 恢复问题 */
|
|
||||||
renameCallBack?: () => void;
|
|
||||||
/** 删除成功时回调 */
|
|
||||||
deleteCallBack?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showDropdown = ref(false);
|
|
||||||
const x = ref(0);
|
|
||||||
const y = ref(0);
|
|
||||||
const currentCallbacks = ref<Partial<ShowData>>({});
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
const opts = [
|
|
||||||
{
|
|
||||||
label: '置于底层',
|
|
||||||
key: 'editIndex',
|
|
||||||
icon: () => h(SolarLayersMinimalisticLinear)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (currentCallbacks.value.showRename) {
|
|
||||||
opts.push({
|
|
||||||
label: '恢复问题',
|
|
||||||
key: 'rename',
|
|
||||||
icon: () => h(SolarEyeOutline),
|
|
||||||
props: { class: 'text-red-500' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
opts.push({
|
|
||||||
label: '删除',
|
|
||||||
key: 'delete',
|
|
||||||
icon: () => h(SolarTrashBinTrashLinear),
|
|
||||||
props: { class: 'text-red-500' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return opts;
|
|
||||||
});
|
|
||||||
|
|
||||||
function show(data: ShowData) {
|
|
||||||
showDropdown.value = true;
|
|
||||||
x.value = data.eventElement.clientX;
|
|
||||||
y.value = data.eventElement.clientY;
|
|
||||||
currentCallbacks.value = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
showDropdown.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelect(key: string) {
|
|
||||||
hide();
|
|
||||||
switch (key) {
|
|
||||||
case 'editIndex':
|
|
||||||
currentCallbacks.value.editIndexBack?.();
|
|
||||||
break;
|
|
||||||
case 'rename':
|
|
||||||
currentCallbacks.value.renameCallBack?.();
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
currentCallbacks.value.deleteCallBack?.();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
<!-- eslint-disable vue/no-mutating-props -->
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<rest-draggable-resizable
|
|
||||||
v-if="topicInfo.question.show !== false"
|
|
||||||
:key="`question_${topicInfo.questionId}`"
|
|
||||||
v-model:x="topicInfo.question.x"
|
|
||||||
v-model:y="topicInfo.question.y"
|
|
||||||
v-model:w="topicInfo.question.w"
|
|
||||||
v-model:h="topicInfo.question.h"
|
|
||||||
class="question"
|
|
||||||
:class="{ 'special': isSpecial }"
|
|
||||||
:style="{ zIndex: questionZIndex }"
|
|
||||||
:initW="topicInfo.question.w"
|
|
||||||
:initH="topicInfo.question.h"
|
|
||||||
:parent="bookContainerBoxRef"
|
|
||||||
:draggable="true"
|
|
||||||
:resizable="true"
|
|
||||||
@activated="handleActivated('question')"
|
|
||||||
@deactivated="handleDeactivated('question')"
|
|
||||||
@contextmenu.stop.prevent="showContextMenu($event, 'question')"
|
|
||||||
>
|
|
||||||
<div class="top-tag" @mousedown.stop.prevent="">
|
|
||||||
<span class="top-tag-text" @click.stop.prevent="handleOpenEdit">{{ questions?.no }}</span>
|
|
||||||
|
|
||||||
<span class="top-tag-text" @click.stop.prevent="handleOpenEdit">{{ showType }}</span>
|
|
||||||
<template v-if="!isSpecial && questions && 'analysis' in questions">
|
|
||||||
<span
|
|
||||||
v-if="[1, 2, 3].includes(questions?.type || -1)"
|
|
||||||
class="top-tag-text"
|
|
||||||
:class="questions?.options ? 'success' : 'danger'"
|
|
||||||
@click.stop.prevent="handleOpenEdit"
|
|
||||||
>
|
|
||||||
配
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="top-tag-text" :class="questions?.answers ? 'success' : 'danger'" @click.stop.prevent="handleOpenEdit">答</span>
|
|
||||||
<span class="top-tag-text" :class="questions?.analysis ? 'success' : 'danger'" @click.stop.prevent="handleOpenEdit">解</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="del-box" @mousedown.stop.prevent="">
|
|
||||||
<n-button type="error" text size="tiny" @click="delQuestion()">
|
|
||||||
<template #icon>
|
|
||||||
<SolarTrashBinMinimalisticOutline />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</rest-draggable-resizable>
|
|
||||||
|
|
||||||
<rest-draggable-resizable
|
|
||||||
v-for="(answerInfo, answerIndex) in topicInfo.answerList"
|
|
||||||
:key="`answer_${questions?.no}_${answerIndex}`"
|
|
||||||
v-model:x="answerInfo.x"
|
|
||||||
v-model:y="answerInfo.y"
|
|
||||||
v-model:w="answerInfo.w"
|
|
||||||
v-model:h="answerInfo.h"
|
|
||||||
class="answer"
|
|
||||||
:style="{ zIndex: answerZIndex }"
|
|
||||||
:initW="answerInfo.w"
|
|
||||||
:initH="answerInfo.h"
|
|
||||||
:parent="bookContainerBoxRef"
|
|
||||||
:draggable="true"
|
|
||||||
:resizable="true"
|
|
||||||
@activated="handleActivated('answer')"
|
|
||||||
@deactivated="handleDeactivated('answer')"
|
|
||||||
@contextmenu.stop.prevent="showContextMenu($event, 'answer', answerIndex)"
|
|
||||||
>
|
|
||||||
<div class="tag-danger top-tag" style="left: 4px; transform: scale(0.7)" @mousedown.stop.prevent="">
|
|
||||||
<span class="top-tag-text">{{ questions?.no }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="del-box" @mousedown.stop.prevent="">
|
|
||||||
<n-button type="error" text size="tiny" @click="delAnswer(answerIndex)">
|
|
||||||
<template #icon>
|
|
||||||
<SolarTrashBinMinimalisticOutline />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</rest-draggable-resizable>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, defineProps, PropType } from 'vue';
|
|
||||||
import NButton from 'naive-ui';
|
|
||||||
import SolarTrashBinMinimalisticOutline from '~icons/solar/trash-bin-minimalistic-outline';
|
|
||||||
import RestDraggableResizable from '@/components/common/rest-draggable-resizable/rest-draggable-resizable.vue';
|
|
||||||
import type { TopicInfo } from '@/views/template/template-detail/types';
|
|
||||||
import type MenuBubble from './menu-bubble.vue';
|
|
||||||
import { topicTypeList } from '@/views/template/template-detail/def-data';
|
|
||||||
import type { BookPageAreas, BookPageQuestion } from '@/service/api/book';
|
|
||||||
import { $mitt, open_book_topic_edit } from '@/utils/event-bus';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 题目 */
|
|
||||||
topicInfo: { type: Object as PropType<TopicInfo>, required: true },
|
|
||||||
/** 限制移动区域的ref */
|
|
||||||
bookContainerBoxRef: { type: [Object, null] as PropType<HTMLDivElement | null>, required: true },
|
|
||||||
/** 菜单气泡的ref */
|
|
||||||
getMenuBubbleRef: { type: [Function, null] as PropType<() => InstanceType<typeof MenuBubble> | null>, default: null },
|
|
||||||
/** 题目信息 */
|
|
||||||
getQuestions: { type: Function as PropType<() => BookPageAreas | BookPageQuestion | undefined>, default: () => undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialog = useDialog();
|
|
||||||
const questionZIndex = ref<number>(2);
|
|
||||||
const answerZIndex = ref<number>(3);
|
|
||||||
|
|
||||||
const questions = computed<BookPageAreas | BookPageQuestion | undefined>(() => props.getQuestions?.());
|
|
||||||
const showType = computed(() => topicTypeList.find((item) => item.value === questions.value?.type)?.label || '未知');
|
|
||||||
/** 是否特殊题目(区域) */
|
|
||||||
const isSpecial = computed(() => (questions.value?.type || 0) >= 100);
|
|
||||||
|
|
||||||
/** 删除答案 */
|
|
||||||
async function delAnswer(answerIndex: number) {
|
|
||||||
try {
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '温馨提示',
|
|
||||||
content: '删除后不可恢复,您确定删除吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: () => resolve(true),
|
|
||||||
onNegativeClick: () => reject()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
props.topicInfo!.answerList!.splice(answerIndex, 1);
|
|
||||||
} catch (error) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function delQuestion() {
|
|
||||||
props.topicInfo!.question.show = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showContextMenu(e: MouseEvent, type: 'answer' | 'question', answerIndex?: number) {
|
|
||||||
e.preventDefault();
|
|
||||||
props.getMenuBubbleRef()?.show({
|
|
||||||
eventElement: e,
|
|
||||||
showRename: type === 'answer' && props.topicInfo!.question.show === false,
|
|
||||||
editIndexBack() {
|
|
||||||
props.topicInfo!.question.show = true;
|
|
||||||
props.getMenuBubbleRef()!.hide();
|
|
||||||
},
|
|
||||||
// 置于底层
|
|
||||||
renameCallBack() {
|
|
||||||
if (type === 'answer') {
|
|
||||||
answerZIndex.value = 2;
|
|
||||||
} else {
|
|
||||||
questionZIndex.value = 2;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async deleteCallBack() {
|
|
||||||
if (type === 'answer') {
|
|
||||||
try {
|
|
||||||
answerIndex ?? (await delAnswer(answerIndex!));
|
|
||||||
} catch (error) {}
|
|
||||||
props.getMenuBubbleRef()!.hide();
|
|
||||||
} else {
|
|
||||||
props.topicInfo!.question.show = false;
|
|
||||||
props.getMenuBubbleRef()!.hide();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开编辑 */
|
|
||||||
function handleOpenEdit(event: MouseEvent) {
|
|
||||||
$mitt.emit(open_book_topic_edit, { questionId: props.topicInfo.questionId, event });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 组件从活跃状态到非活跃状态时触发 */
|
|
||||||
function handleDeactivated(type: 'answer' | 'question') {
|
|
||||||
if (type === 'answer') {
|
|
||||||
answerZIndex.value = 4;
|
|
||||||
} else {
|
|
||||||
questionZIndex.value = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 组件从非活跃状态到活跃状态时触发 */
|
|
||||||
function handleActivated(type: 'answer' | 'question') {
|
|
||||||
if (type === 'answer') {
|
|
||||||
answerZIndex.value = 5;
|
|
||||||
} else {
|
|
||||||
questionZIndex.value = 5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.vdr-container.answer {
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: rgba(215, 250, 222, 0.2);
|
|
||||||
border-color: #67c23a;
|
|
||||||
|
|
||||||
> .vdr-handle {
|
|
||||||
border-color: #67c23a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vdr-container.topic {
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: rgba(209, 227, 252, 0.2);
|
|
||||||
border-color: #409eff;
|
|
||||||
|
|
||||||
> .vdr-handle {
|
|
||||||
border-color: #409eff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vdr-container.question {
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: rgba(255, 245, 234, 0.2);
|
|
||||||
border-color: #e6a23c;
|
|
||||||
|
|
||||||
> .vdr-handle {
|
|
||||||
border-color: #e6a23c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.del-box {
|
|
||||||
position: absolute;
|
|
||||||
top: -10px;
|
|
||||||
right: -10px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #fef0f0;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vdr-container.active > .del-box {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-tag {
|
|
||||||
position: absolute;
|
|
||||||
top: -20px;
|
|
||||||
left: 8px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
|
||||||
transform-origin: bottom left;
|
|
||||||
|
|
||||||
.top-tag-text {
|
|
||||||
height: 18px;
|
|
||||||
padding: 0 10px;
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #409eff;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
background-color: #ecf5ff;
|
|
||||||
border: 1px solid #409eff;
|
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
color: #67c23a;
|
|
||||||
background-color: #f0f9eb;
|
|
||||||
border: 1px solid #67c23a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
color: #f56c6c;
|
|
||||||
background-color: #fef0f0;
|
|
||||||
border: 1px solid #f56c6c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic .topic-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #409eff;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question .question-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #e6a23c;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.answer .answer-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #67c23a;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
|
|
||||||
<template>
|
|
||||||
<div ref="middleBookRef" class="box-border flex items-center justify-center w-full h-full mx-15px overflow-hidden bg-[#f8f8f8] rounded-10px">
|
|
||||||
<n-scrollbar
|
|
||||||
class="w-full h-full"
|
|
||||||
content-style="box-sizing: border-box; min-height: 100%; height: auto; display: flex; align-items: center; justify-content: center;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="currBookPage"
|
|
||||||
class="overflow-hidden"
|
|
||||||
:style="{ width: `calc(${newImgWidth} + ${padding * scale}px)`, height: `calc(${newImgHeight} + ${padding * scale}px)` }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="currBookPage?.bookBaseData.width"
|
|
||||||
class="box-border flex items-center justify-center w-full h-full min-h-0 origin-top-left transform-gpu"
|
|
||||||
:style="{ width: `calc(${newDefPDFWidth} + ${padding}px)`, height: `calc(${newDefPDFHeigth} + ${padding}px)`, transform: `scale(${scale})` }"
|
|
||||||
>
|
|
||||||
<img class="absolute box-border select-none" :src="currBookPage?.currBookPageData?.url || ''" :style="{ width: newDefPDFWidth, height: newDefPDFHeigth }" />
|
|
||||||
<div
|
|
||||||
:style="{ width: `calc(${newDefPDFWidth} + ${padding}px)`, height: `calc(${newDefPDFHeigth} + ${padding}px)`, padding: `${padding / 2}px` }"
|
|
||||||
class="absolute top-0 left-0 box-border"
|
|
||||||
>
|
|
||||||
<div v-if="isShow" ref="bookContainerBoxRef" class="relative w-full h-full">
|
|
||||||
<!-- Removed RestDraggableContainer wrapper -->
|
|
||||||
<topic-item-module
|
|
||||||
v-for="(topicInfo, topicInfoIndex) in currBookPage?.currBookPageData.layout"
|
|
||||||
:key="topicInfoIndex"
|
|
||||||
:topicInfo="topicInfo"
|
|
||||||
:bookContainerBoxRef="bookContainerBoxRef"
|
|
||||||
:getQuestions="() => getQuestions(topicInfo.question.type || null, topicInfo.questionId)"
|
|
||||||
:getMenuBubbleRef="getMenuBubbleRefFun"
|
|
||||||
></topic-item-module>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center justify-center h-full min-h-0">
|
|
||||||
<n-empty description="请在目录中选择页码" class="w-240px" />
|
|
||||||
</div>
|
|
||||||
</n-scrollbar>
|
|
||||||
<menu-bubble ref="menuBubbleRef"></menu-bubble>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, nextTick, watch, useTemplateRef, h } from 'vue';
|
|
||||||
import { NScrollbar, NEmpty } from 'naive-ui';
|
|
||||||
import TopicItemModule from './components/topic-item-module.vue';
|
|
||||||
import { useElementSize } from '@vueuse/core';
|
|
||||||
import type { CurrBookPageAllInfo, QuestionType, BookPageAreas, BookPageQuestion } from '@/views/template/template-detail/types';
|
|
||||||
import { getPDFPageWidth } from '@/views/template/template-detail/pdf-util';
|
|
||||||
import MenuBubble from './components/menu-bubble.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 当前页码信息 */
|
|
||||||
currBookPage: { type: Object as PropType<CurrBookPageAllInfo>, default: undefined },
|
|
||||||
});
|
|
||||||
const bookContainerBoxRef = useTemplateRef('bookContainerBoxRef');
|
|
||||||
const middleBookRef = useTemplateRef('middleBookRef');
|
|
||||||
const { width } = useElementSize(middleBookRef);
|
|
||||||
const menuBubbleRef = useTemplateRef('menuBubbleRef');
|
|
||||||
|
|
||||||
const padding = ref(60);
|
|
||||||
const scale = ref(1);
|
|
||||||
const newImgWidth = ref('100%');
|
|
||||||
const newImgHeight = ref('100%');
|
|
||||||
const pageWidth = computed(() => getPDFPageWidth(props.currBookPage?.bookBaseData.width || 0));
|
|
||||||
const newDefPDFWidth = computed(() => `${pageWidth.value}px`);
|
|
||||||
const newDefPDFHeigth = ref('100%');
|
|
||||||
const isShow = ref(false);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
isShow.value = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
watch(
|
|
||||||
() => [props.currBookPage, width.value],
|
|
||||||
() => {
|
|
||||||
nextTick(() => {
|
|
||||||
if (props.currBookPage?.currBookPageData?.url && props.currBookPage?.bookBaseData.width) {
|
|
||||||
const scaleW = width.value / (pageWidth.value + padding.value);
|
|
||||||
const defPDFHeigth = (props.currBookPage.bookBaseData.height! / props.currBookPage.bookBaseData.width!) * pageWidth.value;
|
|
||||||
newDefPDFHeigth.value = `${defPDFHeigth}px`;
|
|
||||||
scale.value = Math.min(scaleW, 1);
|
|
||||||
newImgWidth.value = `${pageWidth.value * scale.value}px`;
|
|
||||||
newImgHeight.value = `${defPDFHeigth * scale.value}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
function getMenuBubbleRefFun() {
|
|
||||||
return menuBubbleRef.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 得到问题信息
|
|
||||||
* @param type 题目类型
|
|
||||||
*/
|
|
||||||
function getQuestions(type: QuestionType | null, questionId: number): BookPageAreas | BookPageQuestion | undefined {
|
|
||||||
if (typeof type !== 'number') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (type >= 100 && type < 200) {
|
|
||||||
return (props.currBookPage?.currBookPageData?.areas || []).find((item) => item.id === questionId);
|
|
||||||
} else if (type >= 0 && type < 100) {
|
|
||||||
return (props.currBookPage?.currBookPageData?.questions || []).find((item) => item.id === questionId);
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Removed SCSS, using UnoCSS classes in template */
|
|
||||||
</style>
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-modal v-model:show="showModal" preset="card" title="添加解析" class="w-600px" :bordered="false">
|
|
||||||
<div class="analysis-container h-600px flex flex-col">
|
|
||||||
<div class="flex items-center justify-between p-4 border-b border-gray-200">
|
|
||||||
<span class="text-18px font-bold text-[#303133]">添加解析</span>
|
|
||||||
<n-button v-if="![1, 2, 3].includes(type ?? -1)" type="primary" @click="addCustomOption">
|
|
||||||
<template #icon>
|
|
||||||
<icon-mdi-plus />
|
|
||||||
</template>
|
|
||||||
添加自定义选项
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-scrollbar class="flex-1 p-4">
|
|
||||||
<!-- Types 1, 2, 3: Single, Multiple, True/False -->
|
|
||||||
<template v-if="[1, 2, 3].includes(type ?? -1)">
|
|
||||||
<div v-for="(item, index) in customOptions" :key="index" class="mb-4">
|
|
||||||
<n-card size="small">
|
|
||||||
<n-form-item :label="`${item.key}:`" :show-feedback="false">
|
|
||||||
<n-input
|
|
||||||
v-model:value="item.value"
|
|
||||||
type="textarea"
|
|
||||||
:placeholder="`请输入${item.key}对应的解析内容`"
|
|
||||||
:rows="3"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Other types -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="dynamic-options">
|
|
||||||
<div v-for="(item, index) in customOptions" :key="index" class="mb-4 p-2">
|
|
||||||
<n-card size="small">
|
|
||||||
<n-form ref="formRefs" :model="item" :rules="formRule">
|
|
||||||
<n-form-item path="key" class="w-full mb-4">
|
|
||||||
<div class="flex gap-4 items-center w-full">
|
|
||||||
<n-input v-model:value="item.key" placeholder="请输入选项" />
|
|
||||||
<n-button type="error" ghost @click="removeOption(index)">
|
|
||||||
<template #icon>
|
|
||||||
<icon-mdi-delete />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item path="value">
|
|
||||||
<n-input
|
|
||||||
v-model:value="item.value"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="请输入解析内容"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-scrollbar>
|
|
||||||
|
|
||||||
<div class="flex gap-2 justify-end p-4 border-t border-gray-200">
|
|
||||||
<n-button @click="hide">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="affirmLoading" @click="handleAffirm">确定</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, useTemplateRef } from 'vue';
|
|
||||||
import { useMessage, useDialog } from 'naive-ui';
|
|
||||||
import type { FormInst, FormRules } from 'naive-ui';
|
|
||||||
import { type QuestionFromInfo, updateKeywordAnalysisAxios } from '@/service/api/book';
|
|
||||||
import { myStrToJson } from '@/utils/data';
|
|
||||||
|
|
||||||
// Icons need to be handled. Assuming unplugin-icons is set up or I should use a generic one.
|
|
||||||
// I'll use icon-mdi-plus and icon-mdi-delete assuming standard icon sets.
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
const dialog = useDialog();
|
|
||||||
|
|
||||||
const showModal = ref(false);
|
|
||||||
const formRefs = useTemplateRef<FormInst[]>('formRefs'); // Note: naive-ui doesn't support array refs automatically in template like element-plus might, but vue 3.5 does.
|
|
||||||
// However, in loop, we might need to handle validation differently or collect refs manually.
|
|
||||||
// Actually, for "Other types", we have multiple forms.
|
|
||||||
// A simpler way is to validate manually or use one form with dynamic fields.
|
|
||||||
// Let's stick to the structure but handle validation carefully.
|
|
||||||
|
|
||||||
const affirmLoading = ref(false);
|
|
||||||
const questionFromInfo = ref<QuestionFromInfo | undefined>(undefined);
|
|
||||||
const type = ref<number | undefined>(undefined);
|
|
||||||
|
|
||||||
const options = ref<string[]>([]);
|
|
||||||
const customOptions = ref<{ key: string; value: string }[]>([]);
|
|
||||||
|
|
||||||
const formRule: FormRules = {
|
|
||||||
key: [{ required: true, message: '选项不能为空', trigger: 'blur' }],
|
|
||||||
value: [{ required: true, message: '解析内容不能为空', trigger: 'blur' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleAffirm() {
|
|
||||||
try {
|
|
||||||
affirmLoading.value = true;
|
|
||||||
|
|
||||||
if (![1, 2, 3].includes(type.value ?? -1)) {
|
|
||||||
// Validate all dynamic forms
|
|
||||||
// Since formRefs might not work as array directly in some setups, let's assume we can get them.
|
|
||||||
// Or better, just check the data manually since it's simple.
|
|
||||||
let valid = true;
|
|
||||||
if (Array.isArray(formRefs.value)) {
|
|
||||||
for (const form of formRefs.value) {
|
|
||||||
await form.validate((errors) => {
|
|
||||||
if (errors) valid = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (formRefs.value) {
|
|
||||||
// Single form instance? Unlikely with v-for.
|
|
||||||
// Vue 3 v-for ref behavior:
|
|
||||||
// In Vue 3.2.25+, ref in v-for should work as array if bound to a variable.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback manual validation if refs are tricky
|
|
||||||
for (const item of customOptions.value) {
|
|
||||||
if (!item.key || !item.value) {
|
|
||||||
message.error('请填写完整信息');
|
|
||||||
affirmLoading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateKeywordAnalysisAxios({
|
|
||||||
id: questionFromInfo.value!.id!,
|
|
||||||
keywordAnalysis: JSON.stringify(customOptions.value),
|
|
||||||
});
|
|
||||||
hide();
|
|
||||||
message.success('操作成功');
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('error====', error);
|
|
||||||
message.error(error.msg || '操作失败');
|
|
||||||
} finally {
|
|
||||||
affirmLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCustomOption() {
|
|
||||||
customOptions.value.push({
|
|
||||||
key: '',
|
|
||||||
value: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeOption(index: number) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '温馨提示',
|
|
||||||
content: '删除后不可恢复,您确定删除吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: () => {
|
|
||||||
customOptions.value.splice(index, 1);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(_data: { questionFromInfo?: QuestionFromInfo }) {
|
|
||||||
type.value = _data.questionFromInfo?.type ?? undefined;
|
|
||||||
options.value = myStrToJson<string[]>(_data.questionFromInfo?.options || '[]') || [];
|
|
||||||
questionFromInfo.value = _data.questionFromInfo ?? undefined;
|
|
||||||
|
|
||||||
const savedAnalysis = myStrToJson<{ key: string; value: string }[]>(_data.questionFromInfo?.keywordAnalysis || '[]') || [];
|
|
||||||
if (savedAnalysis.length > 0) {
|
|
||||||
customOptions.value = savedAnalysis;
|
|
||||||
} else {
|
|
||||||
if ([1, 2, 3].includes(type.value ?? -1)) {
|
|
||||||
customOptions.value = options.value.map((option) => ({ key: option, value: '' }));
|
|
||||||
} else {
|
|
||||||
customOptions.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
showModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.analysis-container {
|
|
||||||
/* Using UnoCSS classes in template */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
|
|
||||||
<template>
|
|
||||||
<n-modal v-model:show="showModal" preset="card" title="添加题目" class="w-600px" :bordered="false">
|
|
||||||
<n-form ref="addFormRef" :model="form" :rules="rules" label-placement="left" label-width="80px" require-mark-placement="right-hanging">
|
|
||||||
<div class="flex flex-row">
|
|
||||||
<label class="w-80px text-right pr-12px pt-5px">题号</label>
|
|
||||||
<div class="grid grid-cols-2 gap-x-10px w-full">
|
|
||||||
<n-form-item label="" path="pageNumb" :show-label="false">
|
|
||||||
<n-input-number v-model:value="form.pageNumb" placeholder="页码" class="w-full text-center" :min="1">
|
|
||||||
<template #prefix>第</template>
|
|
||||||
<template #suffix>页</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="" path="bigTpicNumb" :show-label="false">
|
|
||||||
<n-input-number v-model:value="form.bigTpicNumb" placeholder="大题号" class="w-full text-center" :min="1">
|
|
||||||
<template #prefix>第</template>
|
|
||||||
<template #suffix>大题</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="" path="smallTpicNumb" :show-label="false">
|
|
||||||
<n-input-number v-model:value="form.smallTpicNumb" placeholder="小题号" class="w-full text-center" :min="1">
|
|
||||||
<template #prefix>第</template>
|
|
||||||
<template #suffix>小题</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="" path="subTopicNumb" :show-label="false">
|
|
||||||
<n-input-number v-model:value="form.subTopicNumb" placeholder="第几部分" class="w-full text-center" :min="0">
|
|
||||||
<template #prefix>第</template>
|
|
||||||
<template #suffix>部分</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-show="showBoot" class="grid grid-cols-2 gap-x-10px">
|
|
||||||
<n-form-item label="类型" path="type">
|
|
||||||
<n-select v-model:value="form.type" :options="topicTypeList" label-field="label" value-field="value" placeholder="请选择题目类型" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="学科" path="subject">
|
|
||||||
<n-select
|
|
||||||
v-model:value="form.subject"
|
|
||||||
filterable
|
|
||||||
remote
|
|
||||||
:options="subjectOptions"
|
|
||||||
placeholder="请选择学科"
|
|
||||||
:loading="subjectLoading"
|
|
||||||
label-field="name"
|
|
||||||
value-field="id"
|
|
||||||
@search="handleSubjectSearch"
|
|
||||||
@focus="() => handleSubjectSearch('')"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="分数" path="score">
|
|
||||||
<n-input-number v-model:value="form.score" placeholder="请输入分数" class="w-full text-center" :min="0">
|
|
||||||
<template #suffix>分</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="答题时间" path="answerTime">
|
|
||||||
<n-input-number v-model:value="form.answerTime" placeholder="请输入答题时间" class="w-full text-center" :min="0">
|
|
||||||
<template #suffix>秒</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-25px">
|
|
||||||
<n-button @click="showModal = false" class="mr-10px">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="affirmLoading" :disabled="affirmLoading" @click="handleAffirm">提交</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch, useTemplateRef } from 'vue';
|
|
||||||
import { NModal, NForm, NFormItem, NInputNumber, NSelect, NButton, useMessage } from 'naive-ui';
|
|
||||||
import type { FormRules } from 'naive-ui';
|
|
||||||
import { defQuestionInfo, topicTypeList } from '@/views/template/template-detail/def-data';
|
|
||||||
import type { CurrBookPageAllInfo, QuestionInfo } from '../../../../types';
|
|
||||||
import { type BookPageQuestion, type BookPageQuestionAddInput, addBookQuestionAxios, fetchSubjectList } from '@/service/api/book';
|
|
||||||
import { getPDFPageWidth } from '@/views/template/template-detail/util';
|
|
||||||
|
|
||||||
export type _AddBookTopicForm = {
|
|
||||||
type?: BookPageQuestion['type'];
|
|
||||||
/** 页码 */
|
|
||||||
pageNumb?: number;
|
|
||||||
/** 大题编号 */
|
|
||||||
bigTpicNumb?: number;
|
|
||||||
/** 小题编号 */
|
|
||||||
smallTpicNumb?: number;
|
|
||||||
/** 子题目编号 */
|
|
||||||
subTopicNumb?: number;
|
|
||||||
/** 分数 */
|
|
||||||
score?: number;
|
|
||||||
/** 学科 */
|
|
||||||
subject?: number; // Changed to ID for NSelect
|
|
||||||
/** 答题时间 */
|
|
||||||
answerTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShowData = {
|
|
||||||
eventElement: MouseEvent | HTMLElement;
|
|
||||||
currBookPage: CurrBookPageAllInfo;
|
|
||||||
/** 添加目录成功时回调 */
|
|
||||||
affirmCallBack?: (form: Required<any>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFormRef = useTemplateRef('addFormRef');
|
|
||||||
const affirmLoading = ref(false);
|
|
||||||
const message = useMessage();
|
|
||||||
const showModal = ref(false);
|
|
||||||
|
|
||||||
// Subject Search
|
|
||||||
const subjectOptions = ref<any[]>([]);
|
|
||||||
const subjectLoading = ref(false);
|
|
||||||
|
|
||||||
let currBookPageData: CurrBookPageAllInfo | undefined = undefined;
|
|
||||||
|
|
||||||
const form = ref<_AddBookTopicForm>({
|
|
||||||
type: undefined,
|
|
||||||
pageNumb: undefined,
|
|
||||||
bigTpicNumb: undefined,
|
|
||||||
smallTpicNumb: undefined,
|
|
||||||
subTopicNumb: 0,
|
|
||||||
score: 2,
|
|
||||||
subject: undefined,
|
|
||||||
answerTime: 60,
|
|
||||||
});
|
|
||||||
const showBoot = ref(true);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => form.value.subTopicNumb,
|
|
||||||
(val) => {
|
|
||||||
if (!val || val <= 1) {
|
|
||||||
showBoot.value = true;
|
|
||||||
} else {
|
|
||||||
showBoot.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const rules = computed<FormRules>(() => {
|
|
||||||
const _rules: FormRules = {
|
|
||||||
pageNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入页码', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
bigTpicNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入大题号', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
smallTpicNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入小题号', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
subTopicNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入第几部分', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
if ((form.value.subTopicNumb || 0) < 2) {
|
|
||||||
_rules.type = [{ required: true, type: 'number', message: '请选择题目类型', trigger: ['change', 'blur'] }];
|
|
||||||
_rules.score = [
|
|
||||||
{ required: true, type: 'number', message: '请输入分数', trigger: ['input', 'blur'] },
|
|
||||||
];
|
|
||||||
_rules.subject = [{ required: true, type: 'number', message: '请选择学科', trigger: ['change', 'blur'] }];
|
|
||||||
}
|
|
||||||
return _rules;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSubjectSearch(query: string) {
|
|
||||||
subjectLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await fetchSubjectList({ page: 1, limit: 20, name: query });
|
|
||||||
subjectOptions.value = res.records || [];
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
subjectLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(data: ShowData) {
|
|
||||||
currBookPageData = data.currBookPage;
|
|
||||||
showModal.value = true;
|
|
||||||
// Reset form or set defaults if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交
|
|
||||||
*/
|
|
||||||
async function handleAffirm() {
|
|
||||||
if (!showBoot.value) {
|
|
||||||
form.value.type = undefined;
|
|
||||||
form.value.subject = undefined;
|
|
||||||
form.value.score = 0;
|
|
||||||
form.value.answerTime = 60;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await addFormRef.value?.validate();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('请检查表单数据是否正确');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
affirmLoading.value = true;
|
|
||||||
const no = `${form.value.pageNumb!}-${form.value.bigTpicNumb!}-${form.value.smallTpicNumb!}-${form.value.subTopicNumb}`;
|
|
||||||
|
|
||||||
await addBookQuestionAxios({
|
|
||||||
bookId: currBookPageData!.currBookPageData.bookId!,
|
|
||||||
bookPageId: currBookPageData!.currBookPageData.bookPageId!,
|
|
||||||
no,
|
|
||||||
type: form.value.type!,
|
|
||||||
score: form.value.score!,
|
|
||||||
subjectId: form.value.subject,
|
|
||||||
answerTime: form.value.answerTime,
|
|
||||||
} satisfies BookPageQuestionAddInput);
|
|
||||||
|
|
||||||
const pageWidth = getPDFPageWidth(currBookPageData?.bookBaseData.width || 0);
|
|
||||||
|
|
||||||
const len = currBookPageData!.currBookPageData.layout.length;
|
|
||||||
const lastQuestion = len > 0 ? currBookPageData!.currBookPageData.layout[len - 1]?.question : undefined;
|
|
||||||
const defY = lastQuestion ? lastQuestion.y + lastQuestion.h : 0;
|
|
||||||
const question: QuestionInfo = defQuestionInfo(no, form.value.type!, defY, pageWidth);
|
|
||||||
|
|
||||||
// We need to emit or callback to add the question to the list.
|
|
||||||
// The original code seemed to do more but it was cut off.
|
|
||||||
// Assuming we just refresh or the parent handles it via event bus or we modify the prop directly (not recommended but seen in this codebase).
|
|
||||||
// Actually, in right-topic-list-normal, it just opens this bubble.
|
|
||||||
// We probably should emit an event or update the local state.
|
|
||||||
// But for now, let's just close the modal and show success.
|
|
||||||
|
|
||||||
message.success('添加成功');
|
|
||||||
showModal.value = false;
|
|
||||||
|
|
||||||
// Need to refresh or add to list.
|
|
||||||
// The original code was updating `currBookPage!.currBookPageData.layout`.
|
|
||||||
// I'll assume we should do that here too if we want immediate feedback.
|
|
||||||
// But I don't have the full original code for `handleAffirm`.
|
|
||||||
// I'll reload the page logic or trigger a reload event.
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.msg || '添加失败');
|
|
||||||
} finally {
|
|
||||||
affirmLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
@ -1,458 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-drawer v-model:show="drawer" :width="800" destroy-on-close>
|
|
||||||
<n-drawer-content :native-scrollbar="false" :body-content-style="{ padding: '0' }">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between w-full">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-16px font-bold text-[#333]">编辑信息</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
round
|
|
||||||
:loading="submitLoading"
|
|
||||||
:disabled="submitLoading || importImgLoading"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-cloud-upload-linear />
|
|
||||||
</template>
|
|
||||||
提交
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="pageLoad.loadState === false" class="p-4">
|
|
||||||
<n-form
|
|
||||||
ref="formRef"
|
|
||||||
:model="formData"
|
|
||||||
:rules="rules"
|
|
||||||
label-placement="left"
|
|
||||||
label-width="80px"
|
|
||||||
require-mark-placement="right-hanging"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-16px font-bold mb-4 border-l-4 border-primary pl-2">基本信息</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<n-form-item label="学科" path="subjectId">
|
|
||||||
<n-select
|
|
||||||
v-model:value="formData.subjectId"
|
|
||||||
:options="subjectList"
|
|
||||||
label-field="label"
|
|
||||||
value-field="value"
|
|
||||||
placeholder="请选择学科"
|
|
||||||
@update:value="subjectIdChange"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="知识点" path="knowledgepointIds">
|
|
||||||
<n-tree-select
|
|
||||||
v-model:value="formData.knowledgepointIds"
|
|
||||||
:disabled="!formData.subjectId || formData.subjectId < 0"
|
|
||||||
:options="knowledgePointList"
|
|
||||||
key-field="id"
|
|
||||||
label-field="name"
|
|
||||||
children-field="childs"
|
|
||||||
multiple
|
|
||||||
cascade
|
|
||||||
checkable
|
|
||||||
placeholder="请选择知识点"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="题型" path="type">
|
|
||||||
<n-select
|
|
||||||
v-model:value="formData.type"
|
|
||||||
:options="topicTypeList"
|
|
||||||
label-field="label"
|
|
||||||
value-field="value"
|
|
||||||
placeholder="请选择题型"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item v-if="[1, 2].includes(formData.type || 0)" label="可选项" path="options">
|
|
||||||
<n-select
|
|
||||||
v-model:value="formData.options"
|
|
||||||
multiple
|
|
||||||
:options="answerOptionsList"
|
|
||||||
label-field="label"
|
|
||||||
value-field="value"
|
|
||||||
placeholder="请选择可选项"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item v-if="formData.type === 1" label="正确答案" path="singleChoiceAnswer">
|
|
||||||
<n-select
|
|
||||||
v-model:value="formData.singleChoiceAnswer"
|
|
||||||
:options="optionsList"
|
|
||||||
label-field="label"
|
|
||||||
value-field="value"
|
|
||||||
placeholder="请选择正确答案"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item v-if="formData.type === 2" label="正确答案" path="multiChoiceAnswer">
|
|
||||||
<n-select
|
|
||||||
v-model:value="formData.multiChoiceAnswer"
|
|
||||||
multiple
|
|
||||||
:options="optionsList"
|
|
||||||
label-field="label"
|
|
||||||
value-field="value"
|
|
||||||
placeholder="请选择正确答案"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item v-if="formData.type === 3" label="正确答案" path="trueOrFalseAnswer">
|
|
||||||
<n-radio-group v-model:value="formData.trueOrFalseAnswer">
|
|
||||||
<n-radio :value="1">正确</n-radio>
|
|
||||||
<n-radio :value="0">错误</n-radio>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="题号" path="no">
|
|
||||||
<n-input v-model:value="formData.no" placeholder="请输入题号" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="分数" path="score">
|
|
||||||
<n-input-number v-model:value="formData.score" :precision="1" placeholder="请输入分数" class="w-full" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="答题时间" path="answerTime">
|
|
||||||
<n-input-number v-model:value="formData.answerTime" :precision="0" placeholder="请输入答题时间" class="w-full">
|
|
||||||
<template #suffix>秒</template>
|
|
||||||
</n-input-number>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="讲解视频" path="videourl">
|
|
||||||
<rest-upload
|
|
||||||
v-model="videoInfo"
|
|
||||||
:accept="['.mp4']"
|
|
||||||
:custom-keys="{ bindFileUrlKey: 'url', bindPosterUrlKey: 'poster' }"
|
|
||||||
:width="160"
|
|
||||||
:height="90"
|
|
||||||
hint="上传讲解视频"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-divider />
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-16px font-bold mb-4 border-l-4 border-primary pl-2">题目</h3>
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-14px font-bold mb-2">题目</div>
|
|
||||||
<n-form-item label="" path="question" :show-label="false">
|
|
||||||
<rest-basic-editor v-model="formData.question" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="![1, 2, 3].includes(formData.type || 0)" class="mb-4">
|
|
||||||
<div class="text-14px font-bold mb-2">答案</div>
|
|
||||||
<n-form-item label="" path="textAnswer" :show-label="false">
|
|
||||||
<rest-basic-editor v-model="formData.textAnswer" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-14px font-bold mb-2">解析</div>
|
|
||||||
<n-form-item label="" path="analysis" :show-label="false">
|
|
||||||
<rest-basic-editor v-model="formData.analysis" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="pageLoad.loadState === 'err'" class="flex justify-center items-center h-full">
|
|
||||||
<n-empty :description="pageLoad.description">
|
|
||||||
<template #extra>
|
|
||||||
<n-button @click="getBaseDataAxios">重试</n-button>
|
|
||||||
</template>
|
|
||||||
</n-empty>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex justify-center items-center h-full">
|
|
||||||
<n-spin size="large" />
|
|
||||||
</div>
|
|
||||||
</n-drawer-content>
|
|
||||||
</n-drawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, shallowRef, shallowReactive, watch, computed, nextTick } from 'vue';
|
|
||||||
import { useMessage } from 'naive-ui';
|
|
||||||
import type { FormInst, FormRules } from 'naive-ui';
|
|
||||||
import RestBasicEditor from '@/components/common/rest-basic-editor/rest-basic-editor.vue';
|
|
||||||
import RestUpload from '@/components/common/rest-upload/rest-upload.vue';
|
|
||||||
import {
|
|
||||||
type BookPageQuestion,
|
|
||||||
type BookPageQuestionUpdateInput,
|
|
||||||
editQuestionInfoAxios,
|
|
||||||
getQuestionInfoAxios,
|
|
||||||
getQuestionBankNavAxios,
|
|
||||||
getKnowledgePointDetailsAxios
|
|
||||||
} from '@/service/api/book';
|
|
||||||
import { fileToBase64, getFirstFrameOfVideo } from '@/utils/file';
|
|
||||||
import { myStrToJson } from '@/utils/data';
|
|
||||||
import { topicTypeList } from '@/views/template/template-detail/def-data';
|
|
||||||
import type { KeyValue, TopicInfo } from '@/views/template/template-detail/types';
|
|
||||||
|
|
||||||
// Mock data for answerOptionsList since it was imported from aa-util/def-data
|
|
||||||
const answerOptionsList = [
|
|
||||||
{ label: 'A', value: 'A' },
|
|
||||||
{ label: 'B', value: 'B' },
|
|
||||||
{ label: 'C', value: 'C' },
|
|
||||||
{ label: 'D', value: 'D' },
|
|
||||||
{ label: 'E', value: 'E' },
|
|
||||||
{ label: 'F', value: 'F' },
|
|
||||||
{ label: 'G', value: 'G' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface KnowledgePointOutput {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
childs?: KnowledgePointOutput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormData = Omit<BookPageQuestionUpdateInput, 'analysisUrl' | 'answer' | 'answerUrl' | 'knowledgepointIds' | 'options' | 'questionUrl'> & {
|
|
||||||
knowledgepointIds: number[];
|
|
||||||
singleChoiceAnswer: string;
|
|
||||||
multiChoiceAnswer: string[];
|
|
||||||
trueOrFalseAnswer?: 0 | 1;
|
|
||||||
textAnswer: string;
|
|
||||||
options: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
const drawer = ref(false);
|
|
||||||
const submitLoading = ref(false);
|
|
||||||
const importImgLoading = ref(false);
|
|
||||||
const formRef = ref<FormInst | null>(null);
|
|
||||||
|
|
||||||
let topicInfo: TopicInfo | undefined = undefined;
|
|
||||||
|
|
||||||
const videoInfo = ref<{ url: string; poster: string } | undefined>(undefined);
|
|
||||||
const subjectList = shallowRef<KeyValue[]>([]);
|
|
||||||
const pageLoad = shallowReactive<{ loadState: boolean | 'err'; description: string }>({ loadState: true, description: '' });
|
|
||||||
const optionsList = ref<typeof answerOptionsList>([]);
|
|
||||||
const knowledgePointList = shallowRef<KnowledgePointOutput[]>([]);
|
|
||||||
|
|
||||||
const formData = ref<FormData>({
|
|
||||||
bookId: -1,
|
|
||||||
bookPageId: -1,
|
|
||||||
id: -1,
|
|
||||||
no: undefined,
|
|
||||||
question: undefined,
|
|
||||||
score: undefined,
|
|
||||||
analysis: undefined,
|
|
||||||
type: 1,
|
|
||||||
subjectId: undefined,
|
|
||||||
knowledgepointIds: [],
|
|
||||||
options: [],
|
|
||||||
singleChoiceAnswer: '',
|
|
||||||
multiChoiceAnswer: [],
|
|
||||||
trueOrFalseAnswer: undefined,
|
|
||||||
textAnswer: '',
|
|
||||||
answerTime: undefined,
|
|
||||||
videourl: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => videoInfo.value,
|
|
||||||
(val) => {
|
|
||||||
if (val) {
|
|
||||||
formData.value.videourl = val.url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => formData.value.type,
|
|
||||||
() => {
|
|
||||||
formData.value.singleChoiceAnswer = '';
|
|
||||||
formData.value.multiChoiceAnswer = [];
|
|
||||||
formData.value.trueOrFalseAnswer = undefined;
|
|
||||||
formData.value.textAnswer = '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => formData.value.options,
|
|
||||||
(val) => {
|
|
||||||
optionsList.value = answerOptionsList.filter((item) => val.includes(item.value));
|
|
||||||
formData.value.singleChoiceAnswer = '';
|
|
||||||
formData.value.multiChoiceAnswer = [];
|
|
||||||
formData.value.trueOrFalseAnswer = undefined;
|
|
||||||
formData.value.textAnswer = '';
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const rules = computed<FormRules>(() => {
|
|
||||||
const _rules: FormRules = {
|
|
||||||
subjectId: [{ required: true, type: 'number', message: '请选择学科', trigger: ['blur', 'change'] }],
|
|
||||||
knowledgepointIds: [{ required: true, type: 'array', message: '知识点不能为空', trigger: ['blur', 'change'] }],
|
|
||||||
type: [{ required: true, type: 'number', message: '题型不能为空', trigger: ['blur', 'change'] }],
|
|
||||||
no: [{ required: true, type: 'string', message: '题号不能为空', trigger: ['blur', 'change'] }],
|
|
||||||
score: [{ required: true, type: 'number', message: '分数不能为空', trigger: ['blur', 'change'] }],
|
|
||||||
answerTime: [{ required: true, type: 'number', message: '答题时间不能为空', trigger: ['blur', 'change'] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if ([1, 2].includes(formData.value.type || 0)) {
|
|
||||||
_rules.options = [
|
|
||||||
{ required: true, type: 'array', message: '答案选项不能为空', trigger: ['blur', 'change'] },
|
|
||||||
// min length validation in naive ui requires custom validator usually, but array min length works in async-validator
|
|
||||||
{ type: 'array', min: 2, message: '最少需要2个答案选项', trigger: ['blur', 'change'] },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.value.type === 1) {
|
|
||||||
_rules.singleChoiceAnswer = [{ required: true, type: 'string', message: '请选择单选题答案', trigger: ['blur', 'change'] }];
|
|
||||||
} else if (formData.value.type === 2) {
|
|
||||||
_rules.multiChoiceAnswer = [{ required: true, type: 'array', min: 1, message: '请选择多选题答案', trigger: ['blur', 'change'] }];
|
|
||||||
} else if (formData.value.type === 3) {
|
|
||||||
_rules.trueOrFalseAnswer = [
|
|
||||||
{ required: true, type: 'number', message: '请选择判断题答案', trigger: ['blur', 'change'] },
|
|
||||||
];
|
|
||||||
} else if (![1, 2, 3].includes(formData.value.type || 0)) {
|
|
||||||
_rules.textAnswer = [{ required: true, type: 'string', message: '请输入答案', trigger: ['blur', 'change'] }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return _rules;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadKnowledgePointList() {
|
|
||||||
if (!formData.value.subjectId) return;
|
|
||||||
try {
|
|
||||||
const res = await getKnowledgePointDetailsAxios({ subjectId: formData.value.subjectId });
|
|
||||||
knowledgePointList.value = res || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBaseDataAxios() {
|
|
||||||
try {
|
|
||||||
pageLoad.loadState = true;
|
|
||||||
const [data, questionBankNav] = await Promise.all([getQuestionInfoAxios(topicInfo!.questionId!), getQuestionBankNavAxios()]);
|
|
||||||
formData.value.bookId = data.bookId!;
|
|
||||||
formData.value.bookPageId = data.bookPageId!;
|
|
||||||
formData.value.id = data.id!;
|
|
||||||
formData.value.no = data.no;
|
|
||||||
formData.value.question = data.question || '';
|
|
||||||
formData.value.score = data.score || undefined;
|
|
||||||
formData.value.analysis = data.analysis || '';
|
|
||||||
formData.value.type = data.type || 1;
|
|
||||||
formData.value.answerTime = data.answerTime || undefined;
|
|
||||||
formData.value.knowledgepointIds = data.knowledgePointIds || [];
|
|
||||||
formData.value.subjectId = data.subjectId || undefined;
|
|
||||||
formData.value.videourl = data.videoUrl || '';
|
|
||||||
|
|
||||||
if (data.videoUrl) {
|
|
||||||
let poster = '';
|
|
||||||
try {
|
|
||||||
const posterInfo = await getFirstFrameOfVideo(data.videoUrl);
|
|
||||||
poster = (await fileToBase64(posterInfo.firstFrame)).img;
|
|
||||||
} catch (error) {}
|
|
||||||
videoInfo.value = { url: data.videoUrl, poster };
|
|
||||||
} else {
|
|
||||||
videoInfo.value = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadKnowledgePointList();
|
|
||||||
formData.value.options = myStrToJson<string[]>(data.options || '[]') || [];
|
|
||||||
subjectList.value = questionBankNav.subjects || [];
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
if (formData.value.type === 1) {
|
|
||||||
formData.value.singleChoiceAnswer = (myStrToJson<string[]>(data.answer || '[]') || [])[0] || '';
|
|
||||||
} else if (formData.value.type === 2) {
|
|
||||||
formData.value.multiChoiceAnswer = myStrToJson<string[]>(data.answer || '[]') || [];
|
|
||||||
} else if (formData.value.type === 3) {
|
|
||||||
const _answer = data.answer ?? undefined;
|
|
||||||
formData.value.trueOrFalseAnswer = _answer !== undefined ? (parseInt(_answer) === 1 ? 1 : 0) : undefined;
|
|
||||||
} else if (![1, 2, 3].includes(formData.value.type || 0)) {
|
|
||||||
formData.value.textAnswer = data.answer ?? '';
|
|
||||||
}
|
|
||||||
pageLoad.loadState = false;
|
|
||||||
} catch (error: any) {
|
|
||||||
pageLoad.loadState = 'err';
|
|
||||||
pageLoad.description = error.message || '系统错误';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function subjectIdChange() {
|
|
||||||
formData.value.knowledgepointIds = [];
|
|
||||||
loadKnowledgePointList();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
try {
|
|
||||||
await formRef.value?.validate();
|
|
||||||
} catch (error) {
|
|
||||||
message.warning('请完善表单');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let answer = '';
|
|
||||||
if (formData.value.type === 1) {
|
|
||||||
answer = formData.value.singleChoiceAnswer || '';
|
|
||||||
answer = answer ? JSON.stringify([answer]) : '[]';
|
|
||||||
} else if (formData.value.type === 2) {
|
|
||||||
answer = JSON.stringify(formData.value.multiChoiceAnswer || []);
|
|
||||||
} else if (formData.value.type === 3) {
|
|
||||||
answer = formData.value.trueOrFalseAnswer === 1 ? '1' : '0';
|
|
||||||
} else if (![1, 2, 3].includes(formData.value.type || 0)) {
|
|
||||||
answer = formData.value.textAnswer || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
submitLoading.value = true;
|
|
||||||
const data: BookPageQuestionUpdateInput = {
|
|
||||||
analysis: formData.value.analysis,
|
|
||||||
answer,
|
|
||||||
bookId: formData.value.bookId,
|
|
||||||
bookPageId: formData.value.bookPageId,
|
|
||||||
id: formData.value.id,
|
|
||||||
options: JSON.stringify(formData.value.options || []),
|
|
||||||
subjectId: formData.value.subjectId,
|
|
||||||
knowledgepointIds: formData.value.knowledgepointIds,
|
|
||||||
no: formData.value.no,
|
|
||||||
question: formData.value.question,
|
|
||||||
score: formData.value.score,
|
|
||||||
type: formData.value.type,
|
|
||||||
answerTime: formData.value.answerTime,
|
|
||||||
videoUrl: formData.value.videourl,
|
|
||||||
};
|
|
||||||
|
|
||||||
await editQuestionInfoAxios(data);
|
|
||||||
message.success('修改成功');
|
|
||||||
drawer.value = false;
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.msg || '修改失败');
|
|
||||||
} finally {
|
|
||||||
submitLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(data: { topicInfo: TopicInfo }) {
|
|
||||||
topicInfo = data.topicInfo;
|
|
||||||
drawer.value = true;
|
|
||||||
getBaseDataAxios();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
drawer.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.n-input-number .n-input__input-el) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-modal v-model:show="showModal" preset="card" title="发布任务" class="w-400px" :bordered="false">
|
|
||||||
<div class="p-4">
|
|
||||||
<n-form ref="formRef" :model="form" :rules="rules">
|
|
||||||
<n-form-item label="接收人" path="operatorId">
|
|
||||||
<n-select
|
|
||||||
v-model:value="form.operatorId"
|
|
||||||
filterable
|
|
||||||
remote
|
|
||||||
:options="operatorOptions"
|
|
||||||
:loading="loading"
|
|
||||||
placeholder="请选择接收人"
|
|
||||||
label-field="name"
|
|
||||||
value-field="id"
|
|
||||||
@search="handleSearch"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 mt-4">
|
|
||||||
<n-button @click="hide">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="affirmLoading" @click="handleAffirm">提交</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
</div>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useMessage } from 'naive-ui';
|
|
||||||
import type { FormInst, FormRules } from 'naive-ui';
|
|
||||||
import { type BookReviewInput, releaseTaskAxios, fetchBookCategoryList } from '@/service/api/book';
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
const showModal = ref(false);
|
|
||||||
const formRef = ref<FormInst | null>(null);
|
|
||||||
const affirmLoading = ref(false);
|
|
||||||
const loading = ref(false);
|
|
||||||
const operatorOptions = ref<any[]>([]);
|
|
||||||
|
|
||||||
let releaseTaskData: BookReviewInput | undefined = undefined;
|
|
||||||
|
|
||||||
const form = ref<BookReviewInput>({
|
|
||||||
operatorId: undefined,
|
|
||||||
bookId: 0, // Default values to satisfy type, will be overwritten
|
|
||||||
bookPageId: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules: FormRules = {
|
|
||||||
operatorId: [{ required: true, type: 'number', message: '请选择操作人', trigger: ['change', 'blur'] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleSearch(query: string) {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await fetchBookCategoryList({ name: query });
|
|
||||||
operatorOptions.value = res || []; // Adjust based on actual API response structure (res.records or res)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAffirm() {
|
|
||||||
try {
|
|
||||||
await formRef.value?.validate();
|
|
||||||
} catch (error) {
|
|
||||||
message.warning('请检查表单数据是否正确');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
affirmLoading.value = true;
|
|
||||||
await releaseTaskAxios({ ...releaseTaskData!, ...form.value });
|
|
||||||
message.success('操作成功');
|
|
||||||
hide();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('error====', error);
|
|
||||||
message.error(error.msg || '操作失败');
|
|
||||||
} finally {
|
|
||||||
affirmLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(data: { releaseTaskData?: BookReviewInput }) {
|
|
||||||
releaseTaskData = data.releaseTaskData;
|
|
||||||
showModal.value = true;
|
|
||||||
handleSearch(''); // Load initial list
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
showModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full transition-all duration-300 hover:shadow-light group right-topic-item">
|
|
||||||
<div class="flex items-center justify-between p-15px pr-10px bg-white rounded-4px right-topic-sub-item">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex items-center justify-center mr-6px text-12px text-primary select-none serial-number">
|
|
||||||
<span>{{ topicInfo.question.no }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-center px-8px py-4px ml-4px text-12px font-bold leading-1em text-primary bg-primary/10 rounded-4px select-none type">
|
|
||||||
<span>{{ showType }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center action-box">
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
text
|
|
||||||
class="ml-2px text-18px"
|
|
||||||
:loading="analysisLoading"
|
|
||||||
:disabled="analysisLoading"
|
|
||||||
@click="openItemAnalysis"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-checklist-minimalistic-linear />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
单项解析
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button type="primary" text class="ml-2px text-18px" @click="releaseTask">
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-bookmark-circle-outline />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
发布任务(录制讲解音视频)
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
text
|
|
||||||
class="ml-2px text-18px"
|
|
||||||
@click="() => emit('edit-topic', topicInfo)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-pen-2-linear />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
编辑或修改信息
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button type="success" text class="ml-2px text-18px" @click="addAnswer">
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-add-square-linear />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
点击添加答题区域
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button type="error" text class="ml-2px text-18px" @click="delTopic">
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-trash-bin-minimalistic-outline />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
删除题目
|
|
||||||
</n-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, defineProps, defineEmits, type PropType } from 'vue';
|
|
||||||
import { NButton, NTooltip, useDialog, useMessage } from 'naive-ui';
|
|
||||||
import IconSolarChecklistMinimalisticLinear from '~icons/solar/checklist-minimalistic-linear';
|
|
||||||
import IconSolarBookmarkCircleOutline from '~icons/solar/bookmark-circle-outline';
|
|
||||||
import IconSolarPen2Linear from '~icons/solar/pen-2-linear';
|
|
||||||
import IconSolarAddSquareLinear from '~icons/solar/add-square-linear';
|
|
||||||
import IconSolarTrashBinMinimalisticOutline from '~icons/solar/trash-bin-minimalistic-outline';
|
|
||||||
import type { CurrBookPageAllInfo, TopicInfo } from '@/views/template/template-detail/types';
|
|
||||||
import { defAnswerInfo, topicTypeList } from '@/views/template/template-detail/def-data';
|
|
||||||
import { type BookReviewInput, type QuestionFromInfo, delBookQuestionAxios, getQuestionInfoAxios, updateBookPageLayoutAxios } from '@/service/api/book';
|
|
||||||
import { myStrToJson } from '@/utils/data';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 题目 */
|
|
||||||
topicInfo: { type: Object as PropType<TopicInfo>, required: true },
|
|
||||||
/** 当前页码信息 */
|
|
||||||
currBookPage: { type: Object as PropType<CurrBookPageAllInfo>, default: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
/** 编辑题目 */
|
|
||||||
(e: 'edit-topic', topicInfo: TopicInfo): void;
|
|
||||||
/** 发布任务 */
|
|
||||||
(e: 'release-task', event: MouseEvent, data: BookReviewInput): void;
|
|
||||||
/** 添加单项解析区域 */
|
|
||||||
(e: 'add-analysis', event: MouseEvent, questionFromInfo: QuestionFromInfo): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const dialog = useDialog();
|
|
||||||
const message = useMessage();
|
|
||||||
const analysisLoading = ref(false);
|
|
||||||
const showType = computed(() => {
|
|
||||||
return topicTypeList.find((item) => item.value === props.topicInfo.question.type)?.label || '默认';
|
|
||||||
});
|
|
||||||
|
|
||||||
function delTopic() {
|
|
||||||
dialog.warning({
|
|
||||||
title: '温馨提示',
|
|
||||||
content: '删除后不可恢复,您确定删除吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await delBookQuestionAxios(props.topicInfo.questionId);
|
|
||||||
const copy = [...props.currBookPage!.currBookPageData.layout];
|
|
||||||
const index = [...props.currBookPage!.currBookPageData.layout].findIndex((item) => item.questionId === props.topicInfo.questionId);
|
|
||||||
copy.splice(index, 1);
|
|
||||||
await updateBookPageLayoutAxios({
|
|
||||||
id: props.currBookPage!.currBookPageData!.bookPageId!,
|
|
||||||
layout: JSON.stringify(copy),
|
|
||||||
});
|
|
||||||
props.currBookPage!.currBookPageData.layout.splice(index, 1);
|
|
||||||
message.success('删除成功');
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.msg || '删除失败,-BD004');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开单项解析
|
|
||||||
* @param event 鼠标事件
|
|
||||||
*/
|
|
||||||
async function openItemAnalysis(event: MouseEvent) {
|
|
||||||
try {
|
|
||||||
analysisLoading.value = true;
|
|
||||||
const res = await getQuestionInfoAxios(props.topicInfo!.questionId!);
|
|
||||||
const options = myStrToJson<string[]>(res.options || '[]') || [];
|
|
||||||
|
|
||||||
if ([1, 2, 3].includes(res.type ?? -1) && options.length <= 0) {
|
|
||||||
message.error('请先完善题目信息');
|
|
||||||
analysisLoading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emit('add-analysis', event, res);
|
|
||||||
analysisLoading.value = false;
|
|
||||||
} catch (error) {
|
|
||||||
analysisLoading.value = false;
|
|
||||||
message.error('获取数据失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加答题区域
|
|
||||||
*/
|
|
||||||
function addAnswer() {
|
|
||||||
const answerInfo = defAnswerInfo(props.topicInfo.question.x);
|
|
||||||
if (Array.isArray(props.topicInfo?.answerList)) {
|
|
||||||
// eslint-disable-next-line vue/no-mutating-props
|
|
||||||
props.topicInfo?.answerList?.push(answerInfo);
|
|
||||||
} else {
|
|
||||||
props.topicInfo!.answerList = [answerInfo];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发布任务
|
|
||||||
*/
|
|
||||||
function releaseTask(event: MouseEvent) {
|
|
||||||
emit('release-task', event, {
|
|
||||||
bookId: props.currBookPage!.currBookPageData!.bookId!,
|
|
||||||
bookPageId: props.currBookPage!.currBookPageData!.bookPageId!,
|
|
||||||
id: props.topicInfo.questionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.right-topic-item {
|
|
||||||
--my-color: #409eff; /* Naive UI primary color usually */
|
|
||||||
--my-background: #ecf5ff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col w-full">
|
|
||||||
<div class="box-border flex items-center justify-between p-15px mb-15px bg-gray-50 rounded-6px">
|
|
||||||
<span class="text-16px font-500 text-gray-600 select-none">普通题目</span>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
ghost
|
|
||||||
:disabled="!currBookPage"
|
|
||||||
@click="addTopic"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-solar-question-circle-outline />
|
|
||||||
</template>
|
|
||||||
添加题目
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col flex-1 w-full h-full max-h-full bg-gray-50 rounded-10px overflow-hidden">
|
|
||||||
<n-scrollbar
|
|
||||||
v-if="currBookPage && currBookPageLayout.length > 0"
|
|
||||||
class="flex-1 w-full h-full max-h-full bg-gray-50 rounded-10px"
|
|
||||||
content-style="height: auto; min-height: 100%"
|
|
||||||
>
|
|
||||||
<ul class="p-15px">
|
|
||||||
<li v-for="(topicInfo, topicInfoIndex) in currBookPageLayout" :key="topicInfoIndex" class="mb-10px">
|
|
||||||
<right-topic-item
|
|
||||||
:topicInfo="topicInfo"
|
|
||||||
:currBookPage="currBookPage"
|
|
||||||
@editTopic="editTopic"
|
|
||||||
@releaseTask="releaseTask"
|
|
||||||
@addAnalysis="openAddAnalysisBubble"
|
|
||||||
></right-topic-item>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</n-scrollbar>
|
|
||||||
<div v-else-if="!currBookPage" class="flex items-center justify-center h-auto min-h-full">
|
|
||||||
<n-empty description="请在目录中选择页码" class="bg-white" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="currBookPageLayout.length <= 0" class="flex items-center justify-center h-auto min-h-full">
|
|
||||||
<n-empty description="暂无数据" class="bg-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<add-topic-form-bubble ref="addTopicFormBubbleRef"></add-topic-form-bubble>
|
|
||||||
<edit-info-bubble ref="editInfoBubbleRef"></edit-info-bubble>
|
|
||||||
<release-task-bubble ref="releaseTaskBubbleRef"></release-task-bubble>
|
|
||||||
<add-analysis-bubble ref="addAnalysisBubbleRef"></add-analysis-bubble>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, defineProps, onMounted, useTemplateRef } from 'vue';
|
|
||||||
import { NButton, NScrollbar, NEmpty, useMessage } from 'naive-ui';
|
|
||||||
import IconSolarQuestionCircleOutline from '~icons/solar/question-circle-outline';
|
|
||||||
import RightTopicItem from './components/right-topic-item.vue';
|
|
||||||
import type { CurrBookPageAllInfo, TopicInfo } from '../../../../types';
|
|
||||||
import AddTopicFormBubble from './components/add-topic-form-bubble.vue';
|
|
||||||
import EditInfoBubble from './components/edit-info-bubble.vue';
|
|
||||||
import type { BookReviewInput, QuestionFromInfo } from '@/service/api/book';
|
|
||||||
import ReleaseTaskBubble from './components/release-task-bubble.vue';
|
|
||||||
import { $mitt, open_book_topic_edit } from '@/utils/event-bus';
|
|
||||||
import AddAnalysisBubble from './components/add-analysis-bubble.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 当前页码信息 */
|
|
||||||
currBookPage: { type: Object as PropType<CurrBookPageAllInfo>, default: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
$mitt.on(open_book_topic_edit, (data: any) => {
|
|
||||||
const topicInfo = currBookPageLayout.value.find((item) => item.questionId === data!.questionId);
|
|
||||||
topicInfo && editTopic(topicInfo);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const addTopicFormBubbleRef = useTemplateRef('addTopicFormBubbleRef');
|
|
||||||
const editInfoBubbleRef = useTemplateRef('editInfoBubbleRef');
|
|
||||||
const releaseTaskBubbleRef = useTemplateRef('releaseTaskBubbleRef');
|
|
||||||
const addAnalysisBubbleRef = useTemplateRef('addAnalysisBubbleRef');
|
|
||||||
|
|
||||||
const currBookPageLayout = computed(() => {
|
|
||||||
const list = props.currBookPage?.currBookPageData.layout || [];
|
|
||||||
return list.filter((item) => item.question.type && item.question.type < 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新增题目
|
|
||||||
*/
|
|
||||||
function addTopic(event: MouseEvent) {
|
|
||||||
if (!props.currBookPage) {
|
|
||||||
message.error('请先在目录中选择页码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTopicFormBubbleRef.value?.show({
|
|
||||||
eventElement: event,
|
|
||||||
currBookPage: props.currBookPage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseTask(event: MouseEvent, releaseTaskData: BookReviewInput) {
|
|
||||||
const currQuestion = props.currBookPage!.currBookPageData.questions.find((item) => item.id === releaseTaskData.id);
|
|
||||||
if (currQuestion?.assign) {
|
|
||||||
message.error('已发布任务,不能重复发布');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
releaseTaskBubbleRef.value?.show({
|
|
||||||
eventElement: event,
|
|
||||||
releaseTaskData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑题目
|
|
||||||
*/
|
|
||||||
function editTopic(topicInfo: TopicInfo) {
|
|
||||||
const questionId = topicInfo.questionId;
|
|
||||||
const questions = props.currBookPage!.currBookPageData.questions;
|
|
||||||
const questionIndex = questions.findIndex((item) => item.id === questionId);
|
|
||||||
|
|
||||||
if (props.currBookPage && questions && topicInfo && questionIndex >= 0) {
|
|
||||||
editInfoBubbleRef.value?.show({
|
|
||||||
questions,
|
|
||||||
topicInfo,
|
|
||||||
questionIndex,
|
|
||||||
currBookPage: props.currBookPage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddAnalysisBubble(event: MouseEvent, questionFromInfo: QuestionFromInfo) {
|
|
||||||
addAnalysisBubbleRef.value?.show({
|
|
||||||
eventElement: event,
|
|
||||||
questionFromInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<n-modal v-model:show="showModal" preset="card" title="添加题目" class="w-500px" :bordered="false">
|
|
||||||
<n-form ref="addFormRef" :model="form" :rules="rules" label-placement="left" label-width="auto">
|
|
||||||
<div class="flex flex-row items-center mb-4">
|
|
||||||
<label class="w-60px text-right mr-3 font-bold">题号</label>
|
|
||||||
<div class="grid grid-cols-3 gap-2 flex-1">
|
|
||||||
<n-form-item label="" path="pageNumb" :show-label="false">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input-group-label>第</n-input-group-label>
|
|
||||||
<n-input-number v-model:value="form.pageNumb" :min="1" placeholder="页码" class="text-center" :show-button="false" />
|
|
||||||
<n-input-group-label>页</n-input-group-label>
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="" path="bigTpicNumb" :show-label="false">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input-group-label>第</n-input-group-label>
|
|
||||||
<n-input-number v-model:value="form.bigTpicNumb" :min="1" placeholder="题号" class="text-center" :show-button="false" />
|
|
||||||
<n-input-group-label>题</n-input-group-label>
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="" path="subTopicNumb" :show-label="false">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input-group-label>第</n-input-group-label>
|
|
||||||
<n-input-number v-model:value="form.subTopicNumb" :min="0" placeholder="第几部分" class="text-center" :show-button="false" />
|
|
||||||
<n-input-group-label>部分</n-input-group-label>
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 gap-2">
|
|
||||||
<n-form-item label="类型" path="type">
|
|
||||||
<n-select
|
|
||||||
v-model:value="form.type"
|
|
||||||
:options="topicType2List"
|
|
||||||
placeholder="请选择题目类型"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end w-full mt-6">
|
|
||||||
<n-button @click="hide" class="mr-3">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="affirmLoading" :disabled="affirmLoading" @click="handleAffirm">提交</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, useTemplateRef } from 'vue';
|
|
||||||
import { useMessage } from 'naive-ui';
|
|
||||||
import type { FormInst, FormRules } from 'naive-ui';
|
|
||||||
import { defAnswerInfo, defQuestionInfo, topicType2List } from '@/views/template/template-detail/def-data';
|
|
||||||
import type { AddBookTopicForm, AnswerInfo, CurrBookPageAllInfo, QuestionInfo, TopicInfo, BookPageOtherAddInput, BookPageOtherUpdateInput, BookPageQuestion } from '@/views/template/template-detail/types';
|
|
||||||
import {
|
|
||||||
addBookAreaAxios,
|
|
||||||
updateBookAreaAxios,
|
|
||||||
updateBookPageLayoutAxios,
|
|
||||||
} from '@/service/api/book';
|
|
||||||
import { getPDFPageWidth } from '@/views/template/template-detail/pdf-util';
|
|
||||||
import { compareVersion } from '@/utils/common'; // Assuming this exists or I should replace it.
|
|
||||||
// I'll assume compareVersion exists or I can implement a simple one if needed.
|
|
||||||
// But the user said "don't delete my commented code" and "use my project style".
|
|
||||||
// compareVersion is likely a utility. I'll check if it exists in @/utils/rest later.
|
|
||||||
// If not, I'll mock it or replace it.
|
|
||||||
// Actually, I should check if @/utils/rest exists.
|
|
||||||
|
|
||||||
export type _AddBookTopicForm = AddBookTopicForm;
|
|
||||||
|
|
||||||
type ShowData = {
|
|
||||||
eventElement: HTMLElement; // Simplified
|
|
||||||
currBookPage: CurrBookPageAllInfo;
|
|
||||||
/** 添加目录成功时回调 */
|
|
||||||
affirmCallBack?: (form: Required<AddBookTopicForm>) => void;
|
|
||||||
/** 题目信息 */
|
|
||||||
topicInfo?: TopicInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
/** 确认时回调 */
|
|
||||||
let affirmCallBack: ShowData['affirmCallBack'] | undefined = undefined;
|
|
||||||
let topicInfo: ShowData['topicInfo'] | undefined = undefined;
|
|
||||||
const addFormRef = useTemplateRef<FormInst>('addFormRef');
|
|
||||||
const affirmLoading = ref(false);
|
|
||||||
const showModal = ref(false);
|
|
||||||
|
|
||||||
let currBookPage: CurrBookPageAllInfo | undefined = undefined;
|
|
||||||
const isEdit = ref(false);
|
|
||||||
const form = ref<_AddBookTopicForm>({
|
|
||||||
type: undefined,
|
|
||||||
pageNumb: undefined,
|
|
||||||
bigTpicNumb: undefined,
|
|
||||||
subTopicNumb: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules: FormRules = {
|
|
||||||
pageNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入页码', trigger: ['input', 'blur'] },
|
|
||||||
{ type: 'number', min: 1, message: '不合法', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
bigTpicNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入大题号', trigger: ['input', 'blur'] },
|
|
||||||
{ type: 'number', min: 1, message: '不合法', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
subTopicNumb: [
|
|
||||||
{ required: true, type: 'number', message: '请输入第几部分', trigger: ['input', 'blur'] },
|
|
||||||
{ type: 'number', min: 0, message: '不合法', trigger: ['input', 'blur'] },
|
|
||||||
],
|
|
||||||
type: [{ required: true, type: 'number', message: '请选择题目类型', trigger: ['change', 'blur'] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交
|
|
||||||
*/
|
|
||||||
async function handleAffirm() {
|
|
||||||
try {
|
|
||||||
await addFormRef.value?.validate();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('请检查表单数据是否正确');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
affirmLoading.value = true;
|
|
||||||
const no = `${form.value.pageNumb!}-${form.value.bigTpicNumb!}-${form.value.subTopicNumb}`;
|
|
||||||
let newQquestionId = -1;
|
|
||||||
if (isEdit.value) {
|
|
||||||
await updateBookAreaAxios({
|
|
||||||
bookId: currBookPage!.currBookPageData.bookId!,
|
|
||||||
bookPageId: currBookPage!.currBookPageData.bookPageId!,
|
|
||||||
no,
|
|
||||||
type: form.value.type!,
|
|
||||||
id: topicInfo!.questionId,
|
|
||||||
} satisfies Required<BookPageOtherUpdateInput>);
|
|
||||||
newQquestionId = topicInfo!.questionId;
|
|
||||||
} else {
|
|
||||||
newQquestionId = await addBookAreaAxios({
|
|
||||||
bookId: currBookPage!.currBookPageData.bookId!,
|
|
||||||
bookPageId: currBookPage!.currBookPageData.bookPageId!,
|
|
||||||
no,
|
|
||||||
type: form.value.type!,
|
|
||||||
} satisfies Required<BookPageOtherAddInput>);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageWidth = getPDFPageWidth(currBookPage?.bookBaseData.width || 0);
|
|
||||||
|
|
||||||
const len = currBookPage!.currBookPageData.layout.length;
|
|
||||||
const lastQuestion = len > 0 ? currBookPage!.currBookPageData.layout[len - 1]?.question : undefined;
|
|
||||||
const defY = lastQuestion ? lastQuestion.y + lastQuestion.h : 0;
|
|
||||||
const question: QuestionInfo = defQuestionInfo(no, form.value.type!, defY, pageWidth);
|
|
||||||
const answerList: AnswerInfo[] = [defAnswerInfo(defY)];
|
|
||||||
const _topicInfo: TopicInfo = { question, answerList, questionId: newQquestionId };
|
|
||||||
await updateBookPageLayoutAxios({
|
|
||||||
id: currBookPage!.currBookPageData!.bookPageId!,
|
|
||||||
layout: JSON.stringify([...currBookPage!.currBookPageData.layout, _topicInfo]),
|
|
||||||
});
|
|
||||||
|
|
||||||
currBookPage!.currBookPageData.layout.push(_topicInfo);
|
|
||||||
currBookPage!.currBookPageData.layout = currBookPage!.currBookPageData.layout.sort((a, b) => {
|
|
||||||
// @ts-ignore
|
|
||||||
return compareVersion(a.question.no, b.question.no, '-');
|
|
||||||
});
|
|
||||||
|
|
||||||
const topicTypeInfo = topicType2List.find((item) => item.value === form.value.type!);
|
|
||||||
const showType = topicTypeInfo ? topicTypeInfo.label : '';
|
|
||||||
const _question: BookPageQuestion = {
|
|
||||||
id: newQquestionId,
|
|
||||||
no,
|
|
||||||
type: form.value.type!,
|
|
||||||
showType,
|
|
||||||
assign: false,
|
|
||||||
options: false,
|
|
||||||
analysis: false,
|
|
||||||
answers: false,
|
|
||||||
};
|
|
||||||
currBookPage!.currBookPageData.questions.push(_question);
|
|
||||||
currBookPage!.currBookPageData.questions = currBookPage!.currBookPageData.questions.sort((a, b) => {
|
|
||||||
// @ts-ignore
|
|
||||||
return compareVersion(a.no!, b.no!, '-');
|
|
||||||
});
|
|
||||||
|
|
||||||
affirmCallBack?.({ type: form.value.type!, pageNumb: form.value.pageNumb, bigTpicNumb: form.value.bigTpicNumb, subTopicNumb: form.value.subTopicNumb } satisfies Required<AddBookTopicForm>);
|
|
||||||
hide();
|
|
||||||
affirmLoading.value = false;
|
|
||||||
} catch (error: any) {
|
|
||||||
affirmLoading.value = false;
|
|
||||||
console.error('error====', error);
|
|
||||||
message.error(error.msg || '操作失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示
|
|
||||||
*/
|
|
||||||
function show(_data: ShowData) {
|
|
||||||
showModal.value = true;
|
|
||||||
isEdit.value = Boolean(_data.topicInfo && _data.topicInfo.questionId);
|
|
||||||
topicInfo = _data.topicInfo;
|
|
||||||
if (isEdit.value) {
|
|
||||||
const noArr = _data.topicInfo?.question?.no.split('-') ?? [];
|
|
||||||
form.value.pageNumb = noArr[0] ? Number(noArr[0]) : undefined;
|
|
||||||
form.value.bigTpicNumb = noArr[1] ? Number(noArr[1]) : undefined;
|
|
||||||
form.value.subTopicNumb = noArr[2] ? Number(noArr[2]) : 0;
|
|
||||||
const type = _data.topicInfo?.question?.type ?? undefined;
|
|
||||||
form.value.type = typeof type === 'number' && type >= 100 && type < 200 ? (type as 100 | 101 | 102) : undefined;
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
form.value.pageNumb = _data.currBookPage.currBookPageData.pageNum ?? undefined;
|
|
||||||
form.value.bigTpicNumb = undefined;
|
|
||||||
form.value.subTopicNumb = 0;
|
|
||||||
form.value.type = undefined;
|
|
||||||
}
|
|
||||||
currBookPage = _data.currBookPage;
|
|
||||||
affirmCallBack = 'affirmCallBack' in _data && typeof _data.affirmCallBack === 'function' ? _data.affirmCallBack : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏
|
|
||||||
*/
|
|
||||||
function hide() {
|
|
||||||
showModal.value = false;
|
|
||||||
}
|
|
||||||
defineExpose({
|
|
||||||
/** 显示 */
|
|
||||||
show,
|
|
||||||
/** 隐藏 */
|
|
||||||
hide,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="right-topic-item w-full transition-all duration-300 hover:shadow-light">
|
|
||||||
<div class="flex items-center justify-between p-3.5 pr-2.5 bg-white rounded-md">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex items-center justify-center mr-1.5 text-xs text-primary select-none">
|
|
||||||
<span>{{ topicInfo.question.no }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-center px-2 py-0.5 ml-1 text-10px font-normal leading-none text-primary select-none bg-primary-light border border-primary rounded-full">
|
|
||||||
<span>{{ showType }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
text
|
|
||||||
class="ml-0.5 text-18px"
|
|
||||||
@click="($event) => emit('edit-topic', $event, topicInfo)"
|
|
||||||
>
|
|
||||||
<template #icon><icon-solar-pen-2-linear /></template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
编辑或修改信息
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button
|
|
||||||
type="success"
|
|
||||||
text
|
|
||||||
class="ml-0.5 text-18px"
|
|
||||||
@click="addAnswer"
|
|
||||||
>
|
|
||||||
<template #icon><icon-solar-add-square-linear /></template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
点击添加区域
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button
|
|
||||||
type="error"
|
|
||||||
text
|
|
||||||
class="ml-0.5 text-18px"
|
|
||||||
@click="delTopic"
|
|
||||||
>
|
|
||||||
<template #icon><icon-solar-trash-bin-minimalistic-outline /></template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
删除题目
|
|
||||||
</n-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { useDialog, useMessage } from 'naive-ui';
|
|
||||||
import type { CurrBookPageAllInfo, TopicInfo } from '@/views/template/template-detail/types';
|
|
||||||
import { defAnswerInfo, topicType2List } from '@/views/template/template-detail/def-data';
|
|
||||||
import { delBookAreaAxios, updateBookPageLayoutAxios } from '@/service/api/book';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 题目 */
|
|
||||||
topicInfo: { type: Object as PropType<TopicInfo>, required: true },
|
|
||||||
/** 当前页码信息 */
|
|
||||||
currBookPage: { type: Object as PropType<CurrBookPageAllInfo>, default: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
/** 编辑题目 */
|
|
||||||
(e: 'edit-topic', event: MouseEvent, topicInfo: TopicInfo): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const dialog = useDialog();
|
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
const showType = computed(() => {
|
|
||||||
return topicType2List.find((item) => item.value === props.topicInfo.question.type)?.label || '默认';
|
|
||||||
});
|
|
||||||
|
|
||||||
function delTopic() {
|
|
||||||
const d = dialog.warning({
|
|
||||||
title: '温馨提示',
|
|
||||||
content: '删除后不可恢复,您确定删除吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
d.loading = true;
|
|
||||||
try {
|
|
||||||
await delBookAreaAxios(props.topicInfo.questionId);
|
|
||||||
const copy = [...props.currBookPage!.currBookPageData.layout];
|
|
||||||
const index = [...props.currBookPage!.currBookPageData.layout].findIndex((item) => item.questionId === props.topicInfo.questionId);
|
|
||||||
if (index > -1) {
|
|
||||||
copy.splice(index, 1);
|
|
||||||
await updateBookPageLayoutAxios({
|
|
||||||
id: props.currBookPage!.currBookPageData!.bookPageId!,
|
|
||||||
layout: JSON.stringify(copy),
|
|
||||||
});
|
|
||||||
props.currBookPage!.currBookPageData.layout.splice(index, 1);
|
|
||||||
message.success('删除成功');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.msg || '删除失败,-BD004');
|
|
||||||
} finally {
|
|
||||||
d.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加答题区域
|
|
||||||
*/
|
|
||||||
function addAnswer() {
|
|
||||||
const answerInfo = defAnswerInfo(props.topicInfo.question.x);
|
|
||||||
if (Array.isArray(props.topicInfo?.answerList)) {
|
|
||||||
// eslint-disable-next-line vue/no-mutating-props
|
|
||||||
props.topicInfo?.answerList?.push(answerInfo);
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line vue/no-mutating-props
|
|
||||||
props.topicInfo!.answerList = [answerInfo];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.text-primary {
|
|
||||||
color: var(--n-color-primary); /* Naive UI var or UnoCSS */
|
|
||||||
}
|
|
||||||
.bg-primary-light {
|
|
||||||
background-color: rgba(24, 160, 88, 0.1); /* Approximate Naive UI primary light */
|
|
||||||
}
|
|
||||||
.border-primary {
|
|
||||||
border-color: var(--n-color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="right-topic-list flex flex-col w-full h-full min-h-0">
|
|
||||||
<div class="head-box box-border flex items-center justify-between p-3.5 mb-3.5 bg-gray-100 rounded-md">
|
|
||||||
<span class="title text-16px font-medium text-gray-600 select-none">特殊区域</span>
|
|
||||||
<n-button :disabled="!currBookPage" type="primary" @click="addTopic">
|
|
||||||
<template #icon><icon-solar-question-circle-outline /></template>
|
|
||||||
添加区域
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 flex-col w-full h-full max-h-full bg-gray-50 rounded-10px">
|
|
||||||
<n-scrollbar
|
|
||||||
v-if="currBookPage && currBookPageLayout.length > 0"
|
|
||||||
class="flex-1 w-full h-full max-h-full bg-gray-50 rounded-10px"
|
|
||||||
content-style="height: auto; min-height: 100%"
|
|
||||||
>
|
|
||||||
<ul class="list p-3.5">
|
|
||||||
<li v-for="(topicInfo, topicInfoIndex) in currBookPageLayout" :key="topicInfoIndex" class="mb-2.5">
|
|
||||||
<right-topic-item :topicInfo="topicInfo" :currBookPage="currBookPage" @edit-topic="editTopic"></right-topic-item>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</n-scrollbar>
|
|
||||||
<div v-else-if="!currBookPage" class="flex items-center justify-center h-full min-h-full">
|
|
||||||
<n-empty description="请在目录中选择页码" class="bg-white p-4 rounded" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="currBookPageLayout.length <= 0" class="flex items-center justify-center h-full min-h-full">
|
|
||||||
<n-empty description="暂无数据" class="bg-white p-4 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<add-topic-form-bubble ref="addTopicFormBubbleRef"></add-topic-form-bubble>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, useTemplateRef } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { useMessage } from 'naive-ui';
|
|
||||||
import RightTopicItem from './components/right-topic-item.vue';
|
|
||||||
import type { CurrBookPageAllInfo, TopicInfo } from '../../types';
|
|
||||||
import AddTopicFormBubble from './components/add-topic-form-bubble.vue';
|
|
||||||
import { $mitt, open_book_topic_edit } from '@/utils/event-bus';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 当前页码信息 */
|
|
||||||
currBookPage: { type: Object as PropType<CurrBookPageAllInfo>, default: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = useMessage();
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
$mitt.on(open_book_topic_edit, (data: { questionId: number; event: MouseEvent }) => {
|
|
||||||
const topicInfo = currBookPageLayout.value.find((item) => item.questionId === data.questionId);
|
|
||||||
if (topicInfo) {
|
|
||||||
editTopic(data.event, topicInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const currBookPageLayout = computed(() => {
|
|
||||||
const list = props.currBookPage?.currBookPageData.layout || [];
|
|
||||||
return list.filter((item) => {
|
|
||||||
return item.question.type && item.question.type >= 100 && item.question.type < 200;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const addTopicFormBubbleRef = useTemplateRef<{ show: Function }>('addTopicFormBubbleRef');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新增题目
|
|
||||||
*/
|
|
||||||
function addTopic(event: MouseEvent, topicInfo: TopicInfo | undefined = undefined) {
|
|
||||||
if (!props.currBookPage) {
|
|
||||||
message.error('请先在目录中选择页码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addTopicFormBubbleRef.value?.show({
|
|
||||||
eventElement: event,
|
|
||||||
currBookPage: props.currBookPage,
|
|
||||||
topicInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑题目
|
|
||||||
*/
|
|
||||||
function editTopic(event: MouseEvent, topicInfo: TopicInfo) {
|
|
||||||
const questionId = topicInfo.questionId;
|
|
||||||
const areas = props.currBookPage!.currBookPageData.areas || [];
|
|
||||||
// The original code looked up in areas, but passed topicInfo (from layout) to show.
|
|
||||||
// Assuming areas and layout are synced or topicInfo is sufficient.
|
|
||||||
// The original code checked findIndex in areas.
|
|
||||||
const questionIndex = areas.findIndex((item) => item.id === questionId);
|
|
||||||
// Actually layout items contain question info.
|
|
||||||
// The original code logic: if (questionIndex >= 0) ...
|
|
||||||
// But if currBookPageLayout is computed from layout, and layout has it, then it exists in layout.
|
|
||||||
// Does layout imply area exists?
|
|
||||||
// Let's keep the check if areas exists.
|
|
||||||
// But `areas` is in `currBookPageData`.
|
|
||||||
if (props.currBookPage && topicInfo) {
|
|
||||||
addTopicFormBubbleRef.value?.show({
|
|
||||||
eventElement: event,
|
|
||||||
currBookPage: props.currBookPage,
|
|
||||||
topicInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Using UnoCSS utility classes in template, minimal scoped styles needed */
|
|
||||||
</style>
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col w-320px min-w-320px min-h-0">
|
|
||||||
<n-tabs v-model:value="topicType" type="line" animated>
|
|
||||||
<n-tab-pane name="normal" tab="普通题目">
|
|
||||||
<right-topic-list-normal :currBookPage="currBookPage"></right-topic-list-normal>
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="special" tab="特殊区域">
|
|
||||||
<right-topic-list-special :currBookPage="currBookPage"></right-topic-list-special>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, type PropType } from 'vue';
|
|
||||||
import { NTabs, NTabPane } from 'naive-ui';
|
|
||||||
import type { CurrBookPageAllInfo } from '../../types';
|
|
||||||
import RightTopicListSpecial from './components/right-topic-list-special/right-topic-list-special.vue';
|
|
||||||
import RightTopicListNormal from './components/right-topic-list-normal/right-topic-list-normal.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/** 当前页码信息 */
|
|
||||||
currBookPage: { type: Object as PropType<CurrBookPageAllInfo>, default: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 题的类型 正常或特殊 */
|
|
||||||
const topicType = ref<'normal' | 'special'>('normal');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import type { ImageInfo } from './types';
|
|
||||||
|
|
||||||
export async function convertPDFToImageFiles(file: File): Promise<{ list: ImageInfo[], pdf: File }> {
|
|
||||||
console.warn('PDF conversion requires pdfjs-dist, which is missing. Returning empty list.');
|
|
||||||
return { list: [], pdf: file };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPDFPageWidth(file: File): Promise<number> {
|
|
||||||
console.warn('PDF conversion requires pdfjs-dist, which is missing. Returning 0.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
|
|
||||||
export interface BookBaseData {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestionInfo {
|
|
||||||
type?: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
no: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutItem {
|
|
||||||
questionId: number;
|
|
||||||
question: QuestionInfo;
|
|
||||||
answerList?: any[]; // Added based on topic-item-module usage
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookPageQuestion {
|
|
||||||
id: number;
|
|
||||||
type?: number;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookPageAreas {
|
|
||||||
id: number;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrBookPageData {
|
|
||||||
url?: string;
|
|
||||||
bookPageId?: number;
|
|
||||||
layout: LayoutItem[];
|
|
||||||
questions: BookPageQuestion[];
|
|
||||||
areas: BookPageAreas[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrBookPageAllInfo {
|
|
||||||
bookBaseData: BookBaseData;
|
|
||||||
currBookPageData: CurrBookPageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionType = number;
|
|
||||||
|
|
||||||
export interface QuestionsImages {
|
|
||||||
// Add properties as needed based on usage in util.ts
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CropImageOptions {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddBookTopicForm {
|
|
||||||
type?: 100 | 101 | 102;
|
|
||||||
pageNumb?: number;
|
|
||||||
bigTpicNumb?: number;
|
|
||||||
subTopicNumb?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookPageOtherAddInput {
|
|
||||||
bookId: number;
|
|
||||||
bookPageId: number;
|
|
||||||
no: string;
|
|
||||||
type: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookPageOtherUpdateInput {
|
|
||||||
bookId: number;
|
|
||||||
bookPageId: number;
|
|
||||||
no: string;
|
|
||||||
type: number;
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TopicInfo {
|
|
||||||
question: QuestionInfo;
|
|
||||||
answerList?: any[];
|
|
||||||
questionId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnswerInfo {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageInfo {
|
|
||||||
file: File;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
74
pnpm-lock.yaml
generated
74
pnpm-lock.yaml
generated
@ -32,9 +32,6 @@ 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))
|
||||||
@ -56,15 +53,9 @@ 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,12 +74,6 @@ importers:
|
|||||||
json5:
|
json5:
|
||||||
specifier: 2.2.3
|
specifier: 2.2.3
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
katex:
|
|
||||||
specifier: ^0.16.27
|
|
||||||
version: 0.16.27
|
|
||||||
mathlive:
|
|
||||||
specifier: ^0.108.2
|
|
||||||
version: 0.108.2
|
|
||||||
naive-ui:
|
naive-ui:
|
||||||
specifier: 2.43.2
|
specifier: 2.43.2
|
||||||
version: 2.43.2(vue@3.5.26(typescript@5.9.3))
|
version: 2.43.2(vue@3.5.26(typescript@5.9.3))
|
||||||
@ -573,10 +558,6 @@ packages:
|
|||||||
'@clack/prompts@0.9.1':
|
'@clack/prompts@0.9.1':
|
||||||
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
||||||
|
|
||||||
'@cortex-js/compute-engine@0.30.2':
|
|
||||||
resolution: {integrity: sha512-Zx+iisk9WWdbxjm8EYsneIBszvjfUs7BHNwf1jBtSINIgfWGpHrTTq9vW0J59iGCFt6bOFxbmWyxNMRSmksHMA==}
|
|
||||||
engines: {node: '>=21.7.3', npm: '>=10.5.0'}
|
|
||||||
|
|
||||||
'@css-render/plugin-bem@0.15.14':
|
'@css-render/plugin-bem@0.15.14':
|
||||||
resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
|
resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -858,9 +839,6 @@ 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'}
|
||||||
@ -1181,10 +1159,6 @@ 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==}
|
||||||
|
|
||||||
@ -1702,9 +1676,6 @@ 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:
|
||||||
@ -2065,18 +2036,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
commander@8.3.0:
|
|
||||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
|
||||||
engines: {node: '>= 12'}
|
|
||||||
|
|
||||||
comment-parser@1.4.1:
|
comment-parser@1.4.1:
|
||||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
complex-esm@2.1.1-esm1:
|
|
||||||
resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==}
|
|
||||||
engines: {node: '>=16.14.2', npm: '>=8.5.0'}
|
|
||||||
|
|
||||||
component-emitter@1.3.1:
|
component-emitter@1.3.1:
|
||||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||||
|
|
||||||
@ -2216,9 +2179,6 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.2.0:
|
||||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||||
|
|
||||||
@ -3399,10 +3359,6 @@ packages:
|
|||||||
jstoxml@2.2.9:
|
jstoxml@2.2.9:
|
||||||
resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
|
resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
|
||||||
|
|
||||||
katex@0.16.27:
|
|
||||||
resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@ -3528,9 +3484,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
mathlive@0.108.2:
|
|
||||||
resolution: {integrity: sha512-GIZkfprGTxrbHckOvwo92ZmOOxdD018BHDzlrEwYUU+pzR5KabhqI1s43lxe/vqXdF5RLiQKgDcuk5jxEjhkYg==}
|
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||||
|
|
||||||
@ -5488,11 +5441,6 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
'@cortex-js/compute-engine@0.30.2':
|
|
||||||
dependencies:
|
|
||||||
complex-esm: 2.1.1-esm1
|
|
||||||
decimal.js: 10.6.0
|
|
||||||
|
|
||||||
'@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
|
'@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
css-render: 0.15.14
|
css-render: 0.15.14
|
||||||
@ -5730,8 +5678,6 @@ 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':
|
||||||
@ -5976,8 +5922,6 @@ 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': {}
|
||||||
@ -6049,7 +5993,7 @@ snapshots:
|
|||||||
|
|
||||||
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
eslint: 9.39.2(jiti@2.6.1)
|
eslint: 9.39.2(jiti@2.6.1)
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
espree: 10.4.0
|
espree: 10.4.0
|
||||||
@ -6630,8 +6574,6 @@ 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
|
||||||
@ -7035,12 +6977,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@7.2.0: {}
|
commander@7.2.0: {}
|
||||||
|
|
||||||
commander@8.3.0: {}
|
|
||||||
|
|
||||||
comment-parser@1.4.1: {}
|
comment-parser@1.4.1: {}
|
||||||
|
|
||||||
complex-esm@2.1.1-esm1: {}
|
|
||||||
|
|
||||||
component-emitter@1.3.1: {}
|
component-emitter@1.3.1: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@ -7159,8 +7097,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
@ -8467,10 +8403,6 @@ snapshots:
|
|||||||
|
|
||||||
jstoxml@2.2.9: {}
|
jstoxml@2.2.9: {}
|
||||||
|
|
||||||
katex@0.16.27:
|
|
||||||
dependencies:
|
|
||||||
commander: 8.3.0
|
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@ -8599,10 +8531,6 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mathlive@0.108.2:
|
|
||||||
dependencies:
|
|
||||||
'@cortex-js/compute-engine': 0.30.2
|
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
|
|||||||
Reference in New Issue
Block a user