feature:概念测试方案配置添加

This commit is contained in:
王博冉
2024-08-25 23:49:25 +08:00
parent 58b070f43d
commit 8cc9e33abe
10 changed files with 2974 additions and 1 deletions

View File

@@ -0,0 +1,73 @@
<template>
<a-modal
:visible="modalVisible"
:maskClosable="false"
:destroyOnClose="true"
:closable="false"
:footer="null"
width="100%"
wrapClassName="my-concept-full-modal"
>
<concept-layout :testType="testType" @closeConceptModal="closeModal" />
</a-modal>
</template>
<script setup>
import { defineExpose, ref, defineProps } from "vue";
import ConceptLayout from "./components/ConceptLayout.vue";
import { Modal } from 'ant-design-vue'
const modalVisible = ref(false);
const testType = ref(1)
const openModal = (type) => {
if(type) {
testType.value = type
}
modalVisible.value = true;
};
const closeModal = () => {
Modal.confirm({
title: '确定退出?',
content: '退出后当前方案配置无法恢复!',
cancelText: '取 消',
okText: '确 定',
class: 'custom-modal custom-modal-title-confirm-notice',
onOk: () => {
modalVisible.value = false;
},
onCancel: () => {}
})
};
defineExpose({
openModal,
});
</script>
<style lang="scss" scoped></style>
<style lang="scss">
.my-concept-full-modal {
.ant-modal {
max-width: 100%;
top: 0;
padding-bottom: 0;
margin: 0;
}
.ant-modal-content {
display: flex;
flex-direction: column;
height: calc(100vh);
}
.ant-modal-body {
flex: 1;
padding: 0;
}
}
</style>

10
src/views/Concept/api.js Normal file
View File

@@ -0,0 +1,10 @@
import request from "@/utils/request";
/* 新增方案配置 */
export function postTemplates(sn,data) {
return request({
method: "POST",
url: `/console/templates/${sn}`,
data,
});
}

View File

