feat: 问卷分析页面功能实现

- 修复路径别名问题
- 添加问卷类型映射工具
- 实现饼图数据格式化功能
- 优化图表组件传参方式
- 重构分析信息展示组件
This commit is contained in:
Huangzhe
2025-05-15 01:16:29 +08:00
parent bc6bd0200f
commit 9f8fc0a31a
11 changed files with 213 additions and 165 deletions

7
components.d.ts vendored
View File

@@ -11,6 +11,7 @@ declare module 'vue' {
Contenteditable: typeof import('./src/components/contenteditable.vue')['default'] Contenteditable: typeof import('./src/components/contenteditable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -22,6 +23,7 @@ declare module 'vue' {
ElSpace: typeof import('element-plus/es')['ElSpace'] ElSpace: typeof import('element-plus/es')['ElSpace']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText'] ElText: typeof import('element-plus/es')['ElText']
Index: typeof import('./src/components/Analysis/Index.vue')['default'] Index: typeof import('./src/components/Analysis/Index.vue')['default']
MarketItem: typeof import('./src/components/MarketItem/MarketItem.vue')['default'] MarketItem: typeof import('./src/components/MarketItem/MarketItem.vue')['default']
@@ -34,6 +36,7 @@ declare module 'vue' {
VanCellGroup: typeof import('vant/es')['CellGroup'] VanCellGroup: typeof import('vant/es')['CellGroup']
VanCheckbox: typeof import('vant/es')['Checkbox'] VanCheckbox: typeof import('vant/es')['Checkbox']
VanCheckboxGroup: typeof import('vant/es')['CheckboxGroup'] VanCheckboxGroup: typeof import('vant/es')['CheckboxGroup']
VanCol: typeof import('vant/es')['Col']
VanDivider: typeof import('vant/es')['Divider'] VanDivider: typeof import('vant/es')['Divider']
VanField: typeof import('vant/es')['Field'] VanField: typeof import('vant/es')['Field']
VanIcon: typeof import('vant/es')['Icon'] VanIcon: typeof import('vant/es')['Icon']
@@ -44,6 +47,7 @@ declare module 'vue' {
VanPopup: typeof import('vant/es')['Popup'] VanPopup: typeof import('vant/es')['Popup']
VanRadio: typeof import('vant/es')['Radio'] VanRadio: typeof import('vant/es')['Radio']
VanRadioGroup: typeof import('vant/es')['RadioGroup'] VanRadioGroup: typeof import('vant/es')['RadioGroup']
VanRow: typeof import('vant/es')['Row']
VanSearch: typeof import('vant/es')['Search'] VanSearch: typeof import('vant/es')['Search']
VanSpace: typeof import('vant/es')['Space'] VanSpace: typeof import('vant/es')['Space']
VanStepper: typeof import('vant/es')['Stepper'] VanStepper: typeof import('vant/es')['Stepper']
@@ -60,4 +64,7 @@ declare module 'vue' {
YLPicker: typeof import('./src/components/YLPicker.vue')['default'] YLPicker: typeof import('./src/components/YLPicker.vue')['default']
YLSelect: typeof import('./src/components/YLSelect.vue')['default'] YLSelect: typeof import('./src/components/YLSelect.vue')['default']
} }
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
} }

View File

@@ -11,3 +11,15 @@ export function analysisInsights (sn: string, params?: ParticalParams) {
params params
}); });
} }
/**
* ai 洞察检查分析是否完成
* @param sn 循环题组sn
* @param data {1|2} 1=AI样本标记;2=AI洞察
*/
export function checkAnalysisSatus(sn: string, data?: 1 | 2) {
return request({
url: `/console/surveys/${sn}/status`,
method: 'post',
data
});
}

View File

