feat: 题目与选项支持图片 (#291)
* feat: 题目与选项支持图片 * fix: 修复使用本地存储时文件访问路径不正确的问题 * fix: 图片编辑表单无法输入 * chore: 添加上传文件夹到gitignore * fix: 两个#app的问题
This commit is contained in:
parent
93938702fe
commit
492e0055f0
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,3 +25,6 @@ pnpm-debug.log*
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.history
|
.history
|
||||||
|
|
||||||
|
# 默认的上传文件夹
|
||||||
|
userUpload
|
||||||
|
@ -52,6 +52,12 @@ http {
|
|||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 静态文件的默认存储文件夹
|
||||||
|
# 文件夹的配置在 server/src/modules/file/config/index.ts SERVER_LOCAL_CONFIG.FILE_KEY_PREFIX
|
||||||
|
location /userUpload {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /500.html;
|
error_page 500 502 503 504 /500.html;
|
||||||
client_max_body_size 20M;
|
client_max_body_size 20M;
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="editor-wrapper border">
|
<div class="editor-wrapper border">
|
||||||
<Toolbar
|
<Toolbar :class="['toolbar', props.staticToolBar ? 'static-toolbar' : 'dynamic-toolbar']" ref="toolbar"
|
||||||
:class="['toolbar', props.staticToolBar ? 'static-toolbar' : 'dynamic-toolbar']"
|
v-show="showToolbar" :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
|
||||||
ref="toolbar"
|
<Editor class="editor" ref="editor" :modelValue="curValue" :defaultConfig="editorConfig" @onCreated="onCreated"
|
||||||
v-show="showToolbar"
|
@onChange="onChange" @onBlur="onBlur" @onFocus="onFocus" :mode="mode" />
|
||||||
:editor="editorRef"
|
|
||||||
:defaultConfig="toolbarConfig"
|
|
||||||
:mode="mode"
|
|
||||||
/>
|
|
||||||
<Editor
|
|
||||||
class="editor"
|
|
||||||
ref="editor"
|
|
||||||
:modelValue="curValue"
|
|
||||||
:defaultConfig="editorConfig"
|
|
||||||
@onCreated="onCreated"
|
|
||||||
@onChange="onChange"
|
|
||||||
@onBlur="onBlur"
|
|
||||||
@onFocus="onFocus"
|
|
||||||
:mode="mode"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -26,28 +11,62 @@
|
|||||||
import '@wangeditor/editor/dist/css/style.css'
|
import '@wangeditor/editor/dist/css/style.css'
|
||||||
import './styles/reset-wangeditor.scss'
|
import './styles/reset-wangeditor.scss'
|
||||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
import { ref, shallowRef, onBeforeMount, watch } from 'vue'
|
import { ref, shallowRef, onBeforeMount, watch, computed } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
import { get as _get } from 'lodash-es'
|
||||||
|
|
||||||
const emit = defineEmits(['input', 'onFocus', 'change', 'blur', 'created'])
|
const emit = defineEmits(['input', 'onFocus', 'change', 'blur', 'created'])
|
||||||
const model = defineModel()
|
const model = defineModel()
|
||||||
const props = defineProps(['staticToolBar'])
|
const props = defineProps({
|
||||||
|
staticToolBar: { default: false, required: false },
|
||||||
|
needUploadImage: { default: false, required: false }
|
||||||
|
})
|
||||||
|
|
||||||
const curValue = ref('')
|
const curValue = ref('')
|
||||||
const editorRef = shallowRef()
|
const editorRef = shallowRef()
|
||||||
const showToolbar = ref(props.staticToolBar || false)
|
const showToolbar = ref(props.staticToolBar)
|
||||||
|
|
||||||
const mode = 'simple'
|
const mode = 'simple'
|
||||||
|
|
||||||
const toolbarConfig = {
|
const toolbarConfig = computed(() => {
|
||||||
|
const config = {
|
||||||
toolbarKeys: [
|
toolbarKeys: [
|
||||||
'color', // 字体色
|
'color', // 字体色
|
||||||
'bgColor', // 背景色
|
'bgColor', // 背景色
|
||||||
'bold',
|
'bold',
|
||||||
'insertLink' // 链接
|
'insertLink', // 链接
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
if (props.needUploadImage) {
|
||||||
|
config.toolbarKeys.push('uploadImage')
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorConfig = {
|
||||||
|
MENU_CONF: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorConfig = {}
|
const store = useStore()
|
||||||
|
const token = _get(store, 'state.user.userInfo.token')
|
||||||
|
|
||||||
|
editorConfig.MENU_CONF['uploadImage'] = {
|
||||||
|
allowedFileTypes: ['image/jpeg', 'image/png'],
|
||||||
|
server: '/api/file/upload',
|
||||||
|
fieldName: 'file',
|
||||||
|
meta: {
|
||||||
|
//! 此处的channel需要跟上传接口内配置的channel一致
|
||||||
|
channel: 'upload'
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
customInsert(res, insertFn) {
|
||||||
|
const url = res.data.url
|
||||||
|
insertFn(url, '', '')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const setHtml = (newHtml) => {
|
const setHtml = (newHtml) => {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
@ -114,6 +133,7 @@ onBeforeMount(() => {
|
|||||||
.static-toolbar {
|
.static-toolbar {
|
||||||
border-bottom: 1px solid #dedede;
|
border-bottom: 1px solid #dedede;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-toolbar {
|
.dynamic-toolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -27,6 +27,16 @@ const isVideo = (html) => {
|
|||||||
return html.indexOf('<video') > -1
|
return html.indexOf('<video') > -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cleanRichTextWithMediaTag = (text) => {
|
||||||
|
if (!text) {
|
||||||
|
return text === 0 ? 0 : ''
|
||||||
|
}
|
||||||
|
const html = transformHtmlTag(text).replace(/<img([\w\W]+?)\/>/g,'[图片]').replace(/<video.*\/video>/g,'[视频]')
|
||||||
|
const content = html.replace(/<[^<>]+>/g, '').replace(/ /g, '')
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
export const cleanRichText = (text) => {
|
export const cleanRichText = (text) => {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return text === 0 ? 0 : ''
|
return text === 0 ? 0 : ''
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -14,6 +12,7 @@ export default {
|
|||||||
@import url('./styles/icon.scss');
|
@import url('./styles/icon.scss');
|
||||||
@import url('../materials/questions/common/css/icon.scss');
|
@import url('../materials/questions/common/css/icon.scss');
|
||||||
@import url('./styles/reset.scss');
|
@import url('./styles/reset.scss');
|
||||||
|
@import url('./styles/common.scss');
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 50px;
|
font-size: 50px;
|
||||||
|
@ -13,28 +13,30 @@
|
|||||||
v-for="item in props.tableData.listHead"
|
v-for="item in props.tableData.listHead"
|
||||||
:key="item.field"
|
:key="item.field"
|
||||||
:prop="item.field"
|
:prop="item.field"
|
||||||
:label="cleanRichText(item.title)"
|
:label="item.title"
|
||||||
minWidth="200"
|
minWidth="200"
|
||||||
>
|
>
|
||||||
|
|
||||||
<template #header="scope">
|
<template #header="scope">
|
||||||
<div
|
<div class="table-row-cell">
|
||||||
class="table-row-cell"
|
<span
|
||||||
|
class="table-row-head"
|
||||||
@mouseover="onPopoverRefOver(scope, 'head')"
|
@mouseover="onPopoverRefOver(scope, 'head')"
|
||||||
:ref="(el) => (popoverRefMap[scope.column.id] = el)"
|
:ref="(el) => (popoverRefMap[scope.column.id] = el)"
|
||||||
|
v-html="item.title"
|
||||||
>
|
>
|
||||||
<span>
|
|
||||||
{{ scope.column.label.replace(/ /g, '') }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div
|
<div>
|
||||||
|
<span
|
||||||
class="table-row-cell"
|
class="table-row-cell"
|
||||||
@mouseover="onPopoverRefOver(scope, 'content')"
|
@mouseover="onPopoverRefOver(scope, 'content')"
|
||||||
|
@click="onPreviewImage"
|
||||||
:ref="(el) => (popoverRefMap[scope.$index + scope.column.property] = el)"
|
:ref="(el) => (popoverRefMap[scope.$index + scope.column.property] = el)"
|
||||||
|
v-html="getContent(scope.row[scope.column.property])"
|
||||||
>
|
>
|
||||||
<span>
|
|
||||||
{{ getContent(scope.row[scope.column.property]) }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -48,15 +50,18 @@
|
|||||||
width="400"
|
width="400"
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
virtual-triggering
|
virtual-triggering
|
||||||
:content="popoverContent"
|
|
||||||
>
|
>
|
||||||
|
<div v-html="popoverContent"></div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
|
||||||
|
<ImagePreview :url="previewImageUrl" v-model:visible="showPreviewImage"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { cleanRichText } from '@/common/xss'
|
import { cleanRichText } from '@/common/xss'
|
||||||
|
import ImagePreview from './ImagePreview.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tableData: {
|
tableData: {
|
||||||
@ -74,8 +79,8 @@ const popoverRefMap = ref({})
|
|||||||
const popoverVirtualRef = ref()
|
const popoverVirtualRef = ref()
|
||||||
const popoverContent = ref('')
|
const popoverContent = ref('')
|
||||||
|
|
||||||
const getContent = (value) => {
|
const getContent = (content) => {
|
||||||
const content = cleanRichText(value)
|
// const content = cleanRichText(value)
|
||||||
return content === 0 ? 0 : content || '未知'
|
return content === 0 ? 0 : content || '未知'
|
||||||
}
|
}
|
||||||
const setPopoverContent = (content) => {
|
const setPopoverContent = (content) => {
|
||||||
@ -93,6 +98,16 @@ const onPopoverRefOver = (scope, type) => {
|
|||||||
}
|
}
|
||||||
setPopoverContent(popoverContent)
|
setPopoverContent(popoverContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewImageUrl = ref('')
|
||||||
|
const showPreviewImage = ref(false)
|
||||||
|
const onPreviewImage = (e) => {
|
||||||
|
if (e.target.tagName === 'IMG') {
|
||||||
|
previewImageUrl.value = e.target.src
|
||||||
|
showPreviewImage.value = true
|
||||||
|
}
|
||||||
|
console.log(e.target.src)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -121,17 +136,25 @@ const onPopoverRefOver = (scope, type) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-row-cell {
|
.table-row-cell {
|
||||||
max-width: 100%;
|
white-space: nowrap; /* 禁止自动换行 */
|
||||||
display: inline-block;
|
overflow: hidden; /* 超出部分隐藏 */
|
||||||
white-space: nowrap;
|
text-overflow: ellipsis; /* 显示省略号 */
|
||||||
/* 禁止自动换行 */
|
:deep(img) {
|
||||||
overflow: hidden;
|
height: 23px;
|
||||||
/* 超出部分隐藏 */
|
width: auto;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
/* 显示省略号 */
|
:deep(p) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
:deep(.el-table td.el-table__cell div) {
|
:deep(.el-table td.el-table__cell div) {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<style>
|
||||||
|
.el-popover p image {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition>
|
||||||
|
<div class="image-preview" v-show="visible">
|
||||||
|
<div class="close-btn" @click="visible = false"> <i-ep-close /> </div>
|
||||||
|
<div class="image-con">
|
||||||
|
<img :src="props.url" class="image-item" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
url: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel('visible')
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-from,
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba($color: #000000, $alpha: .4);
|
||||||
|
z-index: 2024;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.image-con {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
max-width: 80%;
|
||||||
|
max-height: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 20px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -3,6 +3,7 @@
|
|||||||
v-model="renderData"
|
v-model="renderData"
|
||||||
handle=".question-wrapper.isSelected"
|
handle=".question-wrapper.isSelected"
|
||||||
filter=".question-wrapper.isSelected .question.isSelected"
|
filter=".question-wrapper.isSelected .question.isSelected"
|
||||||
|
:preventOnFilter="false"
|
||||||
:group="DND_GROUP"
|
:group="DND_GROUP"
|
||||||
:onEnd="checkEnd"
|
:onEnd="checkEnd"
|
||||||
:move="checkMove"
|
:move="checkMove"
|
||||||
|
@ -57,7 +57,7 @@
|
|||||||
import { computed, inject, ref, type ComputedRef } from 'vue'
|
import { computed, inject, ref, type ComputedRef } from 'vue'
|
||||||
import { ConditionNode, RuleNode } from '@/common/logicEngine/RuleBuild'
|
import { ConditionNode, RuleNode } from '@/common/logicEngine/RuleBuild'
|
||||||
import { CHOICES } from '@/common/typeEnum'
|
import { CHOICES } from '@/common/typeEnum'
|
||||||
import { cleanRichText } from '@/common/xss'
|
import { cleanRichTextWithMediaTag } from '@/common/xss'
|
||||||
const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([])
|
const renderData = inject<ComputedRef<Array<any>>>('renderData') || ref([])
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
index: {
|
index: {
|
||||||
@ -88,7 +88,7 @@ const fieldList = computed(() => {
|
|||||||
.filter((question: any) => CHOICES.includes(question.type))
|
.filter((question: any) => CHOICES.includes(question.type))
|
||||||
.map((item: any) => {
|
.map((item: any) => {
|
||||||
return {
|
return {
|
||||||
label: `${item.showIndex ? item.indexNumber + '.' : ''} ${cleanRichText(item.title)}`,
|
label: `${item.showIndex ? item.indexNumber + '.' : ''} ${cleanRichTextWithMediaTag(item.title)}`,
|
||||||
value: item.field
|
value: item.field
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -102,7 +102,7 @@ const getRelyOptions = computed(() => {
|
|||||||
return (
|
return (
|
||||||
currentQuestion?.options.map((item: any) => {
|
currentQuestion?.options.map((item: any) => {
|
||||||
return {
|
return {
|
||||||
label: cleanRichText(item.text),
|
label: cleanRichTextWithMediaTag(item.text),
|
||||||
value: item.hash
|
value: item.hash
|
||||||
}
|
}
|
||||||
}) || []
|
}) || []
|
||||||
|
8
web/src/management/styles/common.scss
Normal file
8
web/src/management/styles/common.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// 富文本标题、选项中的预览弹窗的图片宽度
|
||||||
|
.el-popover {
|
||||||
|
p {
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@
|
|||||||
<span class="drag-handle qicon qicon-tuodong"></span>
|
<span class="drag-handle qicon qicon-tuodong"></span>
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<RichEditor
|
<RichEditor
|
||||||
|
:needUploadImage="true"
|
||||||
:modelValue="element.text"
|
:modelValue="element.text"
|
||||||
@change="(value) => handleChange(index, value)"
|
@change="(value) => handleChange(index, value)"
|
||||||
/>
|
/>
|
||||||
|
@ -69,6 +69,7 @@ export default defineComponent({
|
|||||||
<RichEditor
|
<RichEditor
|
||||||
class="rich-editor"
|
class="rich-editor"
|
||||||
modelValue={filterXSS(this.title)}
|
modelValue={filterXSS(this.title)}
|
||||||
|
needUploadImage={true}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onCreated={(editor) => {
|
onCreated={(editor) => {
|
||||||
editor?.focus()
|
editor?.focus()
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
|
@ -114,6 +114,11 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:3000',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
// 静态文件的默认存储文件夹
|
||||||
|
'/userUpload': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user