@@ -0,0 +1,603 @@
<template>
<!-- 配置 -->
<div class="survey-box">
<div class="title">问卷配置</div>
<a-form ref="formRefSet" :model="ruleFormSet" :label-col="{ span: 4 }">
<a-form-item label="测试版本" name="testType">
<a-radio-group v-model:value="ruleFormSet.testType">
<a-radio :value="item.id" v-for="item in testVersionList" :key="item.id" @click="onChangeTestType(item)">{{ item.name }}</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
<StandardConceptConfig ref="standardConceptConfigRef"></StandardConceptConfig>
</div>
<div class="line"></div>
<div class="target-box">
<div class="target-box-head div-flex">
<div class="title">
<span class="title-span">指标配置</span>
<a-tooltip
placement="top"
:overlayStyle="{'width': '201px', 'border-radius': '6px'}"
:getPopupContainer="(el) => el.parentNode">
<i class="iconfont icon-xingzhuangjiehe2"></i>
<template #title>
<span style="font-weight: normal;font-size: 10px;">您在此处勾选拟调研的指标每个指标对应一道问题,问卷将针对每个概念分别询问被访者相关问题</span>
</template>
</a-tooltip>
</div>
<div v-if="ruleFormSet.testType === 1">
<a-checkbox
class="custom-checkbox"
:indeterminate="isIndeterminate"
:checked="isCheckedAll"
@change="toggleCheckAll">
全选
</a-checkbox>
</div>
</div>
<div class="target-box-content">
<div v-if="ruleFormSet.testType === 1">
<a-checkbox-group v-model:value="testOneSelectedValues">
<div class="check-box">
<div v-for="item in testOneData" :key="item.value" class="check-item">
<a-checkbox class="custom-checkbox my-checkbox" :value="item.value" :disabled="item.disabled">
<span :class="{'check-label': item.disabled}">
{{ item.label }}
</span>
</a-checkbox>
</div>
</div>
</a-checkbox-group>
</div>
<div v-if="ruleFormSet.testType === 2">
<a-checkbox-group v-model:value="testTwoSelectedValues">
<div class="check-box">
<div v-for="item in testTwoData" :key="item.value" class="check-item">
<a-checkbox class="custom-checkbox my-checkbox" :value="item.value" :disabled="item.disabled">
<span :class="{'check-label': item.disabled}">
{{ item.label }}
</span>
</a-checkbox>
</div>
</div>
</a-checkbox-group>
</div>
<div v-if="ruleFormSet.testType === 3">
<a-checkbox-group v-model:value="testThreeSelectedValues">
<div class="check-box">
<div v-for="item in testThreeData" :key="item.value" class="check-item check-item-three">
<a-checkbox class="custom-checkbox my-checkbox" :value="item.value" :disabled="item.disabled">
<span :class="{'check-label': item.disabled}">
{{ item.label }}
</span>
</a-checkbox>
</div>
</div>
</a-checkbox-group>
</div>
</div>
</div>
<div class="line"></div>
<div class="concept-box">
<div class="concept-box-head div-flex">
<div class="title">
<span class="title-span">概念配置</span>
<a-tooltip
placement="top"
:overlayStyle="{'width': '201px', 'border-radius': '6px'}"
:getPopupContainer="(el) => el.parentNode">
<i class="iconfont icon-xingzhuangjiehe2"></i>
<template #title>
<span style="font-weight: normal;font-size: 10px;">您在此处配置拟调研的概念问卷将针对每个概念分别询问被访者相关问题</span>
</template>
</a-tooltip>
</div>
<div v-if="ruleFormSet.testType !== 3 && isNoAdd">
<a-button class="custom-button" type="primary" @click="onAddConcept">
<PlusOutlined/> 新增概念
</a-button>
</div>
</div>
<div class="concept-box-content">
<!-- 标准版 -->
<draggable v-if="ruleFormSet.testType === 1"
v-model="concepts"
item-key="key"
animation="300"
:scroll="true"
class="drag-box"
handle=".moveConcept"
>
<template #item="{ element, index }">
<div class="concept-wrapper" :class="{'mar-7': index % 2 === 0, 'mal-7': index % 2 !== 0}">
<ConceptSetItem
v-model="element.data"
:key="element.key"
:index="index"
:allItem="concepts"
@changeType="onChangeType"
>
<div class="actions">
<i class="iconfont defaultIcon moveConcept">&#xe71b;</i>
<i class="iconfont filterDelIcon" @click="deleteConcept(element.key)">&#xe6b5;</i>
</div>
</ConceptSetItem>
</div>
</template>
</draggable>
<!-- 快测版 -->
<draggable v-if="ruleFormSet.testType === 2"
v-model="testTwoConcepts"
item-key="key"
animation="300"
:scroll="true"
class="drag-box"
handle=".moveTwoConcept"
>
<template #item="{ element, index }">
<div class="concept-wrapper" :class="{'mar-7': index % 2 === 0, 'mal-7': index % 2 !== 0}">
<ConceptSetItem
v-model="element.data"
:key="element.key"
:index="index"
:allItem="testTwoConcepts"
>
<div class="actions">
<i class="iconfont defaultIcon moveTwoConcept">&#xe71b;</i>
<i class="iconfont filterDelIcon" @click="deleteConcept(element.key)">&#xe6b5;</i>
</div>
</ConceptSetItem>
</div>
</template>
</draggable>
<!-- 配对版 -->
<draggable v-if="ruleFormSet.testType === 3"
v-model="testThreeConcepts"
item-key="key"
animation="300"
:scroll="true"
class="drag-box"
handle=".moveThreeConcept"
>
<template #item="{ element, index }">
<div class="concept-wrapper" :class="{'mar-7': index % 2 === 0, 'mal-7': index % 2 !== 0}">
<ConceptSetItem
v-model="element.data"
:key="element.key"
:index="index"
>
<div class="actions">
<i class="iconfont defaultIcon moveThreeConcept">&#xe71b;</i>
<!-- <i class="iconfont filterDelIcon" @click="deleteConcept(element.key)">&#xe6b5;</i> -->
</div>
</ConceptSetItem>
</div>
</template>
</draggable>
<!-- <div
v-for="(concept, index) in concepts"
:key="concept.key"
class="concept-wrapper"
:class="{'mar-7': index % 2 === 0, 'mal-7': index % 2 !== 0}"
>
<ConceptSetItem
v-model="concept.data"
:key="concept.key"
:index="index"
>
<div class="actions">
<i class="iconfont defaultIcon">&#xe71b;</i>
<i class="iconfont filterDelIcon" @click="deleteConcept(concept.key)">&#xe6b5;</i>
</div>
</ConceptSetItem>
</div> -->
</div>
</div>
</template>
<script setup>
import { defineEmits, ref, reactive, onBeforeMount, watch, computed } from 'vue'
import StandardConceptConfig from './StandardConceptConfig.vue'
import conceptData from '../json/concept.json'
import { PlusOutlined } from '@ant-design/icons-vue';
import ConceptSetItem from './ConceptSetItem.vue'
import { message } from 'ant-design-vue';
import draggable from "vuedraggable";
import { v4 as uuidv4 } from "uuid";
const emits = defineEmits(["changeTestType", "submitInfo"]);
const props = defineProps(["testType"]);
const formRefSet = ref();
const ruleFormSet = reactive({
testType: props.testType || 1,
})
/** 测试版本 */
const testVersionList = [
{ id: 1, name: '标准版' },
{ id: 2, name: '快测版' },
{ id: 3, name: '配对版' },
]
/** 标准版 */
const testOneData = ref(conceptData.testOneCheckInfo)
const testOneSelectedValues = ref(testOneData.value.filter(item => item.disabled).map(item => item.value));
const isCheckedAll = computed(() => {
return testOneSelectedValues.value.length === testOneData.value.length;
});
const isIndeterminate = ref(true);
watch(testOneSelectedValues, () => {
const selectedCount = testOneSelectedValues.value.length;
const totalCount = testOneData.value.length;
const disabledCount = testOneData.value.filter(item => item.disabled).length;
if(selectedCount === totalCount) {
isIndeterminate.value = false;
}else{
isIndeterminate.value = true;
}
}, { immediate: true });
const toggleCheckAll = () => {
if (isCheckedAll.value) {
// 如果已全选则取消选中所有非disabled项
testOneSelectedValues.value = testOneData.value.filter(item => item.disabled).map(item => item.value);
} else {
// 否则选中所有非disabled项
testOneSelectedValues.value = testOneData.value.map(item => item.value);
}
};
// 概念配置
const concepts = ref([
{ key: uuidv4(), data: { concept_type: 0, concept_name: '', concept_url: '' } },
{ key: uuidv4(), data: { concept_type: 1, concept_name: '', concept_url: '' } }
]);
const isNoAdd = computed(() => {
if(ruleFormSet.testType === 1 && concepts.value.length === 8) {
return false
}else if(ruleFormSet.testType === 2 && testTwoConcepts.value.length === 8) {
return false
}
return true
})
// 新增概念
const onAddConcept = () => {
let item = { key: uuidv4(), data: { concept_type: 1, concept_name: '', concept_url: '' } }
if(ruleFormSet.testType === 1) {
concepts.value.push(item);
}else if(ruleFormSet.testType === 2) {
testTwoConcepts.value.push(item);
}
}
// 删除
function deleteConcept(id) {
if(ruleFormSet.testType === 1) {
let checkList = concepts.value.filter(concept => concept.key !== id);
if(checkConceptType(checkList)) {
concepts.value = concepts.value.filter(concept => concept.key !== id);
}
}else if(ruleFormSet.testType === 2) {
let checkList = testTwoConcepts.value.filter(concept => concept.key !== id);
if(checkConceptType(checkList)) {
testTwoConcepts.value = testTwoConcepts.value.filter(concept => concept.key !== id);
}
}
}
/** 快测版 */
const testTwoData = ref(conceptData.testTwoCheckInfo)
const testTwoSelectedValues = ref(testTwoData.value.filter(item => item.disabled).map(item => item.value));
// 标准版-概念配置
const testTwoConcepts = ref([
{ key: uuidv4(), data: { concept_type: 0, concept_name: '', concept_url: '' } },
{ key: uuidv4(), data: { concept_type: 1, concept_name: '', concept_url: '' } }
]);
/** 配对版 */
const testThreeData = ref(conceptData.testThreeCheckInfo)
const testThreeSelectedValues = ref(testThreeData.value.filter(item => item.disabled).map(item => item.value));
// 概念配置
const testThreeConcepts = ref([
{ key: uuidv4(), data: { noType: true, concept_name: '', concept_url: '' } },
{ key: uuidv4(), data: { noType: true, concept_name: '', concept_url: '' } }
]);
onBeforeMount(() => {
})
// 修改测试版本
const onChangeTestType = (val) => {
emits('changeTestType', val)
}
const standardConceptConfigRef = ref()
// 方案配置数据
const getConceptInfo = () => {
// 必填问卷名称
standardConceptConfigRef.value.formRef.validate()
.then(() => {
let params = {
...standardConceptConfigRef.value.ruleForm
}
if(ruleFormSet.testType === 1) {
console.log('testOneData', testOneData.value);
console.log('testOneSelectedValues', testOneSelectedValues.value);
const enabledItems = testOneData.value.filter(item => !item.disabled);
const result = enabledItems.map(item => {
const matchedField = testOneSelectedValues.value.find(field => item.value.includes(field));
if (matchedField) {
return { type: matchedField, is_select: 1 };
}
return null;
}).filter(item => item !== null);
console.log('result', result);
params.concept_indexes = result;
params.concepts = concepts.value.map(item => item.data);
}
if(ruleFormSet.testType === 2) {
console.log('testTwoData', testTwoData.value);
console.log('testTwoSelectedValues', testTwoSelectedValues.value);
const enabledItems = testTwoData.value.filter(item => !item.disabled);
const result = enabledItems.map(item => {
const matchedField = testTwoSelectedValues.value.find(field => item.value.includes(field));
if (matchedField) {
return { type: matchedField, is_select: 1 };
}
return null;
}).filter(item => item !== null);
console.log('result', result);
params.concept_indexes = result;
params.concepts = testTwoConcepts.value.map(item => item.data);
}
if(ruleFormSet.testType === 3) {
params.concepts = testThreeConcepts.value.map(item => item.data);
}
console.log('params', params);
emits('submitInfo', params)
})
.catch((error) => {
console.log("error", error);
});
}
// 修改概念类型
const onChangeType = (typeVal) => {
console.log('typeVal', typeVal);
console.log('ruleFormSet.testType', ruleFormSet.testType);
let checkList = []
if(ruleFormSet.testType === 1) {
checkList = concepts.value
console.log('checkList修改前', checkList);
checkList[typeVal.index].data.concept_type = typeVal.type
console.log('checkList[typeVal.index]', checkList[typeVal.index].data);
console.log('checkList修改后', checkList);
if(checkConceptType(checkList)) {
concepts.value[typeVal.index].data.concept_type = typeVal.type
}
}
}
// 判断 concept_type 是否至少有一个 0 和一个 1
function checkConceptType(arr) {
if(arr.filter(item => item.data.noType).length > 0){
return true
}
const type0Count = arr.filter(item => item.data.concept_type === 0).length;
console.log('type0Count', arr);
const type1Count = arr.filter(item => item.data.concept_type === 1).length;
// 判断是否只有一个 concept_type 为 0且只有一个 concept_type 为 1
if (type0Count >= 1 && type1Count >= 1) {
return true;
}else if(type0Count < 1){
message.error('至少配置一个标杆概念')
return false;
}else if(type1Count < 1) {
message.error('至少配置一个新品概念')
return false;
}
return true;
}
defineExpose({
getConceptInfo
});
</script>
<style lang="scss" scoped>
.line {
width: 100%;
height: 1px;
background: #E8E8E8;
}
.target-box {
margin-top: 18px;
margin-bottom: 18px;
:deep(.ant-tooltip-inner) {
border-radius: 6px;
}
}
.iconfont {
color: #70B936;
margin-left: 9px;
}
.title-span {
font-size: 16px;
}
.div-flex {
display: flex;
justify-content: space-between;
}
.check-box {
display: flex;
flex-wrap: wrap;
}
.check-item {
width: 116px;
font-size: 13px;
display: flex;
align-content: center;
:deep(.ant-checkbox-wrapper) {
span {
font-size: 12px;
}
}
}
.check-item-three {
width: 150px;
}
.concept-box {
margin-top: 18px;
}
.check-label {
&::after {
display: inline-block;
margin-left: 4px;
color: #ff4d4f;
font-size: 12px;
content: '*';
}
}
.my-checkbox {
:deep(.ant-checkbox-disabled .ant-checkbox-inner) {
background-color: #D5EBC3 !important;
border-color: #D5EBC3 !important;
}
}
.concept-box-content {
display: flex;
flex-wrap: wrap;
.drag-box {
width: 100%;
display: flex;
flex-wrap: wrap;
}
.concept-wrapper {
width: calc(50% - 7px);
margin-bottom: 18px;
}
.mar-7 {
margin-right: 7px;
}
.mal-7 {
margin-left: 7px;
}
}
.actions {
.defaultIcon {
cursor: pointer;
color: #dadada;
font-size: 22px;
transition: all .3s ease-in-out;
&:hover{
color: #70b936;
}
}
.filterDelIcon{
cursor: pointer;
color: #dadada;
margin-left: 12px;
font-size: 22px;
transition: all .3s ease-in-out;
&:hover{
color: #70b936;
}
}
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<div class="layout">
<!-- 头部导航栏 -->
<div class="header">
<div @click="quitConcept" class="left">
<i class="icon iconfont icon-fanhui"></i>
<span>退出方案配置</span>
</div>
<div class="action-container">
<a-button class="custom-button" type="primary" @click="saveConcept">保存方案</a-button>
</div>
</div>
<div class="content-box">
<!-- 配置 -->
<div class="content scrollbar">
<ConceptConfig ref="conceptConfigRef" :testType="testType" @changeTestType="onChangeTestType" @submitInfo="onSubmit"></ConceptConfig>
</div>
<!-- 模版详情 -->
<div class="content scrollbar">
<div class="template-box">
<div class="title">模板详情</div>
<ConceptTemplate :type="testTypeInfo.id"></ConceptTemplate>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, ref } from 'vue'
import ConceptTemplate from './ConceptTemplate.vue'
import ConceptConfig from './ConceptConfig.vue'
import { Modal, message } from 'ant-design-vue'
import { postTemplates } from '../api'
const props = defineProps(["testType"]);
const emits = defineEmits(["closeConceptModal"]);
const quitConcept = () => {
emits('closeConceptModal')
}
const conceptConfigRef = ref()
// 保存方案接口
const onSubmit = async(params) => {
console.log('提交 params', params);
if(validateConceptNames(params.concepts) && checkConceptType(params.concepts)) {
Modal.confirm({
title: '确定保存?',
content: '保存方案后无法编辑,请您再次确认已完成最终配置。',
cancelText: '取 消',
okText: '确 定',
class: 'custom-modal custom-modal-title-confirm-notice',
onOk: async() => {
return
const res = await postTemplates(sn, params)
console.log('res', res);
if(res.code === 200) {
console.log('成功了');
// router.push({
// path: "/survey/planet/design",
// query: { sn: data.sn },
// });
}
},
onCancel: () => {}
})
}
}
// 判断 concept_type 是否至少有一个 0 和一个 1
function checkConceptType(arr) {
if(arr.filter(item => item.noType).length > 0){
return true
}
const type0Count = arr.filter(item => item.concept_type === 0).length;
const type1Count = arr.filter(item => item.concept_type === 1).length;
// 判断是否只有一个 concept_type 为 0且只有一个 concept_type 为 1
if (type0Count >= 1 && type1Count >= 1) {
return true;
}else if(type0Count < 1){
message.error('至少配置一个标杆概念')
return false;
}else if(type1Count < 1) {
message.error('至少配置一个新品概念')
return false;
}
return true;
}
// 检查 concept_name 是否为空,并且检查重复的名称
function validateConceptNames(arr) {
const names = arr.map(item => item.concept_name);
// 检查是否有空名称
const emptyNameExists = names.some(name => name.trim() === "");
if (emptyNameExists) {
message.error("概念名称不可为空,请检查后重试!")
return false;
}
// 检查是否有重复名称
const nameCounts = names.reduce((acc, name) => {
acc[name] = (acc[name] || 0) + 1;
return acc;
}, {});
const duplicateNames = Object.keys(nameCounts).filter(name => nameCounts[name] > 1);
if (duplicateNames.length > 0) {
// return `重复名称:${duplicateNames.join(', ')}。请重新填写。`;
message.error('概念名称不可重复,请检查后重试!')
return false;
}
console.log('名称验证通过。');
return true;
}
// 保存方案 按钮
const saveConcept = () => {
// const params = {
// // ...
// }
// console.log('保存方案', params);
conceptConfigRef.value.getConceptInfo()
}
const testTypeInfo = ref({ id: 1, name: '标准版' })
const onChangeTestType = (val) => {
console.log('val', val);
testTypeInfo.value = val
}
</script>
<style lang="scss" scoped>
.layout {
min-width: 1200px;
background-color: #f5f5f5;
.header {
height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 0 24px;
background: #fff;
box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.06);
.left {
cursor: pointer;
display: flex;
align-items: center;
.iconfont {
font-size: 20px;
color: #434343;
}
span {
font-size: 16px;
color: #434343;
line-height: 24px;
margin-left: 8px;
}
}
.action-container {
display: flex;
align-items: center;
}
}
.content-box {
padding: 24px;
display: flex;
justify-content: space-between;
.content{
padding: 22px;
:deep(.title) {
font-size: 16px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
line-height: 24px;
position: relative;
padding-left: 15px;
margin-bottom: 18px;
&::after {
content: "";
width: 4px;
height: 16px;
background: #70b936;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}
}
}
}
.content {
width: calc(50% - 12px);
padding: 0 24px;
height: calc(100vh - 150px);
overflow: auto;
overflow: overlay;
background-color: #fff;
border-radius: 6px;
position: relative;
transition: all 0.2s;
max-width: 1280px;
.content-header {
height: 70px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 24px;
.ch-title {
font-size: 16px;
font-weight: 500;
color: #262626;
span {
font-size: 10px;
color: #999;
line-height: 18px;
margin-left: 6px;
font-weight: 400;
}
}
}
.tabs {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
.tab {
width: 94px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 1px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid #e1e1e1;
cursor: pointer;
.iconfont {
font-size: 20px;
margin-right: 4px;
color: rgba(0, 0, 0, 0.65);
}
.phone-icon {
font-size: 16px;
}
span {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
}
.tab-l {
border-radius: 4px 0px 0px 4px;
}
.tab-r {
border-radius: 0px 4px 4px 0px;
}
.active {
border-color: #70b936;
background: rgba(255, 255, 255, 1);
span,
.iconfont {
color: #70b936;
}
}
}
.phone {
margin: 0 auto;
:deep(.ant-spin-nested-loading) {
height: 100%;
}
:deep(.ant-spin-container) {
height: 100%;
}
}
.open {
top: calc(50% - 127px);
position: absolute;
width: 30px;
height: 254px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #bfbfbf;
cursor: pointer;
}
.open-left {
right: 5px;
}
.open-right {
left: 5px;
}
.open:hover {
background: #f9f9f9;
}
.open:active {
color: #70b936;
}
}
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<div class="concept-item">
<div class="item-btn">
<div class="tab-header">
<div v-if="!conceptItem?.noType" class="tab-btn tab-btn-one" :class="{'btn-actived': conceptItem.concept_type === 0}" @click="onChangeType(0)">标杆概念</div>
<div v-if="!conceptItem?.noType" class="tab-btn tab-btn-two" :class="{'btn-actived': conceptItem.concept_type === 1}" @click="onChangeType(1)">新品概念</div>
</div>
<div class="actions">
<slot></slot>
</div>
</div>
<div class="concept-name">
<a-input
style="border-radius: 4px;width: 100%"
v-model:value="conceptItem.concept_name"
placeholder="请输入概念名称"
:maxlength="30"
showCount
@blur="onBlur"
>
<template #suffix>
<span class="suffix">
{{ `${conceptItem?.concept_name?.length} / 30` }}
</span>
</template>
</a-input>
</div>
<a-spin :spinning="uploadLoading">
<a-upload-dragger
class="my-upload-dragger"
:showUploadList="false"
:customRequest="uploadFile"
@change="handleChange($event, item, index)"
accept="image/*"
>
<div class="packing-upload upload-btn">
<img class="u-icon" src="@/assets/img/upload_img.png" />
<div class="u-title">上传图片</div>
<div class="u-desc">不能超过2M</div>
<div class="u-desc">不能超过2M 建议尺寸300×200px</div>
</div>
</a-upload-dragger>
</a-spin>
</div>
</template>
<script setup>
import { defineEmits, defineProps, nextTick, ref, watch } from 'vue';
import CommonApi from "@/api/common.js";
import { message } from 'ant-design-vue';
const props = defineProps({
index: Number,
modelValue: Object,
allItem: Array,
});
const emits = defineEmits(['update:modelValue']);
const conceptItem = ref({ ...props.modelValue })
watch(conceptItem, (newVal) => {
emits('update:modelValue', newVal);
}, { deep: true });
const uploadLoading = ref(false);
// 自定义上传文件
async function uploadFile(e) {
uploadLoading.value = true;
const { file, onSuccess } = e;
const fileName = file.name.replaceAll(" ", "");
// 校验文件类型
if (file.type.split("/")[0] !== "image") {
uploadLoading.value = false;
return message.error("请上传图片");
}
// 不能超过2M
if (file.size > 2 * 1024 * 1024) {
uploadLoading.value = false;
return message.error("图片不能超过2M");
}
// 上传文件到oss
const result = await CommonApi.cosUpload(
file,
`packing/imgs/${new Date().getTime()}_${Math.floor(Math.random() * 1000)}_${fileName}`
);
console.log('result', result);
if(result?.url) {
conceptItem.value.concept_url = result.url
onSuccess({ url: result.url }, file);
}else{
message.error('上传失败')
}
uploadLoading.value = false;
// 上传成功
}
// 上传文件改变时的状态
function handleChange(info, index) {
const status = info.file.status;
if (status === 'uploading') {
uploadLoading.value = true;
}
if (status === "done") {
message.success(`${info.file.name} 上传成功.`);
uploadLoading.value = false;
} else if (status === "error") {
message.error(`${info.file.name} 上传失败.`);
uploadLoading.value = false;
}
}
// 修改概念类型
const onChangeType = (type) => {
let oldType = conceptItem.value.concept_type
conceptItem.value.concept_type = type
setTimeout(() => {
if(!checkConceptType(props.allItem)) {
setTimeout(() => {
conceptItem.value.concept_type = oldType
}, 100)
}
})
}
// 判断 concept_type 是否至少有一个 0 和一个 1
function checkConceptType(arr) {
if(arr.filter(item => item.data.noType).length > 0){
return true
}
const type0Count = arr.filter(item => item.data.concept_type === 0).length;
const type1Count = arr.filter(item => item.data.concept_type === 1).length;
if (type0Count >= 1 && type1Count >= 1) {
return true;
}else if(type0Count < 1){
message.error('至少配置一个标杆概念')
return false;
}else if(type1Count < 1) {
message.error('至少配置一个新品概念')
return false;
}
}
// 失焦校验
const onBlur = () => {
console.log('proos', props.allItem);
// return
const names = props.allItem.map(item => item.data.concept_name);
// 检查是否有空名称
const emptyNameExists = names.some(name => name.trim() === "");
if (emptyNameExists) {
return false;
}
// 检查是否有重复名称
const nameCounts = names.reduce((acc, name) => {
acc[name] = (acc[name] || 0) + 1;
return acc;
}, {});
const duplicateNames = Object.keys(nameCounts).filter(name => nameCounts[name] > 1);
if (duplicateNames.length > 0) {
// return `重复名称:${duplicateNames.join(', ')}。请重新填写。`;
message.error('概念名称不可重复,请检查后重试!')
return false;
}
}
</script>
<style lang="scss" scoped>
.concept-item {
padding: 14px;
border-radius: 8px;
position: relative;
background: #F9F9F9;
.item-btn {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.concept-name {
margin-bottom: 12px;
}
}
// .actions {
// .defaultIcon {
// cursor: pointer;
// color: #dadada;
// font-size: 22px;
// transition: all .3s ease-in-out;
// &:hover{
// color: #70b936;
// }
// }
// .filterDelIcon{
// cursor: pointer;
// color: #dadada;
// margin-left: 12px;
// font-size: 22px;
// transition: all .3s ease-in-out;
// &:hover{
// color: #70b936;
// }
// }
// }
.tab-header {
display: flex;
.tab-btn {
width: 68px;
height: 26px;
opacity: 1;
text-align: center;
line-height: 26px;
background: #fff;
cursor: pointer;
}
.tab-btn-one {
border-radius: 4px 0 0 4px;
}
.tab-btn-two {
border-radius: 0 4px 4px 0;
}
.btn-actived {
color: #FFFFFF;
background: #70B936;
}
}
.packing-upload {
// width: 176px;
// height: 176px;
}
.upload-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
// background: #f4faef;
img {
width: 50px;
height: 50px;
margin-bottom: 6px;
}
.u-title {
font-size: 14px;
// font-weight: 500;
color: #70b936;
line-height: 20px;
margin-bottom: 14px;
}
.u-desc {
font-size: 12px;
color: #70b936;
line-height: 18px;
margin-bottom: 6px;
}
}
.my-upload-dragger {
:deep(.ant-upload) {
border-width: 0px;
background-color: #fff;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="template-content-box">
<div class="head-title">
<div class="head-title-one">液态奶产品研究标准化问卷-概念测试内测{{ typeTitle }}</div>
<div class="head-title-two">样本量要求60</div>
</div>
<div class="question-content" v-for="(item, index) in questionInfoList" :key="index">
<div class="question-title" v-if="item?.title">{{ item.title }}</div>
<div class="mb-8" v-if="item?.topic">{{ item.topic }}</div>
<div class="question-tip mb-8" v-if="item?.tip">{{ item.tip }}</div>
<div class="mb-8" v-for="it in item.ans" :key="it">{{ it }}</div>
<div class="div-center mb-8" v-if="item?.center">{{ item.center }}</div>
<div class="div-table" v-if="item?.table">
<table>
<thead>
<tr>
<th v-for="(header, index) in item.table.headers" :key="index">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in item.table.rows" :key="rowIndex">
<td v-for="(header, colIndex) in item.table.headers" :key="colIndex">{{ row[item.table.headerMap[header]] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { computed, defineProps } from 'vue'
import { questionInfoOne, questionInfoTwo, questionInfoThree } from '@/views/Concept/json/concept.json'
const props = defineProps({
type: {
type: [Number, String],
default: 0
},
})
const testType = computed(() => props.type)
const typeTitle = computed(() => testType.value === 1 ? '标准版' : testType.value === 2 ? '快测版' : testType.value === 3 ? '配对版' : '')
const questionInfoList = computed(() => testType.value === 1 ? questionInfoOne : testType.value === 2 ? questionInfoTwo : questionInfoThree)
</script>
<style lang="scss" scoped>
.template-content-box {
.head-title {
text-align: center;
.head-title-one {
font-size: 20px;
margin-top: 24px;
}
.head-title-two {
font-size: 20px;
margin-top: 8px;
margin-bottom: 22px;
}
}
.question-content {
.question-title {
font-size: 16px;
margin-bottom: 6px;
}
.question-tip {
color: #70B936;
}
}
}
.mb-8 {
margin-bottom: 8px;
}
.div-center {
width: 100%;
text-align: center;
}
.div-table {
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid #ccc;
padding: 10px;
text-align: center;
}
th {
background-color: #f8f8f8;
font-weight: bold;
// color: red;
}
td:first-child {
text-align: left;
}
td {
// width: 33%;
}
}
</style>

View File

@@ -0,0 +1,433 @@
<template>
<a-form ref="formRef" :model="ruleForm" :rules="rules" :label-col="{ span: 4 }">
<a-form-item label="问卷名称" name="project_name">
<a-input
style="border-radius: 4px;"
v-model:value="ruleForm.project_name"
placeholder="请输入问卷名称"
:maxlength="30"
showCount
>
<template #suffix>
<span class="suffix">
{{ `${ruleForm.project_name.length} / 30` }}
</span>
</template>
</a-input>
</a-form-item>
<a-form-item label="问卷场景" name="scene_code_info" v-if="isShow">
<a-select
disabled
v-model:value="ruleForm.scene_code_info"
style="width: 100%;border-radius: 4px;"
placeholder="请选择场景"
@change="handleSceneChange"
class="custom-select show-select"
:dropdownStyle="{zIndex: 10000}"
>
<a-select-option
:value="`${item.code}`"
:label="item.title"
v-for="item in scenesList"
:key="`${item.code}`"
>
{{item.parentTitle}}-{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="问卷标签" name="tags" v-if="isShow">
<a-select
v-model:value="ruleForm.tags"
style="width: 100%;border-radius: 4px;"
mode="multiple"
placeholder="搜索或新建标签"
@change="handleChange"
:filterOption="filterOption"
class="custom-select show-select"
:dropdownStyle="{zIndex: 10000}"
>
<a-select-option
:value="item.id"
:label="item.title"
v-for="item in tagsList"
:key="item.id"
>
<div style="display: flex; justify-content: space-between">
<span :style="countColor(item.color)" :title="item.title">{{ item.title }}</span>
<span class="icon" v-show="isAdmin">
<EditOutlined class="edit" @click.stop="edit(item)" />
<DeleteOutlined class="del" @click.stop="del(item.id)" />
</span>
</div>
</a-select-option>
<template #dropdownRender="{ menuNode: menu }" v-if="isAdmin">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<div
style="padding: 2px 8px; cursor: pointer; color:#70b936"
@mousedown="(e) => e.preventDefault()"
@click="onAddTag"
>
<plus-outlined />
新建标签
</div>
</template>
</a-select>
</a-form-item>
<a-form-item label="问卷简介" name="remarks" v-if="isShow">
<a-input
style="border-radius: 4px;"
autoSize
:maxlength="150"
placeholder="请输入"
allowClear
v-model:value="ruleForm.remarks"
>
<template #suffix>
<span class="suffix">
{{ `${ruleForm.remarks.length} / 150` }}
</span>
</template>
</a-input>
</a-form-item>
<!-- <a-form-item class="button" style="text-align: right">
<a-button style="margin-right: 12px;border-radius: 4px;" type="default" @click="$emit('cancel')">取消</a-button>
<a-button type="primary" style="border-radius: 4px;" @click="onSubmit">确定</a-button>
</a-form-item> -->
</a-form>
<a-modal v-model:visible="visibleTags" title="新建标签" :destroyOnClose="true" :footer="null">
<addTag @cancel="visibleTags = false" @update="addTagUpdata"></addTag>
</a-modal>
</template>
<script>
import { defineComponent, reactive, ref, watch, onBeforeMount, createVNode } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { message, Modal } from "ant-design-vue";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons-vue";
import {
getTagsList,
deleteTags,
getSceneListForSelect
} from "@/views/ProjectManage/api";
import addTag from "@/views/ProjectManage/components/addTag.vue";
import useEmitter from "@/composables/useEmitter";
export default defineComponent({
name: "StandardConceptConfig",
components: {
addTag,
PlusOutlined,
DeleteOutlined,
EditOutlined,
VNodes: (_, { attrs }) => {
return attrs.vnodes;
}
},
setup(props, context) {
const store = useStore();
const emitter = useEmitter();
const items = ref([]);
const router = useRouter();
const formRef = ref();
const tagsList = ref([]);
const loading = ref(false);
const scenesList = ref([]);
const isAdmin = ref(false);
const isShow = ref(true);
const ruleForm = reactive({
project_name: "",
tags: [],
scene_code_info: '22',
remarks: '',
});
const rules = {
project_name: [
{ required: true, message: "请输入问卷名称", trigger: "blur" },
{ min: 1, max: 30, message: "字数超过限制", trigger: "blur" },
],
scene_code_info: [
{ required: true, message: "请选择场景", trigger: "blur" },
],
};
const visibleTags = ref(false);
// 标签颜色
const countColor = (value) => {
let style = {};
switch (value) {
case 1:
style = {
color: "#4DB8FA",
border: "1px solid #4DB8FA",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 2:
style = {
color: "#0CC126",
// 'border-color' : '#0CC126',
border: "1px solid #0CC126",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 3:
style = {
color: "#FF8800",
// 'border-color' : '#FF8800',
border: "1px solid #FF8800",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 4:
style = {
color: "#FF374F",
// 'border-color' : '#FF374F',
border: "1px solid #FF374F",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 5:
style = {
color: "#1C6FFF",
// 'border-color' : '#1C6FFF',
border: "1px solid #1C6FFF",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 6:
style = {
color: "#11AEA7",
// 'border-color' : '#11AEA7',
border: "1px solid #11AEA7",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 7:
style = {
color: "#25D8C8",
// 'border-color' : '#25D8C8',
border: "1px solid #25D8C8",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
break;
case 8:
style = {
color: "#FECB0D",
// 'border-color' : '#FECB0D',
border: "1px solid #FECB0D",
padding: "0 5px",
"border-radius": "4px",
"line-height": "20px",
};
// break;
// case 9:
// style = {
// 'color' : '4DB8FA',
// 'border-color' : '4DB8FA',
// }
}
return style;
};
/** 获取项目标签列表 */
const getTagsListRequest = async (val) => {
try {
const { data } = await getTagsList();
tagsList.value = data;
} catch (error) {
context.message.error(error.data?.message || error.message || "服务器错误");
}
};
// 删除
const delRequest = async (id) => {
try {
const { data } = await deleteTags(id);
getTagsListRequest();
} catch (error) {
console.log(error);
}
};
const del = (id) => {
Modal.confirm({
content: "删除后,此标签会从当前团队的所有问卷中移除,且无法恢复!",
icon: () => createVNode(ExclamationCircleOutlined),
cancelText: "取消",
okText: "确认",
zIndex: 100001,
onOk: () => {
delRequest(id);
},
});
};
const edit = (item) => {
context.emit("labelEdit", item);
};
const addItem = () => {
emitter.emit("addGroup");
};
const onAddTag = () => {
visibleTags.value = true;
};
const addTagUpdata = () => {
visibleTags.value = false;
getTagsListRequest();
};
// const onSubmitStatus = () => {
// formRef.value
// .validate()
// .then(() => {
// })
// .catch((error) => {
// console.log("error", error);
// });
// };
const handleChange = (value) => {
};
const handleSceneChange = (value) => {
const item = scenesList.value.find(item => item.code === +value)
ruleForm.scene_code = item.parentCode;
};
const getScenes = async () => {
loading.value = true;
const data = await getSceneListForSelect();
if (data?.code) {
message.error(data?.message || "获取场景失败,请刷新!");
return;
}
loading.value = false;
scenesList.value = normalizeScenes(data?.data || []);
}
function normalizeScenes(list) {
const result = [];
const parent = [];
let index = 0;
list.forEach((item) => {
if (item.parentCode > 0) {
// if(!item.sn){
result.push(item);
// }
}else {
parent.push(item);
}
})
result.forEach(item => {
item.parentTitle = parent.find(pItem => pItem.code === item.parentCode).title.slice(0,2);;
})
result.sort((a, b) => a.sort - b.sort);
return result;
}
function filterOption(inputValue, option) {
const reg = new RegExp(inputValue);
const result = reg.test(option.label);
return result;
}
onBeforeMount(() => {
getTagsListRequest();
getScenes();
var user = localStorage.getItem("plantUserInfo");
user = JSON.parse(user);
isAdmin.value = user?.super_admin_flag;
});
return {
formRef,
rules,
ruleForm,
// onSubmitStatus,
loading,
value: ref([]),
handleChange,
handleSceneChange,
options: [],
del,
edit,
items,
addItem,
onAddTag,
tagsList,
filterOption,
getTagsListRequest,
visibleTags,
addTagUpdata,
countColor,
isShow,
scenesList,
getScenes,
isAdmin
};
},
});
</script>
<style lang="scss" scoped>
.icon {
font-size: 16px;
.del {
margin: 0 10px;
}
}
.tags {
margin: 0 5px 5px;
min-width: 75px;
width: 48px;
height: 19px;
border-radius: 4px;
// position: relative;
box-sizing: border-box;
.title {
// position: absolute;
font-size: 12px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
line-height: 17px;
display: flex;
justify-content: center;
}
.tagStyle {
border-radius: 4px;
max-width: 120px;
padding: 0 5px;
margin: 0 5px;
display: inline-block;
border-width: 1px;
border-style: solid;
color: #4db8fa;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.show-select {
.icon {
display: none !important;
}
}
::v-deep .ant-input-textarea-clear-icon {
margin: 4px 3px 0 0;
}
.show-select:deep(.ant-select-selector){
border-radius:4px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -152,6 +152,11 @@
:curTemp="curTemp"
@labelEdit="labelEdit"
/>
<!-- 概念测试 -->
<ConceptTesting
ref="conceptTestingRef"
/>
</div>
</template>
@@ -185,6 +190,8 @@ import {useStore} from "vuex";
import {currentMode} from "@/config";
import { LeftCircleOutlined, RightCircleOutlined } from '@ant-design/icons-vue';
import ConceptTesting from "@/views/Concept/ConceptTesting.vue"
const loading = ref(false);
const router = useRouter();
@@ -393,7 +400,8 @@ function createNormalSurvey(item){
}
function createProfessionalSurvey(record) {
// console.log('使用', record);
console.log('使用', record);
// return
curTemp.value = record;
temp_sn.value = record.sn;
groupInfo.value.scene_code_info = `${record.code}`;
@@ -411,6 +419,9 @@ function createProfessionalSurvey(record) {
curTemp.value.type = 1;
return createSurveySellRef.value.openModal();
}
else if ([36, 37, 38].includes(record.code)) {
return conceptTestingRef.value.openModal();
}
else{
Modal.confirm({
title: () => '创建问卷',
@@ -495,6 +506,8 @@ function professionalPrev(){
})
}
/** 概念测试 */
const conceptTestingRef = ref(null)
</script>
<style scoped lang="scss">

View File

@@ -178,6 +178,11 @@
:curTemp="curTemp"
:zIndex="10000"
/>
<!-- 概念测试 -->
<ConceptTesting
ref="conceptTestingRef"
/>
</div>
</template>
<script>
@@ -191,6 +196,8 @@ import Search from "../components/TempSearch.vue"
import { getGroupList } from "@/api/template-market";
import CreateSurvey from "@views/ProjectManage/components/NewCreateSurvey.vue";
import ConceptTesting from "@/views/Concept/ConceptTesting.vue"
import {
nextTick,
defineComponent,
@@ -275,6 +282,7 @@ export default defineComponent({
CreateSurveySell,
CreateSurveyProduct,
Search,
ConceptTesting,
},
props: {
groupId: { type: Number, value: 0 },
@@ -304,6 +312,7 @@ export default defineComponent({
const createSurveyRef = ref();
const createSurveySellRef = ref();
const createSurveyProductRef = ref();
const conceptTestingRef = ref(null); /** 概念测试 */
const editLabelVisible = ref(false);
const editLabelItem = ref({});
const preview_visible = ref(false);
@@ -555,6 +564,12 @@ export default defineComponent({
createSurveyProductRef.value.openModal();
} else if (+record.type === 1) {
createSurveySellRef.value.openModal();
} else if (record.type === 300) {
conceptTestingRef.value.openModal(1);
} else if (record.type === 301) {
conceptTestingRef.value.openModal(2);
} else if (record.type === 302) {
conceptTestingRef.value.openModal(3);
} else {
groupInfo.value.sn = "";
groupInfo.value.group_id = 0;
@@ -686,6 +701,7 @@ export default defineComponent({
createSurveyRef,
createSurveySellRef,
createSurveyProductRef,
conceptTestingRef,
handlePreview,
handleMove,
handleRemove,