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": {
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@opentiny/fluent-editor": "^4.0.1",
|
||||
"@sa/axios": "workspace:*",
|
||||
@ -33,17 +32,13 @@
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@sapphire/snowflake": "^3.5.5",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@zumer/snapdom": "^2.0.1",
|
||||
"ali-oss": "^6.23.0",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.19",
|
||||
"defu": "6.1.4",
|
||||
"echarts": "6.0.0",
|
||||
"json5": "2.2.3",
|
||||
"katex": "^0.16.27",
|
||||
"mathlive": "^0.108.2",
|
||||
"naive-ui": "2.43.2",
|
||||
"nprogress": "0.2.0",
|
||||
"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>
|
||||
<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 lang="ts" setup>
|
||||
//
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用 UnoCSS 类替代 SCSS */
|
||||
</style>
|
||||
<template>
|
||||
<div class="">
|
||||
<!-- 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':
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@iconify/vue':
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0(vue@3.5.26(typescript@5.9.3))
|
||||
@ -56,15 +53,9 @@ importers:
|
||||
'@sa/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@sapphire/snowflake':
|
||||
specifier: ^3.5.5
|
||||
version: 3.5.5
|
||||
'@vueuse/core':
|
||||
specifier: 14.1.0
|
||||
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
|
||||
'@zumer/snapdom':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.2
|
||||
ali-oss:
|
||||
specifier: ^6.23.0
|
||||
version: 6.23.0
|
||||
@ -83,12 +74,6 @@ importers:
|
||||
json5:
|
||||
specifier: 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:
|
||||
specifier: 2.43.2
|
||||
version: 2.43.2(vue@3.5.26(typescript@5.9.3))
|
||||
@ -573,10 +558,6 @@ packages:
|
||||
'@clack/prompts@0.9.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
|
||||
peerDependencies:
|
||||
@ -858,9 +839,6 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@formkit/auto-animate@0.9.0':
|
||||
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@ -1181,10 +1159,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sapphire/snowflake@3.5.5':
|
||||
resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
@ -1702,9 +1676,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@zumer/snapdom@2.0.2':
|
||||
resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@ -2065,18 +2036,10 @@ packages:
|
||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
commander@8.3.0:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
comment-parser@1.4.1:
|
||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
@ -2216,9 +2179,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||
|
||||
@ -3399,10 +3359,6 @@ packages:
|
||||
jstoxml@2.2.9:
|
||||
resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
|
||||
|
||||
katex@0.16.27:
|
||||
resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==}
|
||||
hasBin: true
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@ -3528,9 +3484,6 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mathlive@0.108.2:
|
||||
resolution: {integrity: sha512-GIZkfprGTxrbHckOvwo92ZmOOxdD018BHDzlrEwYUU+pzR5KabhqI1s43lxe/vqXdF5RLiQKgDcuk5jxEjhkYg==}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||
|
||||
@ -5488,11 +5441,6 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
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)':
|
||||
dependencies:
|
||||
css-render: 0.15.14
|
||||
@ -5730,8 +5678,6 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@formkit/auto-animate@0.9.0': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
@ -5976,8 +5922,6 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@sapphire/snowflake@3.5.5': {}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
@ -6049,7 +5993,7 @@ snapshots:
|
||||
|
||||
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
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-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
@ -6630,8 +6574,6 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
'@zumer/snapdom@2.0.2': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@ -7035,12 +6977,8 @@ snapshots:
|
||||
|
||||
commander@7.2.0: {}
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
comment-parser@1.4.1: {}
|
||||
|
||||
complex-esm@2.1.1-esm1: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@ -7159,8 +7097,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@ -8467,10 +8403,6 @@ snapshots:
|
||||
|
||||
jstoxml@2.2.9: {}
|
||||
|
||||
katex@0.16.27:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@ -8599,10 +8531,6 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
||||
Reference in New Issue
Block a user