zhxy-jzd/src/pages/base/wj/indexwb.vue

1762 lines
53 KiB
Vue
Raw Normal View History

2025-12-24 19:45:48 +08:00
<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="!analysisResult && !analyzing" @click="analyzeWithAI" class="ai-analyze-btn">
<u-icon name="file-text" size="18" color="#fff" style="margin-right: 8rpx;"></u-icon>
AI智能分析
</button>
<!-- 分析中状态 -->
<view v-if="analyzing" class="analyzing-container">
<u-loading-icon mode="spinner" size="24" color="#409EFF"></u-loading-icon>
<text class="analyzing-text">AI分析中请稍候...</text>
</view>
<!-- 分析结果 -->
<view v-if="analysisResult" class="analysis-result">
<view class="analysis-header">
<u-icon name="file-text" size="20" color="#409EFF"></u-icon>
<text class="analysis-title">AI分析报告</text>
</view>
<view class="analysis-content">
<text class="analysis-text">{{ analysisResult }}</text>
</view>
</view>
<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>
<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,
questionnaireAnswerFindByUserApi,
questionnaireAnalyzeWithAIApi
} 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 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);
// AI分析相关
const analyzing = ref(false);
const analysisResult = ref('');
// 签名组件相关
const signCompRef = ref<any>(null);
const signTitle = ref<string>("问卷签名");
const sign_file = ref<string>("");
const showSignature = ref<boolean>(false);
const currentSignatureQuestionId = ref<string>(""); // 当前正在签名的题目ID
onLoad(async (params) => {
options.value = params;
openId.value = params?.openId || '';
xxtsId.value = params?.id || ''; // 从URL参数id获取xxtsId
// 如果有 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;
} 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 (questionnaireInfo.value.allowResubmit !== 1) {
const answerResult = await questionnaireAnswerFindByUserApi({
questionnaireId: questionnaireId.value
});
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: []
};
}
});
}
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;
}
// 提交答案
const result = await questionnaireAnswerSaveApi(submitParams);
if (result && result.resultCode === 1) {
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 analyzeWithAI = async () => {
try {
analyzing.value = true;
analysisResult.value = '';
// 构造发送给AI的问卷数据包含所有分页的题目
const allQuestions: any[] = [];
pageList.value.forEach(page => {
allQuestions.push(...page.questions);
});
const questionnaireData = {
questionnaireId: questionnaireId.value,
title: questionnaireInfo.value?.title,
description: questionnaireInfo.value?.description,
questions: allQuestions.map((q: any) => {
let answerValue = answers[q.id]?.value;
// 处理不同类型的答案格式
if (q.questionType === 'CHECKBOX' && Array.isArray(answerValue)) {
answerValue = answerValue.join(',');
} else if (q.questionType === 'FILE') {
const uploadData = fileUploadData[q.id];
const imageUrls = (uploadData?.imageList || []).filter(img => img.url).map(img => img.url);
const fileUrls = (uploadData?.fileList || []).filter(file => file.url).map(file => file.url);
answerValue = [...imageUrls, ...fileUrls].join(',');
} else if (q.questionType === 'VIDEO') {
const uploadData = fileUploadData[q.id];
const videoUrls = (uploadData?.videoList || []).filter(video => video.url).map(video => video.url);
answerValue = videoUrls.join(',');
} else if (q.questionType === 'SIGNATURE') {
answerValue = answerValue ? '[已签名]' : '[未签名]';
}
// 获取分页名称和排序
const pageName = q.pageName || '第一部分';
const pageSortOrder = q.pageSortOrder !== undefined ? q.pageSortOrder : 0;
return {
content: q.questionContent,
type: q.questionType,
answer: answerValue || '[未填写]',
pageName: pageName,
pageSortOrder: pageSortOrder
};
})
};
// 调用后端AI分析接口
const response = await questionnaireAnalyzeWithAIApi(questionnaireData);
if (response && response.resultCode === 1) {
analysisResult.value = response.result || response.data || '分析完成';
} else {
throw new Error(response?.message || 'AI分析失败');
}
} catch (error: any) {
console.error('AI分析失败:', error);
uni.showToast({ title: error.message || 'AI分析失败请稍后重试', icon: 'none', duration: 2000 });
} finally {
analyzing.value = false;
}
};
</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);
}
}
.analyzing-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30rpx;
padding: 30rpx;
background: #f5f7fa;
border-radius: 16rpx;
.analyzing-text {
margin-left: 20rpx;
font-size: 28rpx;
color: #409EFF;
}
}
.analysis-result {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
text-align: left;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
max-width: 100%;
.analysis-header {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 2rpx solid #eee;
.analysis-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-left: 12rpx;
}
}
.analysis-content {
.analysis-text {
font-size: 28rpx;
color: #666;
line-height: 1.8;
word-break: break-word;
white-space: pre-wrap;
}
}
}
.back-btn {
width: 300rpx;
height: 80rpx;
background: #409EFF;
color: white;
font-size: 30rpx;
border: none;
border-radius: 40rpx;
}
}
}
</style>