chore: 清理未使用的模板编辑功能及相关依赖

移除模板编辑相关组件、工具函数和类型定义,并删除不再需要的第三方依赖包。此功能已不再使用,清理以减少项目体积和复杂度。

- 删除模板编辑页面及其所有子组件(left-tree、middle-book、right-topic等)
- 移除PDF转换、图片裁剪、数学公式编辑等工具函数
- 清理富文本编辑器组件及相关类型定义
- 从package.json移除@formkit/auto-animate、@sapphire/snowflake、katex、mathlive等未使用依赖
This commit is contained in:
2026-01-28 09:53:17 +08:00
parent 133d9a903a
commit 78e51d96f6
29 changed files with 8 additions and 4298 deletions

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -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>

View File

@ -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
}

View File

@ -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>

View File

@ -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
};
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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
View File

@ -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