zhxy-jzd/src/pages/base/wj/indexwb.vue
2026-05-11 15:52:59 +08:00

1814 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="questionnaire-page">
<!-- 加载中 -->
<view v-if="loading" class="loading-container">
<u-loading-icon mode="spinner" size="40" color="#409EFF"></u-loading-icon>
<text class="loading-text">加载中...</text>
</view>
<!-- 提交遮罩层 -->
<view v-if="submitting" class="submit-mask">
<view class="submit-mask-content">
<u-loading-icon mode="spinner" size="40" color="#409EFF"></u-loading-icon>
<text class="submit-mask-text">提交中,请稍候...</text>
</view>
</view>
<!-- 问卷填写阶段 -->
<view v-else-if="currentStep === 'fill'" class="fill-step">
<view class="questionnaire-header">
<text class="questionnaire-title">{{ questionnaireInfo?.title || '问卷' }}</text>
<text class="questionnaire-desc" v-if="questionnaireInfo?.description">{{ questionnaireInfo.description }}</text>
</view>
<!-- 分页标题 -->
<view v-if="pageList.length > 0" class="page-header">
<text class="page-title">{{ pageList[currentPageIndex]?.pageName || '第一部分' }}</text>
<text class="page-progress">{{ currentPageIndex + 1 }}/{{ pageList.length }}</text>
</view>
<view class="questions-container">
<view
v-for="(question, index) in questionList"
:key="question.id || index"
class="question-item"
:class="{ 'question-item-error': questionErrors[question.id] }"
>
<view class="question-header">
<text class="question-required" v-if="question.required === 1">*</text>
<text class="question-title">
第{{ getGlobalQuestionIndex(index) }}题
<text v-if="question.questionContent" class="question-content-inline">{{ question.questionContent }}</text>
<text class="question-type-inline">{{ getQuestionTypeName(question.questionType) }}</text>
</text>
</view>
<!-- 输入框 -->
<view v-if="question.questionType === 'TEXT'" class="question-input">
<input
v-model="answers[question.id].value"
@input="() => { if (questionErrors[question.id]) questionErrors[question.id] = false; }"
type="text"
placeholder="请输入"
:maxlength="500"
class="input-field"
:class="{ 'input-field-error': questionErrors[question.id] }"
/>
</view>
<!-- 多行文本 -->
<view v-else-if="question.questionType === 'TEXTAREA'" class="question-input">
<textarea
v-model="answers[question.id].value"
@input="() => { if (questionErrors[question.id]) questionErrors[question.id] = false; }"
placeholder="请输入"
:maxlength="2000"
class="textarea-field"
:class="{ 'textarea-field-error': questionErrors[question.id] }"
/>
</view>
<!-- 数字 -->
<view v-else-if="question.questionType === 'NUMBER'" class="question-input">
<input
v-model="answers[question.id].value"
@input="() => { if (questionErrors[question.id]) questionErrors[question.id] = false; }"
type="digit"
placeholder="请输入数字"
class="input-field"
:class="{ 'input-field-error': questionErrors[question.id] }"
/>
</view>
<!-- 单选框 -->
<view v-else-if="question.questionType === 'RADIO'" class="question-options" :class="{ 'question-options-error': questionErrors[question.id] }">
<view
v-for="(option, optIndex) in getQuestionOptions(question)"
:key="optIndex"
class="option-item"
@click="selectRadio(question.id, option.key)"
>
<view class="radio-wrapper">
<view
class="radio-circle"
:class="{ 'radio-selected': answers[question.id].value === option.key }"
>
<view v-if="answers[question.id].value === option.key" class="radio-inner"></view>
</view>
<text class="option-label">{{ option.key }}.</text>
<text class="option-text">{{ option.value }}</text>
</view>
</view>
</view>
<!-- 下拉框 -->
<view v-else-if="question.questionType === 'SELECT'" class="question-input">
<picker
:value="getSelectIndex(question.id)"
:range="getQuestionOptions(question)"
range-key="value"
@change="selectPicker($event, question.id)"
>
<view class="picker-view" :class="{ 'picker-view-error': questionErrors[question.id] }">
<text v-if="answers[question.id].value" class="picker-text">
{{ getOptionText(question, answers[question.id].value) }}
</text>
<text v-else class="picker-placeholder">请选择</text>
<u-icon name="arrow-down" size="16" color="#999"></u-icon>
</view>
</picker>
</view>
<!-- 多选框 -->
<view v-else-if="question.questionType === 'CHECKBOX'" class="question-options" :class="{ 'question-options-error': questionErrors[question.id] }">
<view
v-for="(option, optIndex) in getQuestionOptions(question)"
:key="optIndex"
class="option-item"
@click="toggleCheckbox(question.id, option.key)"
>
<view class="checkbox-wrapper">
<view
class="checkbox-square"
:class="{ 'checkbox-selected': (answers[question.id].value || []).includes(option.key) }"
>
<u-icon
v-if="(answers[question.id].value || []).includes(option.key)"
name="checkmark"
size="12"
color="#fff"
></u-icon>
</view>
<text class="option-label">{{ option.key }}.</text>
<text class="option-text">{{ option.value }}</text>
</view>
</view>
</view>
<!-- 日期选择 -->
<view v-else-if="question.questionType === 'DATE'" class="question-input">
<picker
mode="date"
:value="answers[question.id].value"
@change="selectDate($event, question.id)"
>
<view class="picker-view" :class="{ 'picker-view-error': questionErrors[question.id] }">
<text v-if="answers[question.id].value" class="picker-text">{{ answers[question.id].value }}</text>
<text v-else class="picker-placeholder">请选择日期</text>
<u-icon name="arrow-down" size="16" color="#999"></u-icon>
</view>
</picker>
</view>
<!-- 文件上传 -->
<view v-else-if="question.questionType === 'FILE'" class="question-upload" :class="{ 'question-upload-error': questionErrors[question.id] }">
<ImageVideoUpload
v-model:image-list="fileUploadData[question.id].imageList"
v-model:file-list="fileUploadData[question.id].fileList"
:max-image-count="10"
:max-file-count="10"
:enable-video="false"
:enable-image="true"
:enable-file="true"
:allowed-file-types="['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@image-upload-success="(image, index) => onImageUploadSuccess(question.id, image, index)"
@file-upload-success="(file, index) => onFileUploadSuccess(question.id, file, index)"
/>
</view>
<!-- 视频上传 -->
<view v-else-if="question.questionType === 'VIDEO'" class="question-upload" :class="{ 'question-upload-error': questionErrors[question.id] }">
<ImageVideoUpload
v-model:video-list="fileUploadData[question.id].videoList"
:max-video-count="5"
:enable-image="false"
:enable-file="false"
:enable-video="true"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@video-upload-success="(video, index) => onVideoUploadSuccess(question.id, video, index)"
/>
</view>
<!-- 签名 -->
<view v-else-if="question.questionType === 'SIGNATURE'" class="question-signature" :class="{ 'question-signature-error': questionErrors[question.id] }">
<!-- 已有签名:显示预览 -->
<view v-if="answers[question.id].value" class="signature-preview-inline">
<image :src="answers[question.id].value" class="signature-preview-img-inline" mode="aspectFit" />
<view class="signature-actions-inline">
<u-button
type="primary"
size="mini"
class="resign-btn-inline"
@click="handleQuestionSignature(question.id)"
>
重新签名
</u-button>
</view>
</view>
<!-- 未签名:显示签名按钮 -->
<view v-else class="signature-placeholder-inline" @click="handleQuestionSignature(question.id)">
<u-icon name="edit-pen" size="40" color="#999"></u-icon>
<text class="signature-placeholder-text-inline">点击签名</text>
</view>
</view>
</view>
</view>
<!-- 签名区域 -->
<view v-if="questionnaireInfo && questionnaireInfo.mdqz === 1" class="signature-section">
<view class="signature-header">
<text class="signature-label">签名</text>
<text class="signature-required">*</text>
</view>
<!-- 已有签名:显示预览 -->
<view v-if="sign_file" class="signature-preview">
<image :src="sign_file" class="signature-preview-img" mode="aspectFit" />
<view class="signature-actions">
<u-button
type="primary"
size="mini"
class="resign-btn"
@click="handleSignature"
>
重新签名
</u-button>
</view>
</view>
<!-- 未签名:显示签名按钮 -->
<view v-else class="signature-placeholder" @click="handleSignature">
<u-icon name="edit-pen" size="40" color="#999"></u-icon>
<text class="signature-placeholder-text">点击签名</text>
</view>
</view>
<!-- 分页导航按钮 -->
<view class="page-navigation">
<button
v-if="currentPageIndex > 0"
@click="goToPreviousPage"
class="nav-btn prev-btn"
>
上一页
</button>
<button
v-if="currentPageIndex < pageList.length - 1"
@click="goToNextPage"
class="nav-btn next-btn"
>
下一页
</button>
<button
v-if="currentPageIndex === pageList.length - 1"
@click="submitQuestionnaire"
class="nav-btn submit-btn"
>
提交问卷
</button>
</view>
</view>
<!-- 签名组件 -->
<BasicSign v-if="showSignature" ref="signCompRef" :title="signTitle"></BasicSign>
<!-- 提交成功阶段 -->
<view v-else-if="currentStep === 'success'" class="success-step">
<view class="success-content">
<u-icon name="checkmark-circle" size="80" color="#67C23A"></u-icon>
<text class="success-title">提交成功</text>
<text class="success-time">{{ formatTime(new Date()) }}</text>
<text class="success-tip">您已成功提交问卷</text>
<!-- 查看AI分析报告按钮 -->
<button v-if="showAIAnalysisButton" @click="viewAIAnalysisReport" class="ai-analyze-btn">
<u-icon name="file-text" size="18" color="#fff" style="margin-right: 8rpx;"></u-icon>
查看AI分析报告
</button>
<!-- 重新填写按钮当允许重复提交时显示 -->
<button v-if="allowResubmit" @click="handleResubmit" class="resubmit-btn">
<u-icon name="reload" size="18" color="#fff" style="margin-right: 8rpx;"></u-icon>
重新填写
</button>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
<!-- 无权限阶段 -->
<view v-else-if="currentStep === 'noPermission'" class="no-permission-step">
<view class="no-permission-content">
<u-icon name="close-circle" size="80" color="#F56C6C"></u-icon>
<text class="no-permission-title">无填写权限</text>
<text class="no-permission-subtitle">抱歉您没有填写此问卷的权限</text>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
<!-- 已填写阶段 -->
<view v-else-if="currentStep === 'alreadyFilled'" class="already-filled-step">
<view class="already-filled-content">
<u-icon name="info-circle" size="80" color="#409EFF"></u-icon>
<text class="already-filled-title">已填写</text>
<text class="already-filled-subtitle">您已经填写过此问卷</text>
<!-- 查看AI分析报告按钮 -->
<button v-if="showAIAnalysisButton" @click="viewAIAnalysisReport" class="ai-analyze-btn">
<u-icon name="file-text" size="18" color="#fff" style="margin-right: 8rpx;"></u-icon>
查看AI分析报告
</button>
<!-- 重新填写按钮当允许重复提交时显示 -->
<button v-if="allowResubmit" @click="handleResubmit" class="resubmit-btn">
<u-icon name="reload" size="18" color="#fff" style="margin-right: 8rpx;"></u-icon>
重新填写
</button>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
<!-- 时间未开始/已结束阶段 -->
<view v-else-if="currentStep === 'timeInvalid'" class="time-invalid-step">
<view class="time-invalid-content">
<u-icon name="clock" size="80" color="#409EFF"></u-icon>
<text class="time-invalid-title">{{ timeInvalidMessage }}</text>
<text class="time-invalid-subtitle">{{ timeInvalidSubtitle }}</text>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import {
questionnaireFindByIdApi,
questionnaireCheckPermissionApi,
questionnaireQuestionFindPageApi,
questionnaireAnswerSaveApi,
questionnaireAnswerFindByUserOrFamilyApi,
questionnaireAnswerFindDetailApi
} from '@/api/base/wjApi';
import { useUserStore } from '@/store/modules/user';
import { attachmentUpload } from '@/api/system/upload';
import { ImageVideoUpload, type ImageItem, type FileItem, type VideoItem, COMPRESS_PRESETS } from '@/components/ImageVideoUpload';
import BasicSign from '@/components/BasicSign/Sign.vue';
const userStore = useUserStore();
// 页面参数
const options = ref<any>({});
const questionnaireId = ref('');
const openId = ref<string>(""); // 微信 openId
const xxtsId = ref<string>(""); // 消息推送ID从URL参数id获取
const xsId = ref<string>(""); // 学生ID推送链接中的 xsId用于「同一生多家长」去重
const loading = ref(true);
const submitting = ref(false); // 提交中状态
const currentStep = ref<'fill' | 'success' | 'noPermission' | 'alreadyFilled' | 'timeInvalid'>('fill');
// 问卷信息
const questionnaireInfo = ref<any>(null);
const questionList = ref<any[]>([]);
const answers = reactive<Record<string, any>>({});
const timeInvalidMessage = ref('');
const timeInvalidSubtitle = ref('');
// 分页相关
const pageList = ref<Array<{pageId: string, pageName: string, pageSortOrder: number, questions: any[]}>>([]);
const currentPageIndex = ref(0); // 当前分页索引
// 题目验证错误状态(用于红色框标识)
const questionErrors = reactive<Record<string, boolean>>({});
// 文件上传相关数据
const fileUploadData = reactive<Record<string, {
imageList: ImageItem[];
fileList: FileItem[];
videoList: VideoItem[];
}>>({});
// 压缩配置
const compressConfig = ref(COMPRESS_PRESETS.high);
// 签名组件相关
const signCompRef = ref<any>(null);
const signTitle = ref<string>("问卷签名");
const sign_file = ref<string>("");
const showSignature = ref<boolean>(false);
const currentSignatureQuestionId = ref<string>(""); // 当前正在签名的题目ID
// 提交批次ID用于查看AI分析报告
const submitId = ref<string>('');
// 控制是否显示AI智能分析按钮
const showAIAnalysisButton = ref(false);
// 控制是否允许重复提交
const allowResubmit = ref(false);
onLoad(async (params) => {
options.value = params;
openId.value = params?.openId || '';
xxtsId.value = params?.id || ''; // 从URL参数id获取xxtsId
xsId.value = (params?.xsId as string) || '';
// 如果有 openId先进行认证
if (openId.value) {
try {
const loginSuccess = await userStore.loginByOpenId(openId.value);
if (!loginSuccess) {
uni.showToast({ title: "认证失败,请重新登录", icon: "none" });
loading.value = false;
return;
}
} catch (e) {
console.error("openId认证失败:", e);
uni.showToast({ title: "认证失败", icon: "none" });
loading.value = false;
return;
}
}
if (params?.questionnaireId) {
questionnaireId.value = params.questionnaireId;
initializePage();
} else {
uni.showToast({ title: '参数错误', icon: 'none' });
loading.value = false;
}
});
// 初始化页面
const initializePage = async () => {
try {
loading.value = true;
// 1. 获取问卷信息
const questionnaireResult = await questionnaireFindByIdApi({ id: questionnaireId.value });
if (questionnaireResult && questionnaireResult.resultCode === 1) {
questionnaireInfo.value = questionnaireResult.result || questionnaireResult.data;
console.log('【问卷信息】questionnaireInfo.value:', questionnaireInfo.value);
console.log('【allowAnalysis值】', questionnaireInfo.value?.allowAnalysis);
// 判断是否显示AI智能分析按钮如果 allowAnalysis = 1则显示兼容字符串和数字
const allowAnalysisValue = questionnaireInfo.value?.allowAnalysis;
showAIAnalysisButton.value = allowAnalysisValue === 1 || allowAnalysisValue === '1';
console.log('【是否显示AI按钮】showAIAnalysisButton.value:', showAIAnalysisButton.value);
// 判断是否允许重复提交:如果 allowResubmit = 1则允许兼容字符串和数字
const allowResubmitValue = questionnaireInfo.value?.allowResubmit;
allowResubmit.value = allowResubmitValue === 1 || allowResubmitValue === '1';
console.log('【是否允许重复提交】allowResubmit.value:', allowResubmit.value);
} else {
throw new Error('获取问卷信息失败');
}
// 2. 检查时间
const now = new Date();
if (questionnaireInfo.value.startTime && new Date(questionnaireInfo.value.startTime) > now) {
currentStep.value = 'timeInvalid';
timeInvalidMessage.value = '问卷未开始';
timeInvalidSubtitle.value = `问卷开始时间:${formatTime(questionnaireInfo.value.startTime)}`;
loading.value = false;
return;
}
if (questionnaireInfo.value.endTime && new Date(questionnaireInfo.value.endTime) < now) {
currentStep.value = 'timeInvalid';
timeInvalidMessage.value = '问卷已结束';
timeInvalidSubtitle.value = `问卷结束时间:${formatTime(questionnaireInfo.value.endTime)}`;
loading.value = false;
return;
}
// 3. 检查权限
const permissionResult = await questionnaireCheckPermissionApi({
questionnaireId: questionnaireId.value
});
if (permissionResult && permissionResult.resultCode === 1) {
const hasPermission = permissionResult.result || permissionResult.data;
if (!hasPermission) {
currentStep.value = 'noPermission';
loading.value = false;
return;
}
} else {
throw new Error('检查权限失败');
}
// 4. 检查是否已填写(如果不允许重复提交)
if (!allowResubmit.value) {
const answerResult = await questionnaireAnswerFindByUserOrFamilyApi({
questionnaireId: questionnaireId.value,
xxtsId: xxtsId.value || undefined,
xsId: xsId.value || undefined,
});
if (answerResult && answerResult.resultCode === 1) {
const hasAnswered = answerResult.result || answerResult.data;
if (hasAnswered) {
currentStep.value = 'alreadyFilled';
loading.value = false;
return;
}
}
}
// 5. 获取题目列表
const questionResult = await questionnaireQuestionFindPageApi({
questionnaireId: questionnaireId.value,
page: 1,
rows: 1000
});
if (questionResult && questionResult.rows) {
const allQuestions = questionResult.rows.sort((a: any, b: any) => {
// 先按分页排序,再按题目排序
const pageOrderDiff = (a.pageSortOrder || 0) - (b.pageSortOrder || 0);
if (pageOrderDiff !== 0) return pageOrderDiff;
return (a.sortOrder || 0) - (b.sortOrder || 0);
});
// 按分页分组
const pageMap = new Map<string, {pageId: string, pageName: string, pageSortOrder: number, questions: any[]}>();
allQuestions.forEach((question: any) => {
const pageId = question.pageId || 'default';
const pageName = question.pageName || '第一部分';
const pageSortOrder = question.pageSortOrder || 0;
if (!pageMap.has(pageId)) {
pageMap.set(pageId, {
pageId,
pageName,
pageSortOrder,
questions: []
});
}
pageMap.get(pageId)!.questions.push(question);
});
// 转换为数组并按分页排序
pageList.value = Array.from(pageMap.values()).sort((a, b) =>
a.pageSortOrder - b.pageSortOrder
);
// 如果没有分页,创建一个默认分页
if (pageList.value.length === 0) {
pageList.value = [{
pageId: 'default',
pageName: '第一部分',
pageSortOrder: 0,
questions: allQuestions
}];
}
// 设置当前分页为第一个
currentPageIndex.value = 0;
// 更新当前显示的题目列表
questionList.value = pageList.value[0]?.questions || [];
// 调试打印:打印所有题目信息
console.log('分页列表:', pageList.value);
console.log('当前分页题目:', questionList.value);
// 初始化所有题目的答案
allQuestions.forEach((question: any) => {
if (question.questionType === 'CHECKBOX') {
answers[question.id] = { value: [] };
} else {
answers[question.id] = { value: '' };
}
// 初始化文件上传数据
if (question.questionType === 'FILE') {
fileUploadData[question.id] = {
imageList: [],
fileList: [],
videoList: []
};
} else if (question.questionType === 'VIDEO') {
fileUploadData[question.id] = {
imageList: [],
fileList: [],
videoList: []
};
}
});
// 6. 如果允许重复提交,尝试加载已填写的答案进行回显
if (allowResubmit.value) {
await loadExistingAnswers(allQuestions);
}
}
currentStep.value = 'fill';
} catch (error: any) {
console.error('初始化失败:', error);
uni.showToast({ title: error.message || '加载失败', icon: 'none' });
currentStep.value = 'noPermission';
} finally {
loading.value = false;
}
};
// 获取题目的全局序号(跨分页连续编号)
const getGlobalQuestionIndex = (localIndex: number): number => {
let globalIndex = 0;
// 计算前面所有分页的题目总数
for (let i = 0; i < currentPageIndex.value; i++) {
globalIndex += pageList.value[i]?.questions.length || 0;
}
// 加上当前分页中的题目索引
globalIndex += localIndex + 1;
return globalIndex;
};
// 切换到上一页
const goToPreviousPage = () => {
if (currentPageIndex.value > 0) {
currentPageIndex.value--;
questionList.value = pageList.value[currentPageIndex.value]?.questions || [];
// 滚动到顶部
uni.pageScrollTo({
scrollTop: 0,
duration: 300
});
}
};
// 验证题目是否填写
const validateQuestion = (question: any): boolean => {
if (question.required !== 1) {
return true; // 非必填项,直接通过
}
if (question.questionType === 'FILE') {
const uploadData = fileUploadData[question.id];
const hasImage = (uploadData?.imageList || []).some(img => img.url);
const hasFile = (uploadData?.fileList || []).some(file => file.url);
return hasImage || hasFile;
} else if (question.questionType === 'VIDEO') {
const uploadData = fileUploadData[question.id];
const hasVideo = (uploadData?.videoList || []).some(video => video.url);
return hasVideo;
} else if (question.questionType === 'SIGNATURE') {
const answer = answers[question.id];
return answer && answer.value;
} else {
const answer = answers[question.id];
if (!answer || !answer.value) {
return false;
}
if (Array.isArray(answer.value) && answer.value.length === 0) {
return false;
}
return true;
}
};
// 切换到下一页
const goToNextPage = () => {
// 清除当前分页的错误状态
const currentPage = pageList.value[currentPageIndex.value];
if (currentPage) {
currentPage.questions.forEach(q => {
questionErrors[q.id] = false;
});
}
// 验证当前分页的必填项
if (currentPage) {
let hasError = false;
for (const question of currentPage.questions) {
if (!validateQuestion(question)) {
questionErrors[question.id] = true;
hasError = true;
}
}
if (hasError) {
uni.showToast({
title: '请完成所有必填项',
icon: 'none'
});
// 滚动到第一个错误项
nextTick(() => {
const query = uni.createSelectorQuery();
query.select('.question-item-error').boundingClientRect();
query.exec((res) => {
if (res && res[0]) {
uni.pageScrollTo({
scrollTop: res[0].top - 100,
duration: 300
});
}
});
});
return;
}
}
if (currentPageIndex.value < pageList.value.length - 1) {
currentPageIndex.value++;
questionList.value = pageList.value[currentPageIndex.value]?.questions || [];
// 滚动到顶部
uni.pageScrollTo({
scrollTop: 0,
duration: 300
});
}
};
// 获取题目选项
const getQuestionOptions = (question: any) => {
try {
if (!question.questionConfig) return [];
const config = typeof question.questionConfig === 'string'
? JSON.parse(question.questionConfig)
: question.questionConfig;
return config.options || [];
} catch {
return [];
}
};
// 获取问题类型名称
const getQuestionTypeName = (questionType: string) => {
const typeMap: Record<string, string> = {
'TEXT': '输入项',
'TEXTAREA': '富文本',
'NUMBER': '数字',
'RADIO': '单选项',
'SELECT': '下拉项',
'CHECKBOX': '多选项',
'DATE': '日期选择',
'FILE': '文件上传',
'VIDEO': '视频上传',
'SIGNATURE': '签名'
};
return typeMap[questionType] || '';
};
// 选择单选框
const selectRadio = (questionId: string, optionKey: string) => {
answers[questionId].value = optionKey;
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
};
// 选择下拉框
const selectPicker = (event: any, questionId: string) => {
const question = questionList.value.find(q => q.id === questionId);
if (question) {
const options = getQuestionOptions(question);
const selectedOption = options[event.detail.value];
if (selectedOption) {
answers[questionId].value = selectedOption.key;
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
}
}
};
// 获取下拉框选中索引
const getSelectIndex = (questionId: string) => {
const question = questionList.value.find(q => q.id === questionId);
if (question) {
const options = getQuestionOptions(question);
const currentValue = answers[questionId].value;
return options.findIndex((opt: any) => opt.key === currentValue);
}
return 0;
};
// 获取选项文本
const getOptionText = (question: any, optionKey: string) => {
const options = getQuestionOptions(question);
const option = options.find((opt: any) => opt.key === optionKey);
return option ? `${option.key}. ${option.value}` : '';
};
// 切换多选框
const toggleCheckbox = (questionId: string, optionKey: string) => {
const currentValues = answers[questionId].value || [];
const index = currentValues.indexOf(optionKey);
if (index > -1) {
currentValues.splice(index, 1);
} else {
currentValues.push(optionKey);
}
answers[questionId].value = currentValues;
};
// 选择日期
const selectDate = (event: any, questionId: string) => {
answers[questionId].value = event.detail.value;
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
};
// 图片上传成功回调
const onImageUploadSuccess = (questionId: string, image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index);
// 图片上传成功后URL 已经保存在 image.url 中
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
};
// 文件上传成功回调
const onFileUploadSuccess = (questionId: string, file: FileItem, index: number) => {
console.log('文件上传成功:', file, index);
// 文件上传成功后URL 已经保存在 file.url 中
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
};
// 视频上传成功回调
const onVideoUploadSuccess = (questionId: string, video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index);
// 视频上传成功后URL 已经保存在 video.url 中
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
};
// 处理问卷级别签名
const handleSignature = async () => {
try {
showSignature.value = true;
signTitle.value = "问卷签名";
currentSignatureQuestionId.value = ""; // 空字符串表示问卷级别签名
// 等待组件渲染完成
await nextTick();
const data = await signCompRef.value.getSyncSignature();
sign_file.value = data.base64;
showSignature.value = false;
} catch (error) {
showSignature.value = false;
// 如果用户取消签名,不显示错误提示
if (error && typeof error === 'object' && 'message' in error && error.message !== '用户取消') {
uni.showToast({ title: '签名失败', icon: 'none' });
}
}
};
// 处理题目级别签名
const handleQuestionSignature = async (questionId: string) => {
try {
showSignature.value = true;
const question = questionList.value.find(q => q.id === questionId);
signTitle.value = question?.questionContent || "签名";
currentSignatureQuestionId.value = questionId;
// 等待组件渲染完成
await nextTick();
const data = await signCompRef.value.getSyncSignature();
// 将签名保存到对应题目的答案中
if (answers[questionId]) {
answers[questionId].value = data.base64;
// 清除错误状态
if (questionErrors[questionId]) {
questionErrors[questionId] = false;
}
}
showSignature.value = false;
} catch (error) {
showSignature.value = false;
// 如果用户取消签名,不显示错误提示
if (error && typeof error === 'object' && 'message' in error && error.message !== '用户取消') {
uni.showToast({ title: '签名失败', icon: 'none' });
}
}
};
// 提交问卷
const submitQuestionnaire = async () => {
// 防止重复提交
if (submitting.value) {
return;
}
try {
// 检查是否需要签名
if (questionnaireInfo.value && questionnaireInfo.value.mdqz === 1) {
// 如果需要签名但还没有签名,提示用户先签名
if (!sign_file.value) {
uni.showToast({
title: '请先完成签名',
icon: 'none'
});
return;
}
}
// 显示提交遮罩层
submitting.value = true;
// 如果已有签名或不需要签名,直接提交
await doSubmitQuestionnaire();
} catch (error: any) {
console.error('提交失败:', error);
uni.showToast({ title: error.message || '提交失败', icon: 'none' });
} finally {
// 隐藏提交遮罩层
submitting.value = false;
}
};
// 执行提交问卷
const doSubmitQuestionnaire = async () => {
try {
// 验证必填项
for (const question of questionList.value) {
if (question.required === 1) {
if (question.questionType === 'FILE') {
// 文件类型:检查是否有图片或文档
const uploadData = fileUploadData[question.id];
const hasImage = (uploadData?.imageList || []).some(img => img.url);
const hasFile = (uploadData?.fileList || []).some(file => file.url);
if (!hasImage && !hasFile) {
uni.showToast({
title: `${questionList.value.indexOf(question) + 1}题为必填项`,
icon: 'none'
});
return;
}
} else if (question.questionType === 'VIDEO') {
// 视频类型:检查是否有视频
const uploadData = fileUploadData[question.id];
const hasVideo = (uploadData?.videoList || []).some(video => video.url);
if (!hasVideo) {
uni.showToast({
title: `${questionList.value.indexOf(question) + 1}题为必填项`,
icon: 'none'
});
return;
}
} else if (question.questionType === 'SIGNATURE') {
// 签名类型:检查是否有签名
const answer = answers[question.id];
if (!answer || !answer.value) {
uni.showToast({
title: `${questionList.value.indexOf(question) + 1}题为必填项`,
icon: 'none'
});
return;
}
} else {
const answer = answers[question.id];
if (!answer || !answer.value ||
(Array.isArray(answer.value) && answer.value.length === 0)) {
uni.showToast({
title: `${questionList.value.indexOf(question) + 1}题为必填项`,
icon: 'none'
});
return;
}
}
}
}
// 检查是否有文件正在上传
for (const question of questionList.value) {
if (question.questionType === 'FILE' || question.questionType === 'VIDEO') {
const uploadData = fileUploadData[question.id];
if (uploadData) {
const hasUploadingImage = (uploadData.imageList || []).some(img => !img.url);
const hasUploadingFile = (uploadData.fileList || []).some(file => !file.url);
const hasUploadingVideo = (uploadData.videoList || []).some(video => !video.url);
if (hasUploadingImage || hasUploadingFile || hasUploadingVideo) {
uni.showToast({
title: '请等待上传完成',
icon: 'none'
});
return;
}
}
}
}
// 构建提交数据(包含所有分页的题目)
const allQuestions: any[] = [];
pageList.value.forEach(page => {
allQuestions.push(...page.questions);
});
const answerList = allQuestions.map((question: any) => {
const answer = answers[question.id];
let answerValue = answer.value;
let answerScore: number | undefined = undefined;
// 处理不同题型的答案格式和分数计算
if (question.questionType === 'RADIO') {
// 单选框:根据选择的选项 key 获取分数
if (answerValue) {
const options = getQuestionOptions(question);
const selectedOption = options.find((opt: any) => opt.key === answerValue);
if (selectedOption && selectedOption.score !== undefined && selectedOption.score !== null) {
answerScore = selectedOption.score;
}
}
} else if (question.questionType === 'CHECKBOX') {
// 多选框:累加所有选择选项的分数
if (Array.isArray(answerValue) && answerValue.length > 0) {
const options = getQuestionOptions(question);
let totalScore = 0;
answerValue.forEach((key: string) => {
const selectedOption = options.find((opt: any) => opt.key === key);
if (selectedOption && selectedOption.score !== undefined && selectedOption.score !== null) {
totalScore += selectedOption.score;
}
});
answerValue = answerValue.join(',');
answerScore = totalScore > 0 ? totalScore : undefined;
} else {
answerValue = Array.isArray(answerValue) ? answerValue.join(',') : answerValue;
}
} else if (question.questionType === 'SIGNATURE') {
// 签名类型:直接使用 base64 字符串
answerValue = answerValue || '';
} else if (question.questionType === 'FILE') {
// 文件类型合并图片和文档的URL
const uploadData = fileUploadData[question.id];
const imageUrls = (uploadData?.imageList || [])
.filter(img => img.url)
.map(img => img.url)
.filter((url): url is string => !!url);
const fileUrls = (uploadData?.fileList || [])
.filter(file => file.url)
.map(file => file.url)
.filter((url): url is string => !!url);
answerValue = [...imageUrls, ...fileUrls].join(',');
} else if (question.questionType === 'VIDEO') {
// 视频类型获取视频URL
const uploadData = fileUploadData[question.id];
const videoUrls = (uploadData?.videoList || [])
.filter(video => video.url)
.map(video => video.url)
.filter((url): url is string => !!url);
answerValue = videoUrls.join(',');
}
const answerData: any = {
questionnaireId: questionnaireId.value,
questionId: question.id,
answerContent: String(answerValue || ''),
questionType: question.questionType
};
// 如果有分数,添加到提交数据中
if (answerScore !== undefined && answerScore !== null) {
answerData.answerScore = answerScore;
}
return answerData;
});
// 构建提交参数
const submitParams: any = {
questionnaireId: questionnaireId.value,
answerList: answerList
};
// 如果有签名,添加到提交参数中
if (sign_file.value) {
submitParams.signatureImage = sign_file.value;
}
// 如果有xxtsId添加到提交参数中用于更新待办状态
if (xxtsId.value) {
submitParams.xxtsId = xxtsId.value;
}
if (xsId.value) {
submitParams.xsId = xsId.value;
}
// 提交答案
const result = await questionnaireAnswerSaveApi(submitParams);
if (result && result.resultCode === 1) {
// 保存 submitId从返回结果中获取
const resultData = result.result || result.data || {};
if (resultData.submitId) {
submitId.value = resultData.submitId;
}
currentStep.value = 'success';
} else {
throw new Error(result?.message || '提交失败');
}
} catch (error: any) {
console.error('提交失败:', error);
uni.showToast({ title: error.message || '提交失败', icon: 'none' });
}
};
// 返回
const goBack = () => {
uni.navigateBack();
};
// 格式化时间
const formatTime = (time: string | Date) => {
if (!time) return '';
const date = typeof time === 'string' ? new Date(time) : time;
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// 查看AI分析报告
const viewAIAnalysisReport = () => {
// 获取当前用户ID
const currentUserId = userStore.userdata?.id || userStore.userdata?.userId || '';
// 直接跳转到分析页面由分析页面负责调用AI接口并保存结果
uni.navigateTo({
url: `/pages/base/wj/wjEvaluate?questionnaireId=${questionnaireId.value}&userId=${currentUserId}`
});
};
// 加载已填写的答案(用于回显)
const loadExistingAnswers = async (allQuestions: any[]) => {
try {
// 获取当前用户ID
const currentUserId = userStore.userdata?.id || userStore.userdata?.userId || '';
if (!currentUserId) {
console.log('未找到用户ID跳过加载已填写答案');
return;
}
// 获取已填写的答案
const answerResult = await questionnaireAnswerFindDetailApi({
questionnaireId: questionnaireId.value,
userId: currentUserId
});
if (answerResult && answerResult.resultCode === 1) {
const answerList = answerResult.result || answerResult.data || [];
if (answerList && answerList.length > 0) {
console.log('找到已填写的答案,开始回显:', answerList);
// 将答案按题目ID建立映射
const answerMap = new Map<string, any>();
answerList.forEach((answer: any) => {
answerMap.set(answer.questionId, answer);
});
// 回显答案到表单
allQuestions.forEach((question: any) => {
const existingAnswer = answerMap.get(question.id);
if (existingAnswer && existingAnswer.answerContent) {
const answerContent = existingAnswer.answerContent;
if (question.questionType === 'CHECKBOX') {
// 多选框:逗号分隔的字符串转为数组
if (answerContent) {
answers[question.id].value = answerContent.split(',').map((v: string) => v.trim()).filter((v: string) => v);
}
} else if (question.questionType === 'FILE') {
// 文件类型解析URL字符串
if (answerContent) {
const urls = answerContent.split(',').map((v: string) => v.trim()).filter((v: string) => v);
const imageUrls: string[] = [];
const fileUrls: string[] = [];
urls.forEach((url: string) => {
const lowerUrl = url.toLowerCase();
// 判断是图片还是文档
if (lowerUrl.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/i)) {
imageUrls.push(url);
} else {
fileUrls.push(url);
}
});
// 设置到文件上传数据
if (fileUploadData[question.id]) {
fileUploadData[question.id].imageList = imageUrls.map(url => ({ url, name: url.split('/').pop() || '' }));
fileUploadData[question.id].fileList = fileUrls.map(url => ({ url, name: url.split('/').pop() || '' }));
}
}
} else if (question.questionType === 'VIDEO') {
// 视频类型解析URL字符串
if (answerContent) {
const urls = answerContent.split(',').map((v: string) => v.trim()).filter((v: string) => v);
if (fileUploadData[question.id]) {
fileUploadData[question.id].videoList = urls.map(url => ({ url, name: url.split('/').pop() || '' }));
}
}
} else if (question.questionType === 'SIGNATURE') {
// 签名类型直接使用base64字符串
answers[question.id].value = answerContent;
} else {
// 其他类型TEXT, TEXTAREA, NUMBER, DATE, RADIO, SELECT直接赋值
answers[question.id].value = answerContent;
}
}
});
// 如果有问卷级别的签名,也需要回显
// 注意:问卷级别的签名可能不在答案列表中,需要单独处理
// 这里暂时不处理,因为签名通常存储在单独的字段中
console.log('答案回显完成');
} else {
console.log('未找到已填写的答案');
}
}
} catch (error: any) {
console.error('加载已填写答案失败:', error);
// 加载失败不影响主流程,继续显示空白表单
}
};
// 重新填写问卷
const handleResubmit = async () => {
// 重置状态,允许重新填写
currentStep.value = 'fill';
// 重新初始化页面(会自动加载并回显已填写的答案)
await initializePage();
};
</script>
<style lang="scss" scoped>
.questionnaire-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 100rpx;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #666;
}
}
.submit-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.submit-mask-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: white;
padding: 60rpx 80rpx;
border-radius: 16rpx;
.submit-mask-text {
margin-top: 30rpx;
font-size: 28rpx;
color: #333;
}
}
}
.fill-step {
padding: 30rpx;
padding-bottom: 140rpx; // 为底部导航按钮留出空间
}
.questionnaire-header {
background: white;
padding: 30rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
.questionnaire-title {
display: block;
font-size: 36rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.questionnaire-desc {
display: block;
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
}
.questionnaire-info {
background: white;
padding: 20rpx 30rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
.info-item {
display: flex;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 26rpx;
color: #666;
min-width: 140rpx;
}
.info-value {
font-size: 26rpx;
color: #333;
flex: 1;
}
}
}
.questions-container {
.question-item {
background: white;
padding: 30rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
transition: border-color 0.3s;
&.question-item-error {
border: 2px solid #F56C6C;
box-shadow: 0 0 0 2rpx rgba(245, 108, 108, 0.2);
}
.question-header {
display: flex;
align-items: flex-start;
margin-bottom: 16rpx;
flex-wrap: wrap;
.question-required {
color: #F56C6C;
font-size: 28rpx;
margin-right: 4rpx;
line-height: 1.6;
}
.question-title {
font-size: 30rpx;
color: #333;
line-height: 1.6;
flex: 1;
.question-content-inline {
color: #333;
font-weight: 500;
}
.question-type-inline {
color: #666;
font-size: 26rpx;
}
}
}
.question-input {
.input-field, .textarea-field {
width: 100%;
padding: 20rpx;
border: 1px solid #e0e0e0;
border-radius: 8rpx;
font-size: 28rpx;
background: #fafafa;
box-sizing: border-box;
transition: border-color 0.3s;
&.input-field-error, &.textarea-field-error {
border-color: #F56C6C;
background: #fff5f5;
}
}
.input-field {
height: 80rpx;
line-height: 40rpx;
}
.textarea-field {
min-height: 200rpx;
box-sizing: border-box;
}
.picker-view {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
border: 1px solid #e0e0e0;
border-radius: 8rpx;
background: #fafafa;
transition: border-color 0.3s;
&.picker-view-error {
border-color: #F56C6C;
background: #fff5f5;
}
.picker-text {
font-size: 28rpx;
color: #333;
}
.picker-placeholder {
font-size: 28rpx;
color: #999;
}
}
}
.question-options {
padding: 10rpx;
border-radius: 8rpx;
transition: border-color 0.3s;
&.question-options-error {
border: 2px solid #F56C6C;
background: #fff5f5;
}
.option-item {
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
.radio-wrapper, .checkbox-wrapper {
display: flex;
align-items: center;
.radio-circle, .checkbox-square {
width: 40rpx;
height: 40rpx;
border: 2px solid #ddd;
border-radius: 50%;
margin-right: 16rpx;
display: flex;
align-items: center;
justify-content: center;
&.radio-selected, &.checkbox-selected {
border-color: #409EFF;
background: #409EFF;
}
.radio-inner {
width: 20rpx;
height: 20rpx;
background: white;
border-radius: 50%;
}
}
.checkbox-square {
border-radius: 8rpx;
}
.option-label {
font-size: 28rpx;
color: #333;
margin-right: 8rpx;
}
.option-text {
font-size: 28rpx;
color: #333;
flex: 1;
}
}
}
}
.question-upload {
// ImageVideoUpload 组件自带样式,这里可以添加额外样式
padding: 10rpx;
border-radius: 8rpx;
transition: border-color 0.3s;
&.question-upload-error {
border: 2px solid #F56C6C;
background: #fff5f5;
}
}
.question-signature {
padding: 10rpx;
border-radius: 8rpx;
transition: border-color 0.3s;
&.question-signature-error {
border: 2px solid #F56C6C;
background: #fff5f5;
}
.signature-preview-inline {
.signature-preview-img-inline {
width: 100%;
min-height: 200rpx;
max-height: 400rpx;
border: 1px solid #e0e0e0;
border-radius: 8rpx;
background: #fafafa;
}
.signature-actions-inline {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
}
}
.signature-placeholder-inline {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200rpx;
border: 2px dashed #e0e0e0;
border-radius: 8rpx;
background: #fafafa;
cursor: pointer;
transition: all 0.3s;
&:active {
background: #f0f0f0;
border-color: #409EFF;
}
.signature-placeholder-text-inline {
margin-top: 16rpx;
font-size: 28rpx;
color: #999;
}
}
}
}
}
.signature-section {
background: white;
padding: 30rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
.signature-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.signature-label {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.signature-required {
color: #F56C6C;
font-size: 28rpx;
margin-left: 4rpx;
}
}
.signature-preview {
.signature-preview-img {
width: 100%;
min-height: 200rpx;
max-height: 400rpx;
border: 1px solid #e0e0e0;
border-radius: 8rpx;
background: #fafafa;
}
.signature-actions {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
}
}
.signature-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200rpx;
border: 2px dashed #e0e0e0;
border-radius: 8rpx;
background: #fafafa;
cursor: pointer;
transition: all 0.3s;
&:active {
background: #f0f0f0;
border-color: #409EFF;
}
.signature-placeholder-text {
margin-top: 16rpx;
font-size: 28rpx;
color: #999;
}
}
}
.page-header {
background: white;
padding: 30rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
text-align: center;
.page-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.page-progress {
font-size: 26rpx;
color: #666;
margin-left: 8rpx;
}
}
.page-navigation {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: white;
border-top: 1px solid #e0e0e0;
padding: 20rpx 30rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
z-index: 100;
.nav-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 30rpx;
font-weight: 600;
border: none;
border-radius: 8rpx;
margin: 0 10rpx;
&.prev-btn {
background: #f5f5f5;
color: #666;
}
&.next-btn, &.submit-btn {
background: #409EFF;
color: white;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
}
.submit-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: #409EFF;
color: white;
font-size: 32rpx;
font-weight: 600;
border: none;
border-radius: 0;
}
.success-step, .no-permission-step, .already-filled-step, .time-invalid-step {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40rpx;
.success-content, .no-permission-content, .already-filled-content, .time-invalid-content {
text-align: center;
.success-title, .no-permission-title, .already-filled-title, .time-invalid-title {
display: block;
font-size: 36rpx;
font-weight: 600;
color: #333;
margin: 30rpx 0 16rpx;
}
.success-time {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 16rpx;
}
.success-tip, .no-permission-subtitle, .already-filled-subtitle, .time-invalid-subtitle {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 40rpx;
}
.ai-analyze-btn {
width: 320rpx;
height: 80rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 30rpx;
font-weight: 600;
border: none;
border-radius: 40rpx;
margin-bottom: 30rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.4);
transition: all 0.3s;
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(102, 126, 234, 0.3);
}
}
.resubmit-btn {
width: 320rpx;
height: 80rpx;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
font-size: 30rpx;
font-weight: 600;
border: none;
border-radius: 40rpx;
margin-bottom: 30rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(245, 87, 108, 0.4);
transition: all 0.3s;
&:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(245, 87, 108, 0.3);
}
}
.back-btn {
width: 300rpx;
height: 80rpx;
background: #409EFF;
color: white;
font-size: 30rpx;
border: none;
border-radius: 40rpx;
}
}
}
</style>