feat: 皮肤设置2.0 (#426)

* fix: 修复windows上传文件路径反斜杠问题

* feat: 补全服务端skinConf定义

* feat: web端皮肤背景设置2.0

* feat: 删除console.log

* feat: 皮肤设置内容结果增加背景设置以及应用皮肤设置方法抽离

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
This commit is contained in:
Jiangchunfu 2024-09-24 14:09:37 +08:00 committed by sudoooooo
parent 4bc8fbc557
commit 4b8719ab9c
18 changed files with 338 additions and 61 deletions

View File

@ -135,6 +135,17 @@ export interface BaseConf {
export interface SkinConf { export interface SkinConf {
skinColor: string; skinColor: string;
inputBgColor: string; inputBgColor: string;
backgroundConf: {
color: string;
type: string;
image: string;
};
contentConf: {
opacity: number;
};
themeConf: {
color: string;
};
} }
export interface BottomConf { export interface BottomConf {

View File

@ -1,4 +1,4 @@
import { join, dirname } from 'path'; import { join, dirname, sep } from 'path';
import fse from 'fs-extra'; import fse from 'fs-extra';
import { createWriteStream } from 'fs'; import { createWriteStream } from 'fs';
import { FileUploadHandler } from './uploadHandler.interface'; import { FileUploadHandler } from './uploadHandler.interface';
@ -23,7 +23,9 @@ export class LocalHandler implements FileUploadHandler {
const filePath = join( const filePath = join(
options?.pathPrefix ? options?.pathPrefix : '', options?.pathPrefix ? options?.pathPrefix : '',
filename, filename,
); )
.split(sep)
.join('/');
const physicalPath = join(this.physicalRootPath, filePath); const physicalPath = join(this.physicalRootPath, filePath);
await fse.mkdir(dirname(physicalPath), { recursive: true }); await fse.mkdir(dirname(physicalPath), { recursive: true });
const writeStream = createWriteStream(physicalPath); const writeStream = createWriteStream(physicalPath);

View File

@ -249,6 +249,8 @@ describe('DataStatisticController', () => {
skinConf: { skinConf: {
backgroundConf: { backgroundConf: {
color: '#fff', color: '#fff',
type: 'color',
image: '',
}, },
themeConf: { themeConf: {
color: '#ffa600', color: '#ffa600',

View File

@ -44,6 +44,17 @@ export const mockSensitiveResponseSchema: ResponseSchema = {
logoImageWidth: '60%', logoImageWidth: '60%',
}, },
skinConf: { skinConf: {
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
skinColor: '#4a4c5b', skinColor: '#4a4c5b',
inputBgColor: '#ffffff', inputBgColor: '#ffffff',
}, },
@ -327,6 +338,17 @@ export const mockResponseSchema: ResponseSchema = {
logoImageWidth: '60%', logoImageWidth: '60%',
}, },
skinConf: { skinConf: {
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
skinColor: '#4a4c5b', skinColor: '#4a4c5b',
inputBgColor: '#ffffff', inputBgColor: '#ffffff',
}, },

View File

@ -176,7 +176,21 @@ describe('SurveyController', () => {
endTime: '2034-01-23 21:59:05', endTime: '2034-01-23 21:59:05',
}, },
bottomConf: { logoImage: '/imgs/Logo.webp', logoImageWidth: '60%' }, bottomConf: { logoImage: '/imgs/Logo.webp', logoImageWidth: '60%' },
skinConf: { skinColor: '#4a4c5b', inputBgColor: '#ffffff' }, skinConf: {
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
},
submitConf: {}, submitConf: {},
dataConf: { dataConf: {
dataList: [], dataList: [],

View File

@ -50,7 +50,9 @@
"skinColor": "#4a4c5b", "skinColor": "#4a4c5b",
"inputBgColor": "#ffffff", "inputBgColor": "#ffffff",
"backgroundConf": { "backgroundConf": {
"color": "#ffffff" "color": "#ffffff",
"type": "color",
"image": ""
}, },
"themeConf": { "themeConf": {
"color": "#ffa600" "color": "#ffa600"

View File

@ -44,6 +44,17 @@ export const mockResponseSchema: ResponseSchema = {
logoImageWidth: '60%', logoImageWidth: '60%',
}, },
skinConf: { skinConf: {
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
skinColor: '#4a4c5b', skinColor: '#4a4c5b',
inputBgColor: '#ffffff', inputBgColor: '#ffffff',
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

View File

@ -0,0 +1,21 @@
export default function (skinConfig: any) {
const root = document.documentElement
const { themeConf, backgroundConf, contentConf } = skinConfig
if (themeConf?.color) {
// 设置主题颜色
root.style.setProperty('--primary-color', themeConf?.color)
}
// 设置背景
const { color, type, image } = backgroundConf || {}
root.style.setProperty(
'--primary-background',
type === 'image' ? `url(${image}) no-repeat center / cover` : color
)
if (contentConf?.opacity.toString()) {
// 设置全局透明度
root.style.setProperty('--opacity', `${contentConf.opacity / 100}`)
}
}

View File

@ -14,22 +14,34 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted, watch } from 'vue'
import { useEditStore } from '@/management/stores/edit' import { useEditStore } from '@/management/stores/edit'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss' import 'element-plus/theme-chalk/src/message.scss'
import applySkinConfig from '@/common/utils/applySkinConfig'
import LeftMenu from '@/management/components/LeftMenu.vue' import LeftMenu from '@/management/components/LeftMenu.vue'
import CommonTemplate from './components/CommonTemplate.vue' import CommonTemplate from './components/CommonTemplate.vue'
import Navbar from './components/ModuleNavbar.vue' import Navbar from './components/ModuleNavbar.vue'
const editStore = useEditStore() const editStore = useEditStore()
const { init, setSurveyId } = editStore const { init, setSurveyId, schema } = editStore
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
watch(
() => schema.skinConf,
(v) => {
applySkinConfig(v)
},
{
deep: true,
immediate: true
}
)
onMounted(async () => { onMounted(async () => {
const surveyId = route.params.id as string const surveyId = route.params.id as string
setSurveyId(surveyId) setSurveyId(surveyId)

View File

@ -30,12 +30,14 @@
<i-ep-monitor /> <i-ep-monitor />
</div> </div>
</div> </div>
<div :class="`preview-panel ${previewTab == 1 ? 'phone' : 'pc'}`"> <div
:class="`preview-panel ${previewTab == 1 ? 'phone' : 'pc'}`"
>
<div class="wrapper"> <div class="wrapper">
<div class="tips-wrapper"> <div class="tips-wrapper">
<i-ep-WarningFilled /> <span>用户预览模式数据不保存</span> <i-ep-WarningFilled /> <span>用户预览模式数据不保存</span>
</div> </div>
<div v-loading="loading" element-loading-text="加载中..." style="height: 100%"> <div class="iframe-wrapper" v-loading="loading" element-loading-text="加载中...">
<iframe <iframe
v-loading="loading" v-loading="loading"
id="iframe-preview" id="iframe-preview"
@ -125,12 +127,28 @@ const closedDialog = () => {
&.pc { &.pc {
display: flex; display: flex;
justify-content: center; justify-content: center;
background: #f7f7f7;
box-shadow: 0px 2px 10px -2px rgba(82, 82, 102, 0.2); box-shadow: 0px 2px 10px -2px rgba(82, 82, 102, 0.2);
height: 726px; height: 726px;
background: var(--primary-background);
.wrapper { .wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.tips-wrapper {
justify-content: center;
}
.iframe-wrapper {
width: 636px; width: 636px;
height: 704px; flex: 1;
margin-top: 20px;
border-radius: 8px 8px 0 0;
overflow: hidden;
}
} }
} }
&.phone { &.phone {
@ -148,6 +166,9 @@ const closedDialog = () => {
padding-bottom: 14px; padding-bottom: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.iframe-wrapper {
height: 100%;
}
} }
iframe { iframe {
border-radius: 0px 0px 20px 20px; border-radius: 0px 0px 20px 20px;
@ -156,6 +177,7 @@ const closedDialog = () => {
} }
.tips-wrapper { .tips-wrapper {
display: flex; display: flex;
width: 100%;
align-items: center; align-items: center;
background: $primary-bg-color; background: $primary-bg-color;
color: $primary-color; color: $primary-color;

View File

@ -39,7 +39,7 @@ const moduleConfig = toRef(schema, 'submitConf')
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: #f6f7f9; background: var(--primary-background);
} }
.result-page-wrap { .result-page-wrap {
@ -49,8 +49,6 @@ const moduleConfig = toRef(schema, 'submitConf')
max-height: 812px; max-height: 812px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background-color: var(--primary-background-color);
padding: 0 0.3rem;
.result-page { .result-page {
background: rgba(255, 255, 255, var(--opacity)); background: rgba(255, 255, 255, var(--opacity));
display: flex; display: flex;

View File

@ -73,25 +73,6 @@ export default defineComponent({
pageEditOne pageEditOne
} }
}, },
watch: {
skinConf: {
handler(newVal) {
const { themeConf, backgroundConf, contentConf } = newVal
const root = document.documentElement
if (themeConf?.color) {
root.style.setProperty('--primary-color', themeConf?.color) //
}
if (backgroundConf?.color) {
root.style.setProperty('--primary-background-color', backgroundConf?.color) //
}
if (contentConf?.opacity.toString()) {
root.style.setProperty('--opacity', contentConf?.opacity / 100) //
}
},
immediate: true,
deep: true
}
}
}) })
</script> </script>
@ -103,7 +84,7 @@ export default defineComponent({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: #f6f7f9; background: var(--primary-background);
} }
.pagination-wrapper { .pagination-wrapper {
@ -137,7 +118,6 @@ export default defineComponent({
} }
.box { .box {
background-color: var(--primary-background-color);
position: relative; position: relative;
.mask { .mask {
@ -150,9 +130,7 @@ export default defineComponent({
} }
.content { .content {
margin: 0 0.3rem;
background: rgba(255, 255, 255, var(--opacity)); background: rgba(255, 255, 255, var(--opacity));
border-radius: 8px 8px 0 0;
} }
} }
} }

View File

@ -11,10 +11,37 @@ export default [
name: '背景', name: '背景',
key: 'skinConf.backgroundConf', key: 'skinConf.backgroundConf',
formConfigList: [ formConfigList: [
{
type: 'TabsSetter',
key: 'type',
tabList: [
{
label: '图片(<5M)',
value: 'image',
},
{
label: '颜色',
value: 'color',
}
]
},
{
label: '背景图片',
type: 'UploadSingleFile',
accept: "image/*",
limitSize: 5,// 单位MB
key: 'image',
relyFunc: (data) => {
return data.type === 'image'
}
},
{ {
label: '背景颜色', label: '背景颜色',
type: 'ColorPicker', type: 'ColorPicker',
key: 'color' key: 'color',
relyFunc: (data) => {
return data.type === 'color'
}
} }
] ]
}, },

View File

@ -0,0 +1,65 @@
<template>
<div class="tabs-setter">
<div class="tabs-header">
<div
v-for="item in tabList"
:class="['tabs-header__item', { active: props.formConfig.value === item.value }]"
:key="item.value"
@click="handleTabClick(item)"
>
{{ item.label }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
interface IProps {
formConfig: any
}
interface IEmit {
(ev: typeof FORM_CHANGE_EVENT_KEY, arg: { key: string; value: string }): void
}
const props = defineProps<IProps>()
const emit = defineEmits<IEmit>()
const tabList = computed(() => {
return props.formConfig?.tabList || []
})
function handleTabClick(item: any) {
const key = props.formConfig.key
emit(FORM_CHANGE_EVENT_KEY, { key, value: item.value })
}
</script>
<style lang="scss" scoped>
.tabs-setter {
background: #f2f4f7;
border-radius: 4px;
width: 100%;
padding: 4px;
.tabs-header {
display: flex;
width: 100%;
&__item {
flex: 1;
line-height: 24px;
text-align: center;
cursor: pointer;
&.active {
background: #fff;
}
}
}
}
</style>

View File

@ -0,0 +1,100 @@
<template>
<el-input size="small" v-model="inputValue">
<template #append>
<el-upload
ref="upload"
class="upload-img"
action="/api/file/upload"
:accept="formConfig.accept"
:limit="1"
:show-file-list="false"
:data="{ channel: 'upload' }"
:on-exceed="handleExceed"
:headers="{
Authorization: `Bearer ${token}`
}"
:before-upload="beforeAvatarUpload"
:on-success="onSuccess"
>
<img src="/imgs/icons/upload.png" alt="上传图标" />
</el-upload>
</template>
</el-input>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage, genFileId } from 'element-plus'
import { get as _get } from 'lodash-es'
import type { UploadInstance, UploadProps, UploadRawFile } from 'element-plus'
import { useUserStore } from '@/management/stores/user'
const upload = ref<UploadInstance>()
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
interface IProps {
formConfig: any
}
interface IEmit {
(ev: typeof FORM_CHANGE_EVENT_KEY, arg: { key: string; value: string }): void
}
const props = defineProps<IProps>()
const emit = defineEmits<IEmit>()
const userStore = useUserStore()
const token = _get(userStore, 'userInfo.token')
const inputValue = ref(props.formConfig.value)
watch(inputValue, (newValue) => {
emit(FORM_CHANGE_EVENT_KEY, { key: props.formConfig.key, value: newValue })
})
watch(
() => props.formConfig.value,
(newValue) => {
inputValue.value = newValue
}
)
const handleExceed: UploadProps['onExceed'] = (files) => {
upload.value!.clearFiles()
const file = files[0] as UploadRawFile
file.uid = genFileId()
upload.value!.handleStart(file)
}
function onSuccess(response: any) {
if (response?.data?.url) {
const key = props.formConfig.key
emit(FORM_CHANGE_EVENT_KEY, { key, value: response.data.url })
}
}
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
const { limitSize } = props.formConfig
if (limitSize) {
if (rawFile.size / 1024 / 1024 > limitSize) {
ElMessage.error(`图片大小不得超过 ${limitSize}MB!`)
return false
}
}
return true
}
</script>
<style lang="scss" scoped>
:deep(.el-input-group__append) {
padding: 0;
box-sizing: border-box;
.upload-img {
.el-upload {
width: 80px;
height: 32px;
}
}
}
</style>

View File

@ -6,31 +6,12 @@ import { watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useSurveyStore } from './stores/survey' import { useSurveyStore } from './stores/survey'
import applySkinConfig from '@/common/utils/applySkinConfig';
const { skinConf } = storeToRefs(useSurveyStore()) const { skinConf } = storeToRefs(useSurveyStore())
const updateSkinConfig = (value: any) => {
const root = document.documentElement
const { themeConf, backgroundConf, contentConf } = value
if (themeConf?.color) {
//
root.style.setProperty('--primary-color', themeConf?.color)
}
if (backgroundConf?.color) {
//
root.style.setProperty('--primary-background-color', backgroundConf?.color)
}
if (contentConf?.opacity.toString()) {
//
root.style.setProperty('--opacity', `${parseInt(contentConf.opacity) / 100}`)
}
}
watch(skinConf, (value) => { watch(skinConf, (value) => {
updateSkinConfig(value) applySkinConfig(value)
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
@ -54,4 +35,15 @@ html {
flex: 1; flex: 1;
background-color: #fff; background-color: #fff;
} }
@media screen and (min-width: 750px) {
body {
padding-top: 40px;
background: var(--primary-background);
}
#app {
border-radius: 8px 8px 0 0;
box-shadow: var(--el-box-shadow);
}
}
</style> </style>

View File

@ -175,13 +175,11 @@ const handleSubmit = () => {
.wrapper { .wrapper {
min-height: 100%; min-height: 100%;
background-color: var(--primary-background-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.content { .content {
flex: 1; flex: 1;
margin: 0 0.3rem;
background: rgba(255, 255, 255, var(--opacity)); background: rgba(255, 255, 255, var(--opacity));
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
height: 100%; height: 100%;