@@ -23,7 +23,7 @@ export function getSurveyInfo(sn) {
* @param data {import('@/api/types/analysis.js').AnalysisParam} * @param data {import('@/api/types/analysis.js').AnalysisParam}
* @returns {Promise<axios.AxiosResponse<any>> | *} * @returns {Promise<axios.AxiosResponse<any>> | *}
*/ */
export function surveyAnalysis(sn, data) { export function surveyAnalysis(sn, data = {}) {
return request({ return request({
url: `/console/surveys/${sn}/chart_analysis`, url: `/console/surveys/${sn}/chart_analysis`,
method: 'post', method: 'post',

View File

@@ -3,94 +3,23 @@ import { ref, useTemplateRef } from 'vue';
import { showToast } from 'vant'; import { showToast } from 'vant';
import { useSetPieChart } from '@/hooks/chart/usePieChart'; import { useSetPieChart } from '@/hooks/chart/usePieChart';
import { surveys } from '@/components/Analysis/hooks/useSurvey'; import { surveys } from '@/components/Analysis/hooks/useSurvey';
import { questionTypeMap } from '@/utils/question/typeMapping';
// const info = defineModel<any>('info', { required: true });
// series 信息
const series = defineModel<any>('series', { required: true });
// 饼图 dom 结构 // 饼图 dom 结构
const pieChart = useTemplateRef<HTMLSpanElement>('pieChart'); const pieChart = useTemplateRef<HTMLSpanElement>('pieChart');
useSetPieChart(pieChart, [1], { title: false, ledge: false }); useSetPieChart(pieChart, series.value, { title: false, ledge: false });
// 任务数据
const taskData = ref({
title: '问卷A',
progress: 100,
deadline: '2025-03-31至04-01',
creator: '张三',
creationMethod: '移动端',
creationTime: '2025-03-04',
responseCount: 10,
responseRate: '10%',
submissionRate: '10%',
status: '草稿中'
});
// 图表数据
const chartData = ref([
{
name: '选项1',
value: 66.77,
color: '#F56C6C'
},
{
name: '选项2',
value: 33.33,
color: '#F7BA2A'
},
{
name: '选项3',
value: 0,
color: '#67C23A'
},
{
name: '选项4',
value: 0,
color: '#409EFF'
}
]);
// 导航按钮点击事件
const handlePrev = () => {
showToast('点击了上一个问题');
};
const handleNext = () => {
showToast('点击了下一个问题');
};
</script> </script>
<template> <template>
<section> <section>
<!-- 卡片内容 -->
<van-space direction="vertical" fill style="width: 100%; padding-top: 16px">
<p>1. 能描述您为自己或家人购买这款产品A的可能性吗单选</p>
<!-- 图表部分 --> <!-- 图表部分 -->
<div style="display: flex; justify-content: center; margin: 16px 0"> <div style="display: flex; justify-content: center; margin: 16px 0">
<span ref="pieChart" style="width: 100%; height: 300px"></span> <span ref="pieChart" style="width: 100%; height: 300px"></span>
</div> </div>
<!-- 选项列表 -->
<van-cell-group inset>
<van-cell v-for="(item, index) in chartData" :key="index">
<template #title>
<van-tag :color="item.value > 0 ? item.color : '#dcdfe6'" plain>
{{ item.name }}
</van-tag>
</template>
<template #value>
<span>{{ item.value.toFixed(2) }}%</span>
</template>
</van-cell>
</van-cell-group>
</van-space>
<!-- 导航按钮 左右按钮-->
<!-- <div style="position: fixed; left: 16px; top: 50%; transform: translateY(-50%)">-->
<!-- <van-button round icon="arrow-left" @click="handlePrev" />-->
<!-- </div>-->
<!-- <div style="position: fixed; right: 16px; top: 50%; transform: translateY(-50%)">-->
<!-- <van-button round icon="arrow" @click="handleNext" />-->
<!-- </div>-->
</section> </section>
</template> </template>

View File

@@ -9,27 +9,27 @@ const pieChart = ref();
/** /**
* 定义数据集 * 定义数据集
*/ */
const series = ref<dataOption[]>([ // const series = ref<dataOption[]>([
{ // {
name: 'Access From', // name: 'Access From',
type: 'pie', // type: 'pie',
radius: '50%', // radius: '50%',
data: [ // data: [
{ value: 1048, name: 'Search Engine' }, // { value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' }, // { value: 735, name: 'Direct' },
{ value: 580, name: 'Email' }, // { value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' }, // { value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' } // { value: 300, name: 'Video Ads' }
], // ],
emphasis: { // emphasis: {
itemStyle: { // itemStyle: {
shadowBlur: 10, // shadowBlur: 10,
shadowOffsetX: 0, // shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)' // shadowColor: 'rgba(0, 0, 0, 0.5)'
} // }
} // }
} // }
]); // ]);
/** /**
* 饼图的 option * 饼图的 option
@@ -38,18 +38,18 @@ const option = ref(pieOption);
function useSetPieChart( function useSetPieChart(
dom: Readonly<ShallowRef<HTMLSpanElement | null>>, dom: Readonly<ShallowRef<HTMLSpanElement | null>>,
data: any[], series: any,
opts: optsType = {} opts: optsType = {}
): void { ): void {
for (let item in opts) { if (!series) return;
for (const item in opts) {
if (item === 'legend') !opts[item] && deleteLegend(); if (item === 'legend') !opts[item] && deleteLegend();
else if (item === 'title') !opts[item] && deleteTitle(); else if (item === 'title') !opts[item] && deleteTitle();
} }
// 检测边界范围 dom 和 data 是否存在 // 检测边界范围 dom 和 data 是否存在
onMounted(() => { onMounted(() => {
// console.log(dom); if (!dom || series.data.length === 0) return;
if (!dom || data.length === 0) return;
// 在 dom 挂载之后,显示饼图 // 在 dom 挂载之后,显示饼图
pieChart.value = chart.init(dom.value); pieChart.value = chart.init(dom.value);
pieChart.value.setOption(option.value, opts); pieChart.value.setOption(option.value, opts);

View File

@@ -0,0 +1,7 @@
const map = new Map<number, string>();
map.set(1, '单选题');
map.set(5, '数值打分题');
map.set(9, '矩阵单选');
export { map as questionTypeMap };

View File

@@ -11,7 +11,13 @@ import { showDialog, showFailToast, showSuccessToast } from 'vant';
import { finish } from '@/api/survey'; import { finish } from '@/api/survey';
import { copySurveys } from '@/api/home'; import { copySurveys } from '@/api/home';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { form, fetchSurveys, deleteItem, saveTemplate, currentSurvey } from '@/views/Survey/hooks/useSurveyData'; import {
form,
fetchSurveys,
deleteItem,
saveTemplate,
currentSurvey
} from '@/views/Survey/hooks/useSurveyData';
// router // router
const router = useRouter(); const router = useRouter();
@@ -119,7 +125,6 @@ function copyItem (item: SurveyItem) {
// on cancel // on cancel
}); });
} }
</script> </script>
<template> <template>
@@ -136,14 +141,25 @@ function copyItem (item: SurveyItem) {
{{ `${survey.start_time} ${survey.end_time ?? '无限期'}` }} {{ `${survey.start_time} ${survey.end_time ?? '无限期'}` }}
</el-text> </el-text>
<el-button v-if="isAnalysis" type="primary" size="small" @click="emit('post-analysis')">AI 洞察</el-button> <el-button
v-if="isAnalysis"
style="margin-left: 30px"
type="primary"
size="small"
@click="emit('post-analysis')"
>
AI 洞察
</el-button>
</div> </div>
<div class="survey_item_info_status"> <div class="survey_item_info_status">
<el-space spacer="|"> <el-space spacer="|">
<!--报名签到--> <!--报名签到-->
<div class="flex align-center"> <div class="flex align-center">
<img :src="setImg(survey.scene_code_info as number)" alt="Content Icon" <img
style="width: 15px; height: 15px" /> :src="setImg(survey.scene_code_info as number)"
alt="Content Icon"
style="width: 15px; height: 15px"
/>
<el-text size="small">{{ survey.owner }}</el-text> <el-text size="small">{{ survey.owner }}</el-text>
</div> </div>
<!-- 问卷来源 --> <!-- 问卷来源 -->
@@ -186,9 +202,7 @@ function copyItem (item: SurveyItem) {
<!-- action 功能位置 --> <!-- action 功能位置 -->
<div v-if="!isAnalysis" class="survey_item_action"> <div v-if="!isAnalysis" class="survey_item_action">
<div> <div>
<el-button :disabled="survey.source === 0" @click="editItem(survey)"> <el-button :disabled="survey.source === 0" @click="editItem(survey)"> 编辑 </el-button>
编辑
</el-button>
<!-- <el-button--> <!-- <el-button-->
<!-- style="border: 1px solid #71b73c"--> <!-- style="border: 1px solid #71b73c"-->
<!-- @click="toPreview(survey)"--> <!-- @click="toPreview(survey)"-->
@@ -205,19 +219,22 @@ function copyItem (item: SurveyItem) {
</el-text> </el-text>
</el-button> </el-button>
</div> </div>
<el-dropdown v-if="survey.source === 1" placement="top-end" trigger="click" active-color="#71b73c"> <el-dropdown
v-if="survey.source === 1"
placement="top-end"
trigger="click"
active-color="#71b73c"
>
<van-icon class-prefix="mobilefont" name="gengduo" size="0.7rem"></van-icon> <van-icon class-prefix="mobilefont" name="gengduo" size="0.7rem"></van-icon>
<template #dropdown> <template #dropdown>
<el-dropdown-menu active-color="#71b73c" :close-on-click-overlay="false" :close-on-click-outside="false"> <el-dropdown-menu
<el-dropdown-item @click="copyItem(survey)"> active-color="#71b73c"
复制 :close-on-click-overlay="false"
</el-dropdown-item> :close-on-click-outside="false"
<el-dropdown-item @click="deleteItem(survey)"> >
删除 <el-dropdown-item @click="copyItem(survey)"> 复制 </el-dropdown-item>
</el-dropdown-item> <el-dropdown-item @click="deleteItem(survey)"> 删除 </el-dropdown-item>
<el-dropdown-item @click="saveTemplate(survey)"> <el-dropdown-item @click="saveTemplate(survey)"> 保存为模板 </el-dropdown-item>
保存为模板
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -226,8 +243,8 @@ function copyItem (item: SurveyItem) {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/assets/css/base"; @import '@/assets/css/base';
@import "@/assets/css/main"; @import '@/assets/css/main';
.survey_item_info { .survey_item_info {
.survey_item_info_status { .survey_item_info_status {

View File

@@ -4,16 +4,21 @@ import SurveyItem from '@/views/Survey/components/SurveyItem.vue';
import LogicInfo from '@/views/Survey/views/Analysis/components/LogicInfo/Index.vue'; import LogicInfo from '@/views/Survey/views/Analysis/components/LogicInfo/Index.vue';
import AiInsightResults from '@/views/Survey/views/Analysis/components/AiInsightResults/Index.vue'; import AiInsightResults from '@/views/Survey/views/Analysis/components/AiInsightResults/Index.vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useFetchAnalysis, postAnalysis, aiInsightsConfig } from '@/views/Survey/views/Analysis/hooks/useAnalysis'; import {
useFetchAnalysis,
postAnalysis,
aiInsightsConfig
} from '@/views/Survey/views/Analysis/hooks/useAnalysis';
import Wait from '@/views/Survey/views/Analysis/components/Wait/Index.vue'; import Wait from '@/views/Survey/views/Analysis/components/Wait/Index.vue';
import AnalysisInfo from '@/views/Survey/views/Analysis/components/AnalysisInfo/Index.vue';
const route = useRoute(); const route = useRoute();
/** /**
* 如果当前问卷的数据不存在,重新获取数据 * 如果当前问卷的数据不存在,重新获取数据
*/ */
if (!currentSurvey.value) fetchSingleSurvey(<string>route.query.sn); if (!currentSurvey.value) fetchSingleSurvey(route.query.sn as string);
useFetchAnalysis(<string>route.query.sn); useFetchAnalysis(route.query.sn as string);
</script> </script>
<template> <template>
@@ -21,11 +26,14 @@ useFetchAnalysis(<string>route.query.sn);
<!-- 问卷详情部分 --> <!-- 问卷详情部分 -->
<van-cell class="survey-item"> <van-cell class="survey-item">
<template #extra> <template #extra>
<survey-item @post-analysis="postAnalysis(route.query.sn as string)" :is-analysis="true" <survey-item
:survey="<SurveyItem>currentSurvey" /> :is-analysis="true"
:survey="currentSurvey as SurveyItem"
@post-analysis="postAnalysis(route.query.sn as string)"
/>
<!-- 弹窗组件 --> <!-- 弹窗组件 -->
<el-dialog align-center width="70%" v-model="aiInsightsConfig.visible"> <el-dialog v-model="aiInsightsConfig.visible" align-center width="70%">
<wait /> <wait />
</el-dialog> </el-dialog>
</template> </template>
@@ -37,19 +45,24 @@ useFetchAnalysis(<string>route.query.sn);
</template> </template>
</van-cell> </van-cell>
<van-cell class="ai-insight"> <van-cell v-if="false" class="ai-insight">
<template #extra>
<!-- ai 洞察部分内容 --> <!-- ai 洞察部分内容 -->
<AiInsightResults /> <AiInsightResults />
</template>
</van-cell> </van-cell>
<van-cell class="analysis"> <van-cell class="analysis">
<template #extra>
<!-- 图表分析部分 --> <!-- 图表分析部分 -->
<analysis-info />
</template>
</van-cell> </van-cell>
</section> </section>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/assets/css/theme"; @use '@/assets/css/theme';
.survey-container { .survey-container {
padding: theme.$gap; padding: theme.$gap;

View File

@@ -0,0 +1,19 @@
<template>
<div>
<section v-for="analysis in questionAnalysis" :key="analysis.stem">
<el-tag>{{ questionTypeMap.get(analysis.question_type as number) }}</el-tag>
{{ analysis.stem }}
<chart-msg :series="formatData(analysis)" />
</section>
</div>
</template>
<script setup lang="ts">
import { questionAnalysis } from '../../hooks/useAnalysis';
import { questionTypeMap } from '@/utils/question/typeMapping';
import ChartMsg from '@/components/Analysis/Index.vue';
import { formatData, series } from './hooks/pieSeries';
console.log(`question analysis info`, questionAnalysis.value);
</script>

View File

@@ -0,0 +1,39 @@
import { ref } from 'vue';
export const series = ref({
name: 'Access From',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: []
});
export function formatData(data: any) {
console.log(data);
const { option } = data;
series.value.data = option.map((item) => {
return {
value: item.number,
name: item.title
};
});
console.log(`series data`, series.value);
return series.value;
}

View File

@@ -1,15 +1,15 @@
import { surveyAnalysis } from '@/api/survey'; import { surveyAnalysis } from '@/api/survey';
import { ref } from 'vue'; import { onMounted, ref } from 'vue';
import { analysisInsights } from '@/api/anslysis/aiInsight'; import { analysisInsights, checkAnalysisSatus } from '@/api/anslysis/aiInsight';
type AiInsightType = { type AiInsightType = {
visible: boolean, visible: boolean;
timer: NodeJS.Timeout | undefined timer: NodeJS.Timeout | undefined;
} };
// ai 智能洞察 // ai 智能洞察
export const aiInsightsConfig = ref<AiInsightType>({ export const aiInsightsConfig = ref<AiInsightType>({
visible: false, visible: false,
timer: void 0 timer: undefined
}); });
const questionAnalysis = ref(); const questionAnalysis = ref();
@@ -20,23 +20,28 @@ const params: AnalysisParam = {
}; };
export function postAnalysis(sn: string) { export function postAnalysis(sn: string) {
console.log(sn);
aiInsightsConfig.value.visible = true; aiInsightsConfig.value.visible = true;
// analysisInsights(sn); analysisInsights(sn);
if (!aiInsightsConfig.value.timer) { if (!aiInsightsConfig.value.timer) {
// 每 1 s 检查一次是否分析完成 // 每 1 s 检查一次是否分析完成
aiInsightsConfig.value.timer = setInterval(() => { aiInsightsConfig.value.timer = setInterval(() => {
checkAnalysis(sn); checkAnalysis(sn);
}, 1000); }, 1000);
} }
onMounted(() => {
// 当组件取消挂载时, 取消 interval 定时
clearInterval(aiInsightsConfig.value.timer);
});
} }
export function checkAnalysis (sn: string) { export async function checkAnalysis(sn: string) {
console.log(sn); const res = checkAnalysisSatus(sn, 2);
console.log(res);
} }
async function useFetchAnalysis(sn: string) { async function useFetchAnalysis(sn: string) {
const res = await surveyAnalysis(sn, params); const res = await surveyAnalysis(sn);
questionAnalysis.value = res.data.data; questionAnalysis.value = res.data.data;
console.log(`question analysis`, questionAnalysis.value); console.log(`question analysis`, questionAnalysis.value);
} }