diff --git a/server/src/enums/question.ts b/server/src/enums/question.ts index e55b0c46..6dacd512 100644 --- a/server/src/enums/question.ts +++ b/server/src/enums/question.ts @@ -34,4 +34,8 @@ export enum QUESTION_TYPE { * 投票 */ VOTE = 'vote', + /** + * 多级联动 + */ + MULTILEVEL = 'multilevel', } diff --git a/server/src/interfaces/survey.ts b/server/src/interfaces/survey.ts index b7368e26..bf8330f0 100644 --- a/server/src/interfaces/survey.ts +++ b/server/src/interfaces/survey.ts @@ -22,6 +22,20 @@ export interface NPS { rightText: string; } +export interface MultilevelItem { + hash: string; + text: string; + children?: MultilevelItem[]; +} + +export interface MultilevelData { + placeholder: Array<{ + hash: string; + text: string; + }>; + children: Array; +} + export interface TextRange { min: { placeholder: string; @@ -60,6 +74,7 @@ export interface DataItem { rangeConfig?: any; starStyle?: string; innerType?: string; + multilevelData: MultilevelData; } export interface Option { diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts index 185e6354..56ce27f6 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -108,6 +108,7 @@ export class DataStatisticController { QUESTION_TYPE.RADIO_STAR, QUESTION_TYPE.RADIO_NPS, QUESTION_TYPE.VOTE, + QUESTION_TYPE.MULTILEVEL, ]; const fieldList = responseSchema.code.dataConf.dataList .filter((item) => allowQuestionType.includes(item.type as QUESTION_TYPE)) diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts index fbb29ec5..50457448 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -87,6 +87,21 @@ export class DataStatisticService { .join(',') : optionTextMap[data[itemKey]]?.text || data[itemKey]; } + // 将多级联动id还原成选项文案 + if ( + itemConfig.multilevelData && + itemConfig.type === QUESTION_TYPE.MULTILEVEL + ) { + let optionTextMap = keyBy(itemConfig.multilevelData.children, 'hash'); + data[itemKey] = data[itemKey] + ?.split(',') + .map((v) => { + const text = optionTextMap[v]?.text || v; + optionTextMap = keyBy(optionTextMap[v].children, 'hash'); + return text; + }) + .join('-'); + } } return { ...data, diff --git a/server/src/modules/survey/utils/index.ts b/server/src/modules/survey/utils/index.ts index 286f0430..85b39453 100644 --- a/server/src/modules/survey/utils/index.ts +++ b/server/src/modules/survey/utils/index.ts @@ -161,6 +161,7 @@ export function handleAggretionData({ dataMap, item }) { title: dataMap[item.field].title, type: dataMap[item.field].type, data: { + ...item.data, aggregation: arr.map((item) => { const num = item.toString(); return { @@ -173,6 +174,25 @@ export function handleAggretionData({ dataMap, item }) { summary, }, }; + } else if (type == QUESTION_TYPE.MULTILEVEL) { + const aggregation = getTextPaths( + dataMap[item.field].multilevelData.children, + ); + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + data: { + ...item.data, + aggregation: aggregation.map((item) => { + return { + id: item.id, + text: item.text, + count: aggregationMap[item.id]?.count || 0, + }; + }), + }, + }; } else { return { ...item, @@ -182,6 +202,27 @@ export function handleAggretionData({ dataMap, item }) { } } +const getTextPaths = (arr, textPrefix = '', idPrefix = '') => { + let paths = []; + + arr.forEach((item) => { + const currentTextPath = textPrefix + ? `${textPrefix}-${item.text}` + : item.text; + const currentIdPath = idPrefix ? `${idPrefix},${item.hash}` : item.hash; + + if (item.children && item.children.length > 0) { + paths = paths.concat( + getTextPaths(item.children, currentTextPath, currentIdPath), + ); + } else { + paths.push({ id: currentIdPath, text: currentTextPath }); + } + }); + + return paths; +}; + function getAverage({ aggregation }) { const { sum, count } = aggregation.reduce( (pre, cur) => { diff --git a/web/public/imgs/question-type-snapshot/multilevel.webp b/web/public/imgs/question-type-snapshot/multilevel.webp new file mode 100644 index 00000000..ab849863 Binary files /dev/null and b/web/public/imgs/question-type-snapshot/multilevel.webp differ diff --git a/web/src/common/typeEnum.ts b/web/src/common/typeEnum.ts index 14087a02..53270ce2 100644 --- a/web/src/common/typeEnum.ts +++ b/web/src/common/typeEnum.ts @@ -7,7 +7,8 @@ export enum QUESTION_TYPE { BINARY_CHOICE = 'binary-choice', RADIO_STAR = 'radio-star', RADIO_NPS = 'radio-nps', - VOTE = 'vote' + VOTE = 'vote', + MULTILEVEL = 'multilevel', } // 题目类型标签映射对象 @@ -19,7 +20,8 @@ export const typeTagLabels: Record = { [QUESTION_TYPE.BINARY_CHOICE]: '判断', [QUESTION_TYPE.RADIO_STAR]: '评分', [QUESTION_TYPE.RADIO_NPS]: 'NPS评分', - [QUESTION_TYPE.VOTE]: '投票' + [QUESTION_TYPE.VOTE]: '投票', + [QUESTION_TYPE.MULTILEVEL]: '多级联动' } // 输入类题型 @@ -38,3 +40,6 @@ export const CHOICES = [ // 评分题题型分类 export const RATES = [QUESTION_TYPE.RADIO_STAR, QUESTION_TYPE.RADIO_NPS] + +// 高级题型分类 +export const ADVANCED = [QUESTION_TYPE.MULTILEVEL] \ No newline at end of file diff --git a/web/src/management/config/questionMenuConfig.js b/web/src/management/config/questionMenuConfig.js index 66784bc5..f988d8f0 100644 --- a/web/src/management/config/questionMenuConfig.js +++ b/web/src/management/config/questionMenuConfig.js @@ -54,6 +54,13 @@ export const menuItems = { snapshot: '/imgs/question-type-snapshot/nGTscsZlwn1657702222857.webp', icon: 'tixing-toupiao', title: '投票' + }, + multilevel: { + type: 'multilevel', + path: 'MultilevelModule', + snapshot: '/imgs/question-type-snapshot/multilevel.webp', + icon: 'multilevel-select', + title: '多级联动' } } @@ -65,6 +72,9 @@ const menuGroup = [ { title: '选择类题型', questionList: ['radio', 'checkbox', 'binary-choice', 'radio-star', 'radio-nps', 'vote'] + }, { + title: '高级题型', + questionList: ['multilevel'] } ] diff --git a/web/src/management/hooks/useMultilevelPull.ts b/web/src/management/hooks/useMultilevelPull.ts new file mode 100644 index 00000000..8d5382fc --- /dev/null +++ b/web/src/management/hooks/useMultilevelPull.ts @@ -0,0 +1,138 @@ +import { ElMessageBox } from 'element-plus' +import { ref } from 'vue' +import { cloneDeep } from 'lodash-es' + +interface NodeItem { + hash: string, + text: string, + children?: NodeItem[], +} + +type MultilevelDat = { + placeholder: Array<{ + hash: string, + text: string, + }>, + children: Array, +} + +export const useMultilevelPull = () => { + const maxCount = 3; + const optionsCount = 50; + const multilevelVal = ref>([]); + const multilevelData = ref(null) + let hashArr: Array = []; + + + const extractHash = (obj: MultilevelDat): Array => { + const hashes: Array = []; + + function recurse(currentObj: any) { + if (Array.isArray(currentObj)) { + currentObj.forEach(item => recurse(item)); + } else if (typeof currentObj === 'object' && currentObj !== null) { + if (currentObj.hash) { + hashes.push(currentObj.hash); + } + for (const key in currentObj) { + // eslint-disable-next-line no-prototype-builtins + if (currentObj.hasOwnProperty(key as any)) { + recurse(currentObj[key]); + } + } + } + } + + recurse(obj); + return hashes; + } + + const getRandom = () => { + return Math.random().toString().slice(-6) + } + + const getNewHash = () => { + let random = getRandom() + while (random in hashArr) { + random = getRandom() + } + hashArr.push(random) + return random + } + + const addMultilevelNode = (key: number) => { + const nodeItem: NodeItem = (key == 0 ? multilevelData.value : multilevelVal.value[key - 1]) as NodeItem + if (nodeItem.children && nodeItem.children.length > optionsCount) { + ElMessageBox.alert(`当前最多添加${optionsCount}个选项`, '提示', { + confirmButtonText: '确定', + type: 'warning' + }) + return + } + const optionStr = `选项${nodeItem?.children ? nodeItem?.children?.length + 1 : 1}` + nodeItem.children?.push({ + hash: getNewHash(), + text: optionStr, + children: [] + }) + } + + const resetMultilevelVal = (index: number) => { + for (let i = multilevelVal.value.length; index < i; i--) { + multilevelVal.value[i - 1] = null; + } + } + + const removeMultilevelNode = (nodeItem: NodeItem, index: number, key: number) => { + try { + if (key == 0 && multilevelData.value?.children && multilevelData.value?.children?.length<=1) { + ElMessageBox.alert('至少保留一个选项', '提示', { + confirmButtonText: '确定', + type: 'warning' + }) + return + } + if (nodeItem.children) { + nodeItem.children[index].children = []; + } + nodeItem.children?.splice(index, 1) + resetMultilevelVal(key) + } catch (error) { + console.log(error) + } + } + + const editMultilevelNode = (nodeItem: NodeItem, index: number, text: string) => { + nodeItem.children && (nodeItem.children[index].text = text) + } + + + const setMultilevelVal = (data: NodeItem, index: number) => { + if (multilevelVal.value[index]?.hash == data.hash) return + resetMultilevelVal(index) + multilevelVal.value[index] = data + } + + + const loadInitData = (data: MultilevelDat) => { + multilevelData.value = cloneDeep(data); + multilevelVal.value = []; + for (let index = 0; index < maxCount; index++) { + multilevelVal.value.push(null) + } + hashArr = extractHash(multilevelData.value); + } + + + + return { + addMultilevelNode, + removeMultilevelNode, + editMultilevelNode, + loadInitData, + setMultilevelVal, + + multilevelVal, + multilevelData + } +} \ No newline at end of file diff --git a/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/MultiLevelConfig.vue b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/MultiLevelConfig.vue new file mode 100644 index 00000000..7f896aef --- /dev/null +++ b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/MultiLevelConfig.vue @@ -0,0 +1,194 @@ + + + \ No newline at end of file diff --git a/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue index ba18f6c2..30b88e96 100644 --- a/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue +++ b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue @@ -40,6 +40,9 @@ switch (props.moduleConfig.type) { case QUESTION_TYPE.RADIO_NPS: advancedComponent.value = defineAsyncComponent(() => import('./RateConfig.vue')) break + case QUESTION_TYPE.MULTILEVEL: + advancedComponent.value = defineAsyncComponent(() => import('./MultiLevelConfig.vue')) + break default: break } diff --git a/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue b/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue index e4d812be..89c9cca3 100644 --- a/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue +++ b/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue @@ -55,7 +55,7 @@ const editStore = useEditStore() const { newQuestionIndex } = storeToRefs(editStore) const { addQuestion, setCurrentEditOne, createNewQuestion } = editStore -const activeNames = ref([0, 1]) +const activeNames = ref([0, 1, 2]) const previewImg = ref('') const isShowPreviewImage = ref(false) const previewTop = ref(0) diff --git a/web/src/management/styles/icon.scss b/web/src/management/styles/icon.scss index aae3e7f6..dec4b2d1 100644 --- a/web/src/management/styles/icon.scss +++ b/web/src/management/styles/icon.scss @@ -1,9 +1,9 @@ @font-face { font-family: 'iconfont'; /* Project id 4263849 */ src: - url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.woff2?t=1723600417360') format('woff2'), - url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.woff?t=1723600417360') format('woff'), - url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.ttf?t=1723600417360') format('truetype'); + url('//at.alicdn.com/t/c/font_4263849_pddd8961y4d.woff2?t=1728727761985') format('woff2'), + url('//at.alicdn.com/t/c/font_4263849_pddd8961y4d.woff?t=1728727761985') format('woff'), + url('//at.alicdn.com/t/c/font_4263849_pddd8961y4d.ttf?t=1728727761985') format('truetype'); } .iconfont { @@ -177,3 +177,6 @@ .icon-shiying:before { content: '\e6f6'; } +.icon-multilevel-select:before { + content: '\e6f9'; +} \ No newline at end of file diff --git a/web/src/materials/questions/common/config/moduleList.js b/web/src/materials/questions/common/config/moduleList.js index d922b0f3..75de5b80 100644 --- a/web/src/materials/questions/common/config/moduleList.js +++ b/web/src/materials/questions/common/config/moduleList.js @@ -10,5 +10,6 @@ export default { city: 'CityModule', vote: 'VoteModule', 'matrix-checkbox': 'GroupModule', - selectMoreModule: 'SelectMoreModule' + selectMoreModule: 'SelectMoreModule', + multilevel:'MultilevelModule', } diff --git a/web/src/materials/questions/widgets/MultilevelModule/BaseMultilevel/index.vue b/web/src/materials/questions/widgets/MultilevelModule/BaseMultilevel/index.vue new file mode 100644 index 00000000..9858305f --- /dev/null +++ b/web/src/materials/questions/widgets/MultilevelModule/BaseMultilevel/index.vue @@ -0,0 +1,180 @@ + + + \ No newline at end of file diff --git a/web/src/materials/questions/widgets/MultilevelModule/Picker/event.ts b/web/src/materials/questions/widgets/MultilevelModule/Picker/event.ts new file mode 100644 index 00000000..403886ae --- /dev/null +++ b/web/src/materials/questions/widgets/MultilevelModule/Picker/event.ts @@ -0,0 +1,24 @@ + +interface IEvent { + handleConfirm: () => void, + handleCancel: () => void +} + +const useEvent = ({ emit, ctx }: any): IEvent => { + const handleConfirm = () => { + emit('confirm', ctx.list[ctx.index]) + emit('update:modelValue', false) + } + + const handleCancel = () => { + emit('update:modelValue', false) + emit('cancel', false) + } + + return { + handleConfirm, + handleCancel + } +} + +export default useEvent \ No newline at end of file diff --git a/web/src/materials/questions/widgets/MultilevelModule/Picker/index.vue b/web/src/materials/questions/widgets/MultilevelModule/Picker/index.vue new file mode 100644 index 00000000..76b3dd6b --- /dev/null +++ b/web/src/materials/questions/widgets/MultilevelModule/Picker/index.vue @@ -0,0 +1,155 @@ + + + + + \ No newline at end of file diff --git a/web/src/materials/questions/widgets/MultilevelModule/Picker/list.ts b/web/src/materials/questions/widgets/MultilevelModule/Picker/list.ts new file mode 100644 index 00000000..8af5b65d --- /dev/null +++ b/web/src/materials/questions/widgets/MultilevelModule/Picker/list.ts @@ -0,0 +1,128 @@ +import { ref, computed } from 'vue' +import type { Ref } from 'vue' + +interface IList { + box: Ref, + list: Ref>, + getOffsetY: any, + getStyle: any, + handleMove: (e: TouchEvent) => void, + handleStart: (e: TouchEvent) => void, + handleEnd: (e: TouchEvent) => void, + goItem: (idx: number) => void, + resetData: () => void, + index: Ref +} + +const useList = (props: any): IList => { + const colors = ['gray', '#ccc', '#ddd', '#eee'] + const scales = [.96, .9, .88, .84] + let startY: number, activeIndex = 0 + const box = ref() + const offY = ref() + const index = ref(0) + const list = ref(props.list) + + const getStyle = (idx: number) => { + let color = '#000', scale = 1 + const len = colors.length - 1 + if (idx > activeIndex) { + const _idx = idx - activeIndex > len ? len : idx - activeIndex - 1 + color = colors[_idx] + scale = scales[_idx] + } else if (idx < activeIndex) { + const _idx = activeIndex - idx > len ? len : activeIndex - idx - 1 + color = colors[_idx] + scale = scales[_idx] + } + return { color, transform: `scale(${scale})` } + } + + // 节流 + const throttle = function (callback: any, delay = 20) { + let timer: number | null = null + return function (args: any) { + if (timer) { + return + } + timer = setTimeout(() => { + callback(args) + timer = null + }, delay) + } + } + + // 移动的实现 + const move = throttle((e: any) => { + offY.value = e.touches[0].clientY - startY + if (offY.value > 40) { + offY.value = 40 + } else if (offY.value < -box.value.offsetHeight - 40) { + offY.value = -box.value.offsetHeight - 40 + } + // 计算当前位置的就近下标 + index.value = Math.abs(Math.ceil(offY.value / 40)) + // 判断顶部和底部的一个界限,然后做一个位置的重置 + if (index.value <= 0 || offY.value > 0) { + index.value = 0 + } else if (index.value > list.value.length - 1 || offY.value < -box.value.offsetHeight - 18) { + index.value = list.value.length - 1 + } + activeIndex = index.value + }) + + const goItem = (idx: number) => { + index.value = idx; + activeIndex = idx; + } + + const resetData = () => { + startY = 0; + activeIndex = 0 + index.value = 0 + box.value = null; + offY.value = null; + } + + const handleStart = (e: TouchEvent) => { + const transform = box.value.style.transform + transform.match(/,(.*)px/) + startY = e.touches[0].clientY - Number(RegExp.$1) + } + + const handleMove = (e: TouchEvent) => move(e) + + const handleEnd = () => { + // 重置当前位置,加setTimeout避免出现Bug + setTimeout(() => { + offY.value = -index.value * 40 - 18 + }, 100) + } + + const getOffsetY = computed(() => { + if (typeof offY.value === 'number') { + return { + transform: `translate(-50%, ${offY.value}px)` + } + } else { + return { + transform: 'translate(-50%, -18px)' + } + } + }) + + return { + box, + list, + getOffsetY, + getStyle, + handleMove, + handleStart, + handleEnd, + goItem, + resetData, + index + } +} + +export default useList \ No newline at end of file diff --git a/web/src/materials/questions/widgets/MultilevelModule/index.jsx b/web/src/materials/questions/widgets/MultilevelModule/index.jsx new file mode 100644 index 00000000..72ab0a3e --- /dev/null +++ b/web/src/materials/questions/widgets/MultilevelModule/index.jsx @@ -0,0 +1,45 @@ +import { defineComponent} from 'vue' +import BaseMultilevel from './BaseMultilevel/index.vue' + +export default defineComponent({ + name: 'MultilevelModule', + props: { + field: { + type: [String, Number], + default: '' + }, + value: { + type: String, + default: '' + }, + readonly: { + type: Boolean, + default: false + }, + multilevelData: { + type: Object, + default: () => {} + }, + }, + emits: ['change'], + setup(props, { emit }) { + const onChange = (value) => { + const key = props.field + emit('change', { + key, + value + }) + } + + return { + props, + onChange + } + }, + render() { + const { props } = this + return ( + + ) + } +}) \ No newline at end of file diff --git a/web/src/materials/questions/widgets/MultilevelModule/meta.js b/web/src/materials/questions/widgets/MultilevelModule/meta.js new file mode 100644 index 00000000..111ee556 --- /dev/null +++ b/web/src/materials/questions/widgets/MultilevelModule/meta.js @@ -0,0 +1,94 @@ +import basicConfig from '@materials/questions/common/config/basicConfig' + + +const meta = { + title: '多级联动', + type: 'multilevel', + componentName: 'MultilevelModule', + attrs: [ + { + name: 'type', + propType: 'String', + description: '这是用于描述题目类型', + defaultValue: 'multilevel' + }, + { + name: 'isRequired', + propType: Boolean, + description: '是否必填', + defaultValue: true + }, + { + name: 'showIndex', + propType: Boolean, + description: '显示序号', + defaultValue: true + }, + { + name: 'showType', + propType: Boolean, + description: '显示类型', + defaultValue: true + }, + { + name: 'showSpliter', + propType: Boolean, + description: '显示分割线', + defaultValue: true + }, + { + name: 'multilevelData', + propType: Array, + description: '这是用于描述选项', + defaultValue: + { + placeholder: [{ + text: '请选择', + hash: '115016' + }, { + text: '请选择', + hash: '115017' + }, { + text: '请选择', + hash: '115018' + }], + children: [ + { + text: '选项1', + children: [], + hash: '115019' + }, + { + text: '选项2', + children: [], + hash: '115020' + }, + { + text: '选项3', + children: [], + hash: '115011' + } + ] + }, + + + }, + ], + formConfig: [ + basicConfig + ], + editConfigure: { + optionEdit: { + show: false + }, + optionEditBar: { + show: true, + configure: { + showOthers: false, + showAdvancedConfig: true + } + } + } +} + +export default meta diff --git a/web/src/render/main.js b/web/src/render/main.js index ca68f229..e097d4ca 100644 --- a/web/src/render/main.js +++ b/web/src/render/main.js @@ -3,6 +3,7 @@ import App from './App.vue' import EventBus from './utils/eventbus' import router from './router' import { createPinia } from 'pinia' +import 'default-passive-events' const app = createApp(App) const pinia = createPinia()