|
|
@@ -1,91 +1,138 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { computed, ref, onMounted } from 'vue';
|
|
|
+import { computed, ref, watch } from 'vue';
|
|
|
import type { Dayjs } from 'dayjs';
|
|
|
import { use } from 'echarts/core';
|
|
|
import { BarChart } from 'echarts/charts';
|
|
|
import { GridComponent, TooltipComponent, TitleComponent } from 'echarts/components';
|
|
|
import { CanvasRenderer } from 'echarts/renderers';
|
|
|
import VChart from 'vue-echarts';
|
|
|
-// import { useRequest } from 'alova/client';
|
|
|
-// import type { SatisfactionStatisticItem } from '@/request/api/satisfaction.api';
|
|
|
-// import { satisfactionStatisticsMethod } from '@/request/api/satisfaction.api';
|
|
|
+import type { SatisfactionStatisticsModel, SatisfactionStatisticsQuery, SatisfactionModel } from '@/model/satisfaction.model';
|
|
|
+import { satisfactionStatisticsMethod, satisfactionMethod } from '@/request/api/satisfaction.api';
|
|
|
+import { useRequest } from 'alova/client';
|
|
|
+import { notification } from 'ant-design-vue';
|
|
|
|
|
|
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent, TitleComponent]);
|
|
|
|
|
|
defineOptions({
|
|
|
name: 'SatisfactionStatisticsPage',
|
|
|
});
|
|
|
-const questionnaireList = ref<any[]>([
|
|
|
- { label: '问卷1', value: '1' },
|
|
|
- { label: '问卷2', value: '2' },
|
|
|
- { label: '问卷3', value: '3' },
|
|
|
-]);
|
|
|
+
|
|
|
+const questionnaireList = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+const { onSuccess } = useRequest(satisfactionMethod, {
|
|
|
+ immediate: true,
|
|
|
+});
|
|
|
+onSuccess((res: any) => {
|
|
|
+ if (res && res.data && Array.isArray(res.data?.data)) {
|
|
|
+ questionnaireList.value = res.data.data.map((item: SatisfactionModel) => ({
|
|
|
+ label: item.name || '',
|
|
|
+ value: item.id || '',
|
|
|
+ }));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 默认选中第一张问卷
|
|
|
const questionnaireId = ref<Array<{ label: string; value: string }>>([]);
|
|
|
type RangeValue = [Dayjs, Dayjs] | null;
|
|
|
|
|
|
-const selectedRange = ref<RangeValue>(null);
|
|
|
-
|
|
|
-const statistics = ref<any[]>([]);
|
|
|
-
|
|
|
-const mockStatistics = [
|
|
|
- { date: '2024-09-01', score: 5, count: 620 },
|
|
|
- { date: '2024-09-05', score: 4, count: 140 },
|
|
|
- { date: '2024-09-06', score: 3, count: 86 },
|
|
|
- { date: '2024-09-07', score: 2, count: 18 },
|
|
|
- { date: '2024-09-08', score: 1, count: 6 },
|
|
|
- { date: '2024-09-10', score: 4, count: 212 },
|
|
|
- { date: '2024-09-15', score: 5, count: 280 },
|
|
|
- { date: '2024-09-20', score: 3, count: 35 },
|
|
|
-];
|
|
|
-
|
|
|
-// const { loading, send: fetchStatistics } = useRequest((params?: SatisfactionStatisticsQuery) => satisfactionStatisticsMethod(params), {
|
|
|
-// immediate: false,
|
|
|
-// });
|
|
|
-
|
|
|
-function normalizeResponse(res: any): any[] {
|
|
|
- const list = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
|
|
|
- return list.map((item: any & Record<string, any>) => ({
|
|
|
- score: Number(item.score ?? item.rating ?? item.result ?? 0),
|
|
|
- count: Number(item.count ?? item.total ?? item.value ?? 0),
|
|
|
- date: item.date ?? item.sendDate ?? item.createTime ?? item.statDate ?? '',
|
|
|
- }));
|
|
|
-}
|
|
|
+const selectedRange = ref<RangeValue>(null) as any;
|
|
|
+
|
|
|
+const statisticsData = ref<Record<string, SatisfactionStatisticsModel>>({});
|
|
|
+
|
|
|
+const { loading, send: fetchStatistics } = useRequest((params?: SatisfactionStatisticsQuery) => satisfactionStatisticsMethod(params), {
|
|
|
+ immediate: false,
|
|
|
+});
|
|
|
|
|
|
async function loadStatistics(range?: RangeValue) {
|
|
|
- const params = range
|
|
|
- ? {
|
|
|
- startDate: range[0].startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
|
|
- endDate: range[1].endOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
|
|
- }
|
|
|
- : undefined;
|
|
|
+ const selected = questionnaireId.value;
|
|
|
+ if (!selected || !Array.isArray(selected) || selected.length === 0) {
|
|
|
+ statisticsData.value = {};
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取所有选中的问卷ID
|
|
|
+ const ids = selected.map((item) => {
|
|
|
+ if (typeof item === 'object' && item !== null) {
|
|
|
+ return String(item.value || '');
|
|
|
+ }
|
|
|
+ return String(item);
|
|
|
+ });
|
|
|
+
|
|
|
+ //时间范围
|
|
|
+ const baseParams: Partial<SatisfactionStatisticsQuery> = {};
|
|
|
+ if (range) {
|
|
|
+ baseParams.sendTimeStart = range[0].startOf('day').format('YYYY-MM-DD');
|
|
|
+ baseParams.sendTimeEnd = range[1].endOf('day').format('YYYY-MM-DD');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 为每个问卷ID分别调用接口
|
|
|
+ const dataMap: Record<string, SatisfactionStatisticsModel> = {};
|
|
|
+
|
|
|
try {
|
|
|
- // const res = await fetchStatistics(params);
|
|
|
- // const list = normalizeResponse(res);
|
|
|
- const list = mockStatistics;
|
|
|
- statistics.value = list.length ? list : mockStatistics;
|
|
|
+ // 并发请求所有问卷的统计数据
|
|
|
+ const promises = ids.map(async (id) => {
|
|
|
+ const params: SatisfactionStatisticsQuery = {
|
|
|
+ id,
|
|
|
+ ...baseParams,
|
|
|
+ } as SatisfactionStatisticsQuery;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetchStatistics(params);
|
|
|
+ console.log(`问卷 ${id} 的统计数据:`, res);
|
|
|
+ if (res && typeof res === 'object') {
|
|
|
+ const data = { ...res, id: id } as SatisfactionStatisticsModel;
|
|
|
+ if (Array.isArray(data.xaxis) && Array.isArray(data.yaxis)) {
|
|
|
+ return { id, data };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (error) {
|
|
|
+ notification.error({ message: `获取问卷 ${id} 的统计数据失败`, description: (error as Error).message });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const results = await Promise.all(promises);
|
|
|
+
|
|
|
+ // 处理返回结果
|
|
|
+ results.forEach((result) => {
|
|
|
+ if (result && result.data) {
|
|
|
+ dataMap[result.id] = result.data;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ statisticsData.value = dataMap;
|
|
|
} catch (error) {
|
|
|
- console.error('获取满意度统计失败,使用本地数据兜底。', error);
|
|
|
- statistics.value = mockStatistics;
|
|
|
+ notification.error({ message: '获取满意度统计失败', description: (error as Error).message });
|
|
|
+ statisticsData.value = {};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function handleRangeChange(dates: RangeValue) {
|
|
|
- selectedRange.value = dates;
|
|
|
- loadStatistics(dates ?? undefined);
|
|
|
+// 处理查询按钮点击
|
|
|
+function handleQuery() {
|
|
|
+ loadStatistics(selectedRange.value ?? undefined);
|
|
|
}
|
|
|
|
|
|
-const aggregatedCounts = computed(() => {
|
|
|
- const map: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
|
- statistics.value.forEach((item) => {
|
|
|
- const score = Number(item.score);
|
|
|
- if (score >= 1 && score <= 5) {
|
|
|
- map[score] += Number(item.count ?? 0);
|
|
|
+// 监听问卷列表变化,自动选中第一张问卷
|
|
|
+let isInitialLoad = true;
|
|
|
+watch(
|
|
|
+ questionnaireList,
|
|
|
+ (newList) => {
|
|
|
+ if (newList.length > 0 && questionnaireId.value.length === 0) {
|
|
|
+ questionnaireId.value = [
|
|
|
+ {
|
|
|
+ label: newList[0].label,
|
|
|
+ value: newList[0].value,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ // 默认选中第一个问卷时,自动加载一次数据
|
|
|
+ if (isInitialLoad) {
|
|
|
+ isInitialLoad = false;
|
|
|
+ loadStatistics(selectedRange.value ?? undefined);
|
|
|
+ }
|
|
|
}
|
|
|
- });
|
|
|
- return map;
|
|
|
-});
|
|
|
-
|
|
|
-const totalSamples = computed(() => Object.values(aggregatedCounts.value).reduce((sum, val) => sum + val, 0));
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
|
|
|
// 为每个选中的问卷生成图表配置
|
|
|
const chartOptions = computed(() => {
|
|
|
@@ -94,102 +141,105 @@ const chartOptions = computed(() => {
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
- const counts = aggregatedCounts.value;
|
|
|
- const categories = ['1', '2', '3', '4', '5'];
|
|
|
-
|
|
|
- return selected.map((questionnaire) => {
|
|
|
- // labelInValue 返回的数据结构:{label: string, value: string}
|
|
|
- let qValue: string;
|
|
|
- let qLabel: string;
|
|
|
-
|
|
|
- if (typeof questionnaire === 'object' && questionnaire !== null) {
|
|
|
- qValue = String(questionnaire.value || '');
|
|
|
-
|
|
|
- // 尝试获取 label,确保是字符串类型
|
|
|
- let tempLabel: any = questionnaire.label;
|
|
|
- if (tempLabel && typeof tempLabel === 'string') {
|
|
|
- qLabel = tempLabel;
|
|
|
+ return selected
|
|
|
+ .map((questionnaire) => {
|
|
|
+ let qValue: string;
|
|
|
+ let qLabel: string;
|
|
|
+
|
|
|
+ if (typeof questionnaire === 'object' && questionnaire !== null) {
|
|
|
+ qValue = String(questionnaire.value || '');
|
|
|
+
|
|
|
+ let tempLabel: any = questionnaire.label;
|
|
|
+ if (tempLabel && typeof tempLabel === 'string') {
|
|
|
+ qLabel = tempLabel;
|
|
|
+ } else {
|
|
|
+ const found = questionnaireList.value.find((item) => String(item.value) === qValue);
|
|
|
+ qLabel = found?.label || `问卷 ${qValue}`;
|
|
|
+ }
|
|
|
} else {
|
|
|
- // 如果 label 不存在或不是字符串,从 questionnaireList 中查找
|
|
|
- const found = questionnaireList.value.find(item => String(item.value) === qValue);
|
|
|
+ qValue = String(questionnaire);
|
|
|
+ const found = questionnaireList.value.find((item) => String(item.value) === qValue);
|
|
|
qLabel = found?.label || `问卷 ${qValue}`;
|
|
|
}
|
|
|
- } else {
|
|
|
- qValue = String(questionnaire);
|
|
|
- // 从 questionnaireList 中查找对应的 label
|
|
|
- const found = questionnaireList.value.find(item => String(item.value) === qValue);
|
|
|
- qLabel = found?.label || `问卷 ${qValue}`;
|
|
|
- }
|
|
|
-
|
|
|
- // 最终确保 qLabel 是有效的字符串(防止任何意外情况)
|
|
|
- qLabel = String(qLabel || `问卷 ${qValue}`);
|
|
|
-
|
|
|
- // 这里可以根据不同的问卷ID加载不同的统计数据
|
|
|
- // 目前使用相同的统计数据作为示例
|
|
|
- const seriesData = categories.map((score) => counts[Number(score)] ?? 0);
|
|
|
-
|
|
|
- return {
|
|
|
- questionnaireId: qValue,
|
|
|
- questionnaireLabel: qLabel,
|
|
|
- option: {
|
|
|
- title: {
|
|
|
- text: String(qLabel), // 确保是字符串
|
|
|
- left: 'center',
|
|
|
- top: 0,
|
|
|
- textStyle: {
|
|
|
- fontSize: 16,
|
|
|
- fontWeight: 600,
|
|
|
- color: '#333'
|
|
|
+
|
|
|
+ qLabel = String(qLabel || `问卷 ${qValue}`);
|
|
|
+
|
|
|
+ // 获取该问卷的统计数据
|
|
|
+ const statData = statisticsData.value[qValue];
|
|
|
+
|
|
|
+ if (!statData || !Array.isArray(statData.xaxis) || !Array.isArray(statData.yaxis)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用返回的数据 xaxis 和 yaxis
|
|
|
+ const xaxis = statData.xaxis;
|
|
|
+ const yaxis = statData.yaxis;
|
|
|
+
|
|
|
+ return {
|
|
|
+ questionnaireId: qValue,
|
|
|
+ questionnaireLabel: qLabel,
|
|
|
+ option: {
|
|
|
+ title: {
|
|
|
+ text: String(qLabel), // 确保是字符串
|
|
|
+ left: 'center',
|
|
|
+ top: 0,
|
|
|
+ textStyle: {
|
|
|
+ fontSize: 16,
|
|
|
+ fontWeight: 600,
|
|
|
+ color: '#333',
|
|
|
+ },
|
|
|
},
|
|
|
- },
|
|
|
- tooltip: { trigger: 'axis' },
|
|
|
- grid: { left: 60, right: 40, top: 50, bottom: 50 },
|
|
|
- xAxis: {
|
|
|
- type: 'category',
|
|
|
- data: categories,
|
|
|
- name: '分',
|
|
|
- nameGap: 25,
|
|
|
- axisTick: { alignWithLabel: true },
|
|
|
- axisLabel: { fontSize: 12 },
|
|
|
- },
|
|
|
- yAxis: {
|
|
|
- type: 'value',
|
|
|
- name: '人',
|
|
|
- minInterval: 1,
|
|
|
- splitLine: {
|
|
|
- lineStyle: { type: 'dashed', color: '#eaeaea' },
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ grid: { left: 60, right: 40, top: 50, bottom: 50 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: xaxis,
|
|
|
+ name: '分',
|
|
|
+ nameGap: 25,
|
|
|
+ axisTick: { alignWithLabel: true },
|
|
|
+ axisLabel: { fontSize: 12 },
|
|
|
},
|
|
|
- axisLine: { lineStyle: { color: '#d9d9d9' } },
|
|
|
- },
|
|
|
- series: [
|
|
|
- {
|
|
|
- type: 'bar',
|
|
|
- data: seriesData,
|
|
|
- barWidth: 40,
|
|
|
- itemStyle: {
|
|
|
- color: '#69c0ff',
|
|
|
- borderRadius: [6, 6, 0, 0],
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '人',
|
|
|
+ minInterval: 1,
|
|
|
+ splitLine: {
|
|
|
+ lineStyle: { type: 'dashed', color: '#eaeaea' },
|
|
|
},
|
|
|
- emphasis: {
|
|
|
- focus: 'series',
|
|
|
+ axisLine: { lineStyle: { color: '#d9d9d9' } },
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ data: yaxis,
|
|
|
+ barWidth: 40,
|
|
|
itemStyle: {
|
|
|
- color: '#4096ff',
|
|
|
+ color: '#69c0ff',
|
|
|
+ borderRadius: [6, 6, 0, 0],
|
|
|
+ },
|
|
|
+ emphasis: {
|
|
|
+ focus: 'series',
|
|
|
+ itemStyle: {
|
|
|
+ color: '#4096ff',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'top',
|
|
|
+ formatter: '{c}',
|
|
|
},
|
|
|
},
|
|
|
- label: {
|
|
|
- show: true,
|
|
|
- position: 'top',
|
|
|
- formatter: '{c}',
|
|
|
- },
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- };
|
|
|
- });
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .filter((config) => config !== null) as Array<{
|
|
|
+ questionnaireId: string;
|
|
|
+ questionnaireLabel: string;
|
|
|
+ option: any;
|
|
|
+ }>;
|
|
|
});
|
|
|
|
|
|
-onMounted(() => loadStatistics());
|
|
|
-
|
|
|
defineExpose({
|
|
|
send: () => loadStatistics(selectedRange.value ?? undefined),
|
|
|
});
|
|
|
@@ -200,39 +250,41 @@ defineExpose({
|
|
|
<section class="filter-card">
|
|
|
<div class="filter-item mr-10">
|
|
|
<span class="label">发送日期:</span>
|
|
|
- <a-range-picker v-model:value="selectedRange" allow-clear format="YYYY-MM-DD" :placeholder="['开始日期', '结束日期']" @change="handleRangeChange" />
|
|
|
+ <a-range-picker v-model:value="selectedRange" allow-clear format="YYYY-MM-DD" :placeholder="['开始日期', '结束日期']" />
|
|
|
</div>
|
|
|
<div class="filter-item">
|
|
|
<span class="label">选择问卷:</span>
|
|
|
- <a-select v-model:value="questionnaireId" placeholder="请选择问卷名称" style="width: 300px" mode="multiple" :labelInValue="true" >
|
|
|
+ <a-select v-model:value="questionnaireId" placeholder="请选择问卷名称" style="width: 300px" mode="multiple" :labelInValue="true">
|
|
|
<a-select-option v-for="item in questionnaireList" :key="item.value" :value="item.value" :label="item.label">{{ item.label }}</a-select-option>
|
|
|
</a-select>
|
|
|
</div>
|
|
|
+ <div class="button-item"><a-button type="primary" @click="handleQuery">查询</a-button></div>
|
|
|
</section>
|
|
|
|
|
|
<section class="chart-card">
|
|
|
<main>
|
|
|
- <!-- <a-spin :spinning="loading"> -->
|
|
|
- <template v-if="chartOptions.length > 0">
|
|
|
- <div v-for="(chartConfig, index) in chartOptions" :key="`chart-${String(chartConfig.questionnaireId)}-${index}`" class="chart-wrapper">
|
|
|
- <VChart class="chart" :option="chartConfig.option" autoresize />
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- <a-empty v-else description="请选择问卷查看统计" />
|
|
|
- <!-- </a-spin> -->
|
|
|
+ <a-spin :spinning="loading">
|
|
|
+ <template v-if="chartOptions.length > 0">
|
|
|
+ <div v-for="(chartConfig, index) in chartOptions" :key="`chart-${String(chartConfig.questionnaireId)}-${index}`" class="chart-wrapper">
|
|
|
+ <VChart class="chart" :option="chartConfig.option" autoresize />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <a-empty v-else description="请选择问卷查看统计" />
|
|
|
+ </a-spin>
|
|
|
</main>
|
|
|
</section>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
+.button-item {
|
|
|
+ margin-left: 80px;
|
|
|
+}
|
|
|
.statistics-page {
|
|
|
padding: 24px;
|
|
|
height: 100%;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- // gap: 16px;
|
|
|
- // background: #f5f6fa;
|
|
|
border: 1px solid #f0f0f0;
|
|
|
}
|
|
|
|
|
|
@@ -241,9 +293,7 @@ defineExpose({
|
|
|
border-radius: 8px;
|
|
|
padding: 16px 24px;
|
|
|
display: flex;
|
|
|
- // justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- // border: 1px solid #f0f0f0;
|
|
|
}
|
|
|
|
|
|
.filter-item {
|
|
|
@@ -266,7 +316,6 @@ defineExpose({
|
|
|
background: #fff;
|
|
|
border-radius: 8px;
|
|
|
padding: 24px;
|
|
|
- // border: 1px solid #f0f0f0;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
|
|
|
@@ -282,8 +331,6 @@ defineExpose({
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 24px;
|
|
|
- // align-items: center;
|
|
|
- // justify-content: center;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -293,6 +340,7 @@ defineExpose({
|
|
|
border-radius: 8px;
|
|
|
padding: 16px;
|
|
|
border: 1px solid #f0f0f0;
|
|
|
+ margin-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
.chart-title {
|