feat: 题目与选项支持图片 (#291)

* feat: 题目与选项支持图片

* fix: 修复使用本地存储时文件访问路径不正确的问题

* fix: 图片编辑表单无法输入

* chore: 添加上传文件夹到gitignore

* fix: 两个#app的问题
This commit is contained in:
nil 2024-07-17 19:21:09 -07:00 committed by GitHub
parent 93938702fe
commit 492e0055f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 212 additions and 65 deletions

3
.gitignore vendored
View File

@ -25,3 +25,6 @@ pnpm-debug.log*
*.sw? *.sw?
.history .history
# 默认的上传文件夹
userUpload

View File

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

View File

@ -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: {
//! channelchannel
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;

View File

@ -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(/&nbsp;/g, '')
return content
}
export const cleanRichText = (text) => { export const cleanRichText = (text) => {
if (!text) { if (!text) {
return text === 0 ? 0 : '' return text === 0 ? 0 : ''

View File

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

View File

@ -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(/&nbsp;/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>

View File

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

View File

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

View File

@ -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
} }
}) || [] }) || []

View File

@ -0,0 +1,8 @@
// 富文本标题选项中的预览弹窗的图片宽度
.el-popover {
p {
img {
max-width: 100%;
}
}
}

View File

@ -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)"
/> />

View File

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

View File

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

View File

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