feat[preview]: 抽离矩阵的验证内容

- 抽离 preview 矩阵的验证内容,多选矩阵的校验放到 PreviewMatrixCheckbox 内
- 添加新的错误提示支持
- 增加 矩阵 类型
- 添加矩阵测试文件
This commit is contained in:
Huangzhe
2025-03-31 15:28:48 +08:00
parent c97ab0cb49
commit f8749dbb5c
10 changed files with 451 additions and 101 deletions

View File

@@ -34,7 +34,7 @@ declare interface IQuestionOption {
relation_last_scope: number;
relation_first_scope: number;
relation_question_index: number;
options?: questionOptionType[];
options?: IMatrixCheckboxOption[];
}
// 答案 config 类型
@@ -89,7 +89,7 @@ export declare interface IQuestion<QuestionConfig> {
stem?: string;
other?: string;
// options 列表项,第一个是默认
list: questionOptionType[];
list: questionOptionType[] | IQuestionOption[];
question_index?: number;
question_type?: number;
// 如果没有自定义类型,那么就直接用基础 config 类型

View File

@@ -0,0 +1,70 @@
export declare interface IMatrixCheckboxConfig extends IBaseConfig {
/** 是否必选 (1-是 0-否) */
is_required: 0 | 1;
/** 是否交换行列 */
is_change_row_cell?: boolean;
/** 暂未得知 */
quick_type: 0 | 1;
/** 每题选项数 */
each_number: number;
/** 最大选择数 (空字符串表示无限制) */
max_select: number | '';
/** 最小选择数 (空字符串表示无限制) */
min_select: number | '';
/** 随机选择数量 */
select_random: number;
}
export declare interface IMatrixCheckboxOption {
/** 选项HTML内容 */
option: string;
/** 是否为"其他"选项 (0-否 1-是) */
is_other: 0 | 1;
/** 是否固定位置 (0-否 1-是) */
is_fixed: 0 | 1;
/** 是否移除其他选项 (0-否 1-是) */
is_remove_other: 0 | 1;
/** 层级深度 */
level: number;
/** 选项唯一键 */
option_key: string;
/** 选项索引(可能不连续) */
option_index: string;
/** 选项编码 */
option_code: string;
/** 选项配置 */
option_config: IMatrixCheckboxOptionConfig;
/** 父选项索引 */
parent_option_index: number;
/** 子选项(树形结构) */
children: null;
/** 最小选择数 */
min_select: number | string;
}
export declare interface IMatrixCheckboxOptionConfig {
/** 选项类型 */
type: number;
/** 关联价格 */
price: number;
/** 标题 */
title: string;
/** 渐变样式 */
gradient: string;
/** 图片URL数组 */
image_url: string[];
/** 子区域配置 */
child_area: null | {
// 根据实际业务补充具体结构
area_type?: number;
coordinates?: number[];
};
/** 选项类型标记 */
option_type: number;
/** 说明内容数组 */
instructions: string[];
/** 绑定商品ID */
binding_goods_id: string;
/** 右侧限制内容HTML */
limit_right_content: string;
}

View File

@@ -9,13 +9,16 @@
</template>
<script setup lang="ts">
import type { IQuestion } from '@/types/question';
import type { IMatrixCheckboxConfig } from '@/types/questions/matrixCheckbox';
// 接受获取到的 col row 的索引参数
const rowIndex = defineModel<number>('rowIndex', { required: true, default: 0 });
const colIndex = defineModel<number>('colIndex', { required: true, default: 0 });
const rowRecord = defineModel<number[][]>('rowRecord', { required: false, default: () => [] });
const element = defineModel<question>('element', {
const element = defineModel<IQuestion<IMatrixCheckboxConfig>>('element', {
required: false,
default: () => {
/**/
@@ -61,6 +64,7 @@ const emitValue = (/* val: unknown */) => {
</script>
<style scoped lang="scss">
@import '@/assets/css/main';
input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
@@ -73,9 +77,11 @@ input[type='checkbox'] {
cursor: pointer;
position: relative;
transition: border-color 0.4s ease;
&:checked {
border-color: $theme-color;
background: $theme-color;
&::after {
content: '\2713';
font-family: 'Arial', sans-serif; // 确保符号正常显示

View File

@@ -4,6 +4,8 @@ import { Io5EllipsisVerticalSharp } from 'vue-icons-plus/io5';
import MatrixCheckbox from '@/views/Design/components/Questions/MatrixCheckbox.vue';
import MatrixText from '@/views/Design/components/Questions/MatrixText.vue';
import MatrixRadio from '@/views/Design/components/Questions/MatrixRadio.vue';
import type { IQuestion } from '@/types/question';
import type { IMatrixCheckboxConfig } from '@/types/questions/matrixCheckbox';
// 添加激活 action 的选项
interface _questionOptionType extends questionOptionType {
@@ -11,7 +13,7 @@ interface _questionOptionType extends questionOptionType {
}
// 题目
const element = defineModel<question>('element', {
const element = defineModel<IQuestion<IMatrixCheckboxConfig>>('element', {
type: Object,
default: () => {
return {};
@@ -41,6 +43,7 @@ const emitValue = () => {
// 组件选择
const activeComponent = selectActiveComponent();
function selectActiveComponent(): Component {
switch (element.value.question_type) {
case 8:
@@ -87,6 +90,7 @@ function handleActionSelect(action: { text: string }, axi: any, type: 'row' | 'c
}
addShowActionOption();
/**
* 给行或者列选项 添加 showAction 选项
*/
@@ -113,7 +117,7 @@ const errorMessage = defineModel('errorMessage', {
label-align="top"
class="contenteditable-question-title"
>
<template #left-icon> {{ isPreview ? element.title : index + 1 }}. </template>
<template #left-icon> {{ isPreview ? element.title : index + 1 }}.</template>
<!-- 使用 title 插槽来自定义标题 -->
<template #label>
<contenteditable
@@ -220,6 +224,7 @@ input[type='text'] {
//top: 15%;
//right: 0;
color: #606266;
& > svg {
height: 15px;
}

View File

@@ -539,6 +539,7 @@ import PreviewCheckbox from '@/views/Survey/views/Preview/components/questions/P
import PreviewRate from '@/views/Survey/views/Preview/components/questions/PreviewRate.vue';
import PreviewSign from '@/views/Survey/views/Preview/components/questions/PreviewSign.vue';
import PreviewTextWithImages from '@/views/Survey/views/Preview/components/questions/PreviewTextWithImages.vue';
const isPreview = defineModel('isPreview', {
type: Boolean,
default: true
@@ -697,86 +698,6 @@ async function answer(callback, callbackBeforePage) {
question.error = translatedText.value.PleaseInputAValue;
} else if (answer && questionType === 2) {
} else if (answer && questionType === 10) {
// 矩阵多选题,列分组时,校验选项数量
const cellGroups = (config?.cell_option_groups?.option_group || []).filter(
(i) => i.groups?.length
);
if (cellGroups.length) {
const rows = question.list.reduce(
(p, c) => [...p, ...(c.type === 1 ? c.options || [] : [])],
[]
);
const cols = question.list.reduce(
(p, c) => [...p, ...(c.type === 2 ? c.options || [] : [])],
[]
);
const freeCols = cols.filter(
(col) => !cellGroups.some((g) => g.groups.find((c) => c.option_key === col.option_key))
);
const arr = rows.map((row) => {
return cellGroups.map((group) => {
return group.groups.map((col) => {
return `${row.option_key}_${col.option_key}`;
});
});
});
arr.forEach((a, idx) =>
a.push(freeCols.map((col) => `${rows[idx].option_key}_${col.option_key}`))
);
const answered = Object.keys(answer);
cellGroups.forEach((group, idx) => {
if (!group.groups?.length || (!group.max && !group.min) || isError) {
return;
}
for (let i = 0; i < arr.length; i += 1) {
const row = arr[i];
const selectedCount = row[idx].filter((key) => answered.includes(key)).length;
if (group.min && selectedCount < group.min) {
isError = true;
question.error = translatedText.value.PleaseSelectAtLeastOneOptionsInTitleGroup(
group.min,
group.title
);
break;
}
if (group.max && selectedCount > group.max) {
isError = true;
question.error = translatedText.value.PleaseSelectAtMostOneOptionsInTitleGroup(
group.max,
group.title
);
break;
}
}
});
}
// 最少选择数量校验
const minSelect = +config.min_select || 0;
const perLineSelectedCount = Object.keys(answer).reduce(
(p, c) => {
if (+answer[c]) {
p[+c.split('_')[0] - 1] += 1;
}
return p;
},
question.list.filter((i) => i.type === 1).flatMap((i) => i.options.map(() => 0))
);
if (minSelect && minSelect > Math.max(...perLineSelectedCount)) {
if (question.config.is_change_row_cell) {
question.error = translatedText.value.PleaseSelectAtLeastOneOptionsPerColumn(
config.min_select || 1
);
} else {
question.error = translatedText.value.PleaseSelectAtLeastOneOptionsPerLine(
config.min_select || 1
);
}
isError = true;
}
// console.log('===', minSelect, Object.keys(answer), answer, perLineSelectedCount);
} else if (answer && questionType === 12) {
question.error = '';
} else if (answer && questionType === 14 && Object.keys(answer).length < config.min_select) {
@@ -1301,6 +1222,7 @@ function updateAnswer(auto) {
});
}
}
// 选项隐藏
function hideOptions(hide) {
const questionIndex = hide?.question_index;
@@ -1353,6 +1275,7 @@ function toUrl(url) {
open(modifiedUrl);
}
}
/* eslint-enable */
/**

View File

@@ -14,18 +14,21 @@
<script setup lang="ts">
import MatrixQuestion from '@/views/Design/components/Questions/MatrixQuestion.vue';
import { computed, ref, watch } from 'vue';
// const questionType = defineModel<number>('questionType', { required: false });
import type { IQuestion, IQuestionOption } from '@/types/question';
import { validateMatrixCheckbox } from '@/views/Survey/views/Preview/components/questions/validate/validateMatrixCheckbox';
import type { MatrixCheckboxAnswerType } from '@/views/Survey/views/Preview/components/questions/types/previewMatrix';
import type { IMatrixCheckboxConfig } from '@/types/questions/matrixCheckbox';
// 矩阵多选的答案类型
type answerType = {
[key: string]: 1;
};
// const questionType = defineModel<number>('questionType', { required: false });
// preview props
// const stem = defineModel('stem');
// const list = defineModel<questionsList[]>('list', { required: false });
// const config = defineModel<OptionConfigType>('config', { required: false });
const question = defineModel<question>('question', { default: () => {} });
const question = defineModel<IQuestion<IMatrixCheckboxConfig>>('question', {
default: () => {}
});
console.log(`question value: `, question.value);
const answerIndex = computed(() => question.value.title);
const emit = defineEmits(['changeAnswer', 'previous', 'next']);
// 示例
@@ -35,7 +38,7 @@ const emit = defineEmits(['changeAnswer', 'previous', 'next']);
// "2_1": 1,
// "2_2": 1
// }
const answer = defineModel<answerType>('answer', {
const answer = defineModel<MatrixCheckboxAnswerType>('answer', {
// 临时赋值, 用于测试
// default: () => ({
// "1_1": 1,
@@ -63,7 +66,7 @@ answer.value && parseAnswer(answer.value);
/**
* 解析 answer
*/
function parseAnswer(answer: answer) {
function parseAnswer(answer: MatrixCheckboxAnswerType) {
// console.log(`come in parseAnswer`);
const rowRecordList: number[][] = [];
Object.entries(answer).forEach(([key]) => {
@@ -75,6 +78,7 @@ function parseAnswer(answer: answer) {
return rowRecordList;
}
// 查看parseAnswer的返回值
// console.log(`parseAnswer value:`, parseAnswer(answer.value!))
@@ -88,15 +92,32 @@ const cols = computed(() => question.value?.list[1]?.options ?? []);
watch(
rowRecord,
() => {
(value) => {
// console.log(`record has changed`, rowRecord.value);
// 重新生成 answer
const newAnswer: answer = {};
const newAnswer = {} as MatrixCheckboxAnswerType;
rowRecord.value.forEach((rowOptions, rowIndex) => {
rowOptions.forEach((colIndex) => {
newAnswer[`${rowIndex + 1}_${colIndex + 1}`] = 1;
const key = `${rowIndex + 1}_${colIndex + 1}`;
newAnswer[key] = 1;
});
});
/**
* 校验
*/
const res: string = validateMatrixCheckbox(
value,
question.value.config!,
question.value.list as IQuestionOption[]
);
console.log('res', res);
question.value.error = res;
// 如果校验失败,清空 answer
if (res.length) {
question.value.answer = undefined;
return;
}
answer.value = newAnswer;
emit('changeAnswer', newAnswer);
},

View File

@@ -0,0 +1,2 @@
export type MatrixCheckboxAnswerType = { [key: string]: 1 | number };
s;

View File

@@ -0,0 +1,179 @@
import { describe, it, expect, vi } from 'vitest';
import { validateMatrixCheckbox } from '../validateMatrixCheckbox';
import { getLanguage } from '@/views/Survey/views/Preview/js/language';
import type { ITranslatedText } from '@/views/Survey/views/Preview/components/questions/types/translatedText';
// 模拟翻译函数
const mockTranslatedText: ITranslatedText = getLanguage(['zh']);
describe('validateMatrixCheckbox 函数测试', () => {
// 基础测试数据
const baseOptions = [
{
type: 1, // 行选项
options: [
{ option_key: 'row1', option: '行1' },
{ option_key: 'row2', option: '行2' },
{ option_key: 'row3', option: '行3' }
],
cite_type: 0,
relation_type: 0,
relation_out_scope: [],
relation_last_scope: 0,
relation_first_scope: 0,
relation_question_index: 0
},
{
type: 2, // 列选项
options: [
{ option_key: 'col1', option: '列1' },
{ option_key: 'col2', option: '列2' },
{ option_key: 'col3', option: '列3' }
],
cite_type: 0,
relation_type: 0,
relation_out_scope: [],
relation_last_scope: 0,
relation_first_scope: 0,
relation_question_index: 0
}
];
const baseConfig = {
is_required: 1,
quick_type: 0,
each_number: 3,
max_select: 3,
min_select: 1,
select_random: 0,
is_change_row_cell: false,
float_window_content: '',
is_show: [],
is_brand: 0,
is_behavior: 0,
is_initialize: 0,
is_price_tag: 0,
is_three_dimensions: 0,
is_default_perspective: 0,
popup_window: 0,
popup_window_content: ''
};
it('当选项为空数组时,应返回空字符串', () => {
const answer = [];
const result = validateMatrixCheckbox(answer, baseConfig, [], mockTranslatedText);
expect(result).toBe('');
});
it('当所有行都满足最小选择数要求时,应返回空字符串', () => {
const answer = [
[1], // 第1行选择了1个
[1, 2], // 第2行选择了2个
[1, 2, 3] // 第3行选择了3个
];
const result = validateMatrixCheckbox(answer, baseConfig, baseOptions, mockTranslatedText);
expect(result).toBe('');
});
it('当某行选择数小于最小要求时,应返回相应错误信息', () => {
const answer = [
[1], // 第1行选择了1个
[], // 第2行没有选择
[1, 2] // 第3行选择了2个
];
const result = validateMatrixCheckbox(answer, baseConfig, baseOptions, mockTranslatedText);
expect(result).toBe('第2行最少选1个。');
});
it('当某行选择数超过最大要求时,应返回相应错误信息', () => {
const config = { ...baseConfig, max_select: 2 };
const answer = [
[1], // 第1行选择了1个
[1, 2], // 第2行选择了2个
[1, 2, 3] // 第3行选择了3个超过了最大值2
];
const result = validateMatrixCheckbox(answer, config, baseOptions, mockTranslatedText);
expect(result).toBe('第3行最多选2个。');
});
it('当设置is_change_row_cell=true时应返回列相关的错误信息', () => {
const config = { ...baseConfig, is_change_row_cell: true };
const answer = [
[1], // 第1行选择了1个
[], // 第2行没有选择
[1, 2] // 第3行选择了2个
];
const result = validateMatrixCheckbox(answer, config, baseOptions, mockTranslatedText);
expect(result).toBe('第2列最少选1个。');
});
it('当min_select为0时不应进行最小值校验', () => {
const config = { ...baseConfig, min_select: 0 };
const answer = [
[], // 第1行没有选择
[], // 第2行没有选择
[] // 第3行没有选择
];
const result = validateMatrixCheckbox(answer, config, baseOptions, mockTranslatedText);
expect(result).toBe('');
});
it('当min_select为空字符串时应将其转换为0并且不进行最小值校验', () => {
const config = { ...baseConfig, min_select: '' as any };
const answer = [
[], // 第1行没有选择
[], // 第2行没有选择
[] // 第3行没有选择
];
const result = validateMatrixCheckbox(answer, config, baseOptions, mockTranslatedText);
expect(result).toBe('');
});
it('当max_select为0或空字符串时不应进行最大值校验', () => {
const config1 = { ...baseConfig, max_select: 0 };
const config2 = { ...baseConfig, max_select: '' as any };
const answer = [
[1, 2, 3, 4, 5], // 第1行选择了5个
[1, 2, 3, 4], // 第2行选择了4个
[1, 2, 3] // 第3行选择了3个
];
const result1 = validateMatrixCheckbox(answer, config1, baseOptions, mockTranslatedText);
const result2 = validateMatrixCheckbox(answer, config2, baseOptions, mockTranslatedText);
expect(result1).toBe('');
expect(result2).toBe('');
});
it('当有多行不满足条件时,应返回第一个检测到的错误', () => {
const config = { ...baseConfig, min_select: 2, max_select: 3 };
const answer = [
[1], // 第1行选择不足
[], // 第2行选择不足
[1, 2, 3, 4] // 第3行选择过多
];
const result = validateMatrixCheckbox(answer, config, baseOptions, mockTranslatedText);
expect(result).toBe('第1行最少选2个。');
});
it('当min_select和max_select都有限制时应先检查最小值再检查最大值', () => {
const config = { ...baseConfig, min_select: 2, max_select: 3 };
// 测试最小值不满足的情况
const answer1 = [
[1], // 第1行选择不足
[1, 2, 3, 4] // 第2行选择过多
];
const result1 = validateMatrixCheckbox(answer1, config, baseOptions, mockTranslatedText);
expect(result1).toBe('第1行最少选2个。');
// 测试最大值不满足的情况
const answer2 = [
[1, 2], // 第1行满足最小值
[1, 2, 3, 4] // 第2行选择过多
];
const result2 = validateMatrixCheckbox(answer2, config, baseOptions, mockTranslatedText);
expect(result2).toBe('第2行最多选3个。');
});
});

View File

@@ -0,0 +1,117 @@
import { getLanguage } from '@/views/Survey/views/Preview/js/language';
import type { IQuestionOption } from '@/types/question';
import type { IMatrixCheckboxConfig } from '@/types/questions/matrixCheckbox';
/**
* 矩阵多选题校验
* @param answer 答案
* @param config 配置
* @param options 选项
* @param translatedText 语言
*/
function validateMatrixCheckbox(
answer: number[][],
config: IMatrixCheckboxConfig,
options: IQuestionOption[],
translatedText = getLanguage(['zh'])
) {
const [rows, cols] = options;
// 矩阵多选题,列选项分组时,校验选项数量
// const cellGroups = (config?.cell_option_groups?.option_group || []).filter(
// (i) => i.groups?.length
// );
// if (cellGroups.length) {
// const rows = question.list.reduce(
// (p, c) => [...p, ...(c.type === 1 ? c.options || [] : [])],
// []
// );
// const cols = question.list.reduce(
// (p, c) => [...p, ...(c.type === 2 ? c.options || [] : [])],
// []
// );
// const freeCols = cols.filter(
// (col) => !cellGroups.some((g) => g.groups.find((c) => c.option_key === col.option_key))
// );
//
// const arr = rows.map((row) => {
// return cellGroups.map((group) => {
// return group.groups.map((col) => {
// return `${row.option_key}_${col.option_key}`;
// });
// });
// });
// arr.forEach((a, idx) =>
// a.push(freeCols.map((col) => `${rows[idx].option_key}_${col.option_key}`))
// );
// const answered = Object.keys(answer);
//
// cellGroups.forEach((group, idx) => {
// if (!group.groups?.length || (!group.max && !group.min) || isError) {
// return;
// }
//
// for (let i = 0; i < arr.length; i += 1) {
// const row = arr[i];
// const selectedCount = row[idx].filter((key) => answered.includes(key)).length;
// if (group.min && selectedCount < group.min) {
// isError = true;
// question.error = translatedText.value.PleaseSelectAtLeastOneOptionsInTitleGroup(
// group.min,
// group.title
// );
// break;
// }
// if (group.max && selectedCount > group.max) {
// isError = true;
// question.error = translatedText.value.PleaseSelectAtMostOneOptionsInTitleGroup(
// group.max,
// group.title
// );
// break;
// }
// }
// });
// }
// 最少选择数量校验
// 转换配置值为数字
const minSelect = +config.min_select;
const maxSelect = +config.max_select;
// 如果选项为空,直接返回空字符串
if (!options.length) {
return '';
}
// 错误信息
let errorMessage = '';
// 最大和最小选择数量校验
answer.forEach((row, rowIndex) => {
// 如果没有最小选择要求或最小选择为0不进行最小值校验
if (minSelect >= 0 && row.length < minSelect) {
// 如果已经有错误信息,不进行最小值校验
if (errorMessage) return;
// 是否行列转换
errorMessage = config.is_change_row_cell
? translatedText.PleaseSelectAtLeastOneOptionsPerColumn(minSelect, rowIndex)
: translatedText.PleaseSelectAtLeastOneOptionsPerLine(minSelect, rowIndex);
}
// 如果有最大选择限制,进行最大值校验
if (maxSelect > 0 && row.length > maxSelect) {
// 如果已经有错误信息,不进行最大值校验
if (errorMessage) return;
// 是否行列转换
errorMessage = config.is_change_row_cell
? translatedText.PleaseSelectLessOptionsPerColumn(maxSelect, rowIndex)
: translatedText.PleaseSelectLessOptionsPerLine(maxSelect, rowIndex);
}
});
// 检测所有的答案是否正常选择
if (errorMessage.length === 0 && rows.options!.length > answer.length) {
errorMessage = translatedText.PleaseSelectAllRows;
}
return errorMessage;
}
export { validateMatrixCheckbox };

View File

@@ -5,13 +5,36 @@ export const language = {
},
PleaseSelectAtLeastOneOptionsPerLine: {
en: (count) => `Please select at least ${count} answer option${count > 1 ? 's' : ''} per row.`,
zh: (count) => `每行最少选${count}个。`
en: (count, row) =>
typeof row === 'number'
? `Please select at least ${count} answer option${count > 1 ? 's' : ''} at row ${row + 1}.`
: `Please select at least ${count} answer option${count > 1 ? 's' : ''} per row.`,
zh: (count, row) =>
typeof row === 'number' ? `${row + 1}行最少选${count}个。` : `每行最少选${count}个。`
},
PleaseSelectLessOptionsPerLine: {
en: (count, row) =>
typeof row === 'number'
? `Please select no more than ${count} answer option${count > 1 ? 's' : ''} at row ${row + 1}.`
: `Please select no more than ${count} answer option${count > 1 ? 's' : ''} per row.`,
zh: (count, row) =>
typeof row === 'number' ? `${row + 1}行最多选${count}个。` : `每行最多选${count}个。`
},
PleaseSelectAtLeastOneOptionsPerColumn: {
en: (count) =>
`Please select at least ${count} answer option${count > 1 ? 's' : ''} per column.`,
zh: (count) => `每列最少选${count}个。`
en: (count, column) =>
typeof column === 'number'
? `Please select at least ${count} answer option${count > 1 ? 's' : ''} at column ${column + 1}.`
: `Please select at least ${count} answer option${count > 1 ? 's' : ''} per column.`,
zh: (count, column) =>
typeof column === 'number' ? `${column + 1}列最少选${count}个。` : `每列最少选${count}个。`
},
PleaseSelectLessOptionsPerColumn: {
en: (count, column) =>
typeof column === 'number'
? `Please select no more than ${count} answer option${count > 1 ? 's' : ''} at column ${column + 1}.`
: `Please select no more than ${count} answer option${count > 1 ? 's' : ''} per column.`,
zh: (count, column) =>
typeof column === 'number' ? `${column + 1}列最多选${count}个。` : `每列最多选${count}个。`
},
PleaseCategorizeAllOptions: {
en: 'Please categorize all answer options.',
@@ -21,6 +44,10 @@ export const language = {
en: 'This is compulsory.',
zh: '请填写当前题目'
},
PleaseSelectAllRows: {
en: 'Please select all rows.',
zh: '请完成所有行。'
},
PleaseInputAValue: {
en: 'Please input a value.',
zh: '请输入。'