yuanmingze 1 mesiac pred
rodič
commit
020c7e0014

+ 225 - 2
src/pages-task/task-detail/components/NormalTaskDetail.vue

@@ -45,9 +45,55 @@
         </template>
       </view>
 
+      <view v-if="field.quizResults.length" class="task-detail-field__quizzes">
+        <view
+          v-for="(quiz, quizIndex) in field.quizResults"
+          :key="`${quiz.name}-${quizIndex}`"
+          class="quiz-result"
+        >
+          <view class="quiz-result__header">
+            <view class="quiz-result__icon">
+              <view class="quiz-result__icon-line quiz-result__icon-line--short" />
+              <view class="quiz-result__icon-line" />
+              <view class="quiz-result__icon-line" />
+            </view>
+
+            <view class="quiz-result__heading">
+              <text class="quiz-result__title">{{ quiz.name }}</text>
+              <text class="quiz-result__count">共 {{ quiz.questions.length }} 题</text>
+            </view>
+          </view>
+
+          <view v-if="quiz.questions.length" class="quiz-result__questions">
+            <view
+              v-for="(question, questionIndex) in quiz.questions"
+              :key="`${questionIndex}-${question.question}`"
+              class="quiz-result__question"
+            >
+              <text class="quiz-result__number">{{ questionIndex + 1 }}</text>
+
+              <view class="quiz-result__question-main">
+                <text class="quiz-result__question-title">{{ question.question }}</text>
+                <view class="quiz-result__answer">
+                  <text class="quiz-result__answer-label">作答</text>
+                  <text
+                    class="quiz-result__answer-value"
+                    :class="{ 'quiz-result__answer-value--empty': !question.answered }"
+                  >
+                    {{ question.answer }}
+                  </text>
+                </view>
+              </view>
+            </view>
+          </view>
+
+          <view v-else class="quiz-result__empty">暂无答题记录</view>
+        </view>
+      </view>
+
       <view v-if="field.text" class="task-detail-field__text">{{ field.text }}</view>
       <view
-        v-else-if="!field.attachments.length"
+        v-else-if="!field.attachments.length && !field.quizResults.length"
         class="task-detail-field__text task-detail-field__text--empty"
       >
         -
@@ -71,11 +117,13 @@ import type {
 } from '@/services/modules/task/taskFrom/type'
 
 import { toFullUrl } from '../utils'
+import { parseQuizResult } from './quizResult'
 import TaskDetailSummary from './TaskDetailSummary.vue'
 
 const IMAGE_FIELD_TYPES = new Set(['img', 'sign'])
 const IMAGE_AND_FILE_FIELD_TYPE = 'imgandfile'
 const FILE_URL_FIELD_TYPE = 'fileurl'
+const QUIZ_FIELD_TYPE = 'quiz'
 const DICT_FIELD_TYPES = new Set(['select', 'domain', 'multiple_select'])
 const FILE_EXTENSIONS = new Set(['pdf', 'ppt', 'pptx'])
 const IMAGE_EXTENSIONS = new Set([
@@ -167,6 +215,7 @@ const dictLabelsByField = computed(() => {
  * 其余情况在 item.label 为空时用 value 回显。
  * img、sign 类型转成图片列表;imgAndFile 根据值末尾来源标记转成图片或文件;
  * fileurl 解析 value 中的 JSON 数组,取 url 和 fileName 展示附件,仅图片后缀使用图片预览;
+ * quiz 反序列化 value 并展示问卷名称、问题和答案,解析失败时保留原始 value 回显;
  * 其余类型显示标准化后的 label。
  */
 const contentFields = computed(() => {
@@ -179,6 +228,9 @@ const contentFields = computed(() => {
       const items = field.items.map((item) => ({
         ...item,
         label: resolveItemLabel(name, item),
+        quizResult: isQuizFieldType(item.type)
+          ? parseQuizResult(item.value, item.label?.trim() || name)
+          : null,
       }))
       const attachments = items.flatMap((item) => {
         if (isFileUrlFieldType(item.type)) {
@@ -205,12 +257,14 @@ const contentFields = computed(() => {
       const imageUrls = uniqueAttachments
         .filter((attachment) => attachment.type === 'image')
         .map((attachment) => attachment.url)
+      const quizResults = items.flatMap((item) => (item.quizResult ? [item.quizResult] : []))
       const text = items
         .filter(
           (item) =>
             !isImageFieldType(item.type) &&
             !isImageAndFileFieldType(item.type) &&
-            !isFileUrlFieldType(item.type)
+            !isFileUrlFieldType(item.type) &&
+            !item.quizResult
         )
         .map((item) => item.label)
         .filter(Boolean)
@@ -222,6 +276,7 @@ const contentFields = computed(() => {
         required: field.required,
         attachments: uniqueAttachments,
         imageUrls,
+        quizResults,
         text,
       }
     })
@@ -259,6 +314,10 @@ const isFileUrlFieldType = (type: string): boolean => {
   return type.trim().toLowerCase() === FILE_URL_FIELD_TYPE
 }
 
+const isQuizFieldType = (type: string): boolean => {
+  return type.trim().toLowerCase() === QUIZ_FIELD_TYPE
+}
+
 const parseFileUrlAttachments = (value: string): DetailAttachment[] => {
   try {
     const files: unknown = JSON.parse(value)
@@ -523,6 +582,170 @@ const showFileMessage = (title: string): void => {
   margin-top: 18rpx;
 }
 
+.task-detail-field__quizzes {
+  display: flex;
+  flex-direction: column;
+  gap: 18rpx;
+  margin-top: 18rpx;
+}
+
+.quiz-result {
+  overflow: hidden;
+  border: 1rpx solid #e6eef8;
+  border-radius: 18rpx;
+  background: #fff;
+  box-shadow: 0 8rpx 24rpx rgba(44, 112, 181, 0.07);
+}
+
+.quiz-result__header {
+  display: flex;
+  align-items: center;
+  gap: 18rpx;
+  padding: 22rpx 24rpx;
+  background: linear-gradient(135deg, #f2f8ff 0%, #f8fbff 54%, #eef7ff 100%);
+  border-bottom: 1rpx solid #e5eef8;
+}
+
+.quiz-result__icon {
+  display: flex;
+  flex-shrink: 0;
+  flex-direction: column;
+  justify-content: center;
+  gap: 6rpx;
+  width: 54rpx;
+  height: 54rpx;
+  padding: 0 13rpx;
+  background: linear-gradient(145deg, #4cacfa, #2788e7);
+  border-radius: 15rpx;
+  box-shadow: 0 7rpx 14rpx rgba(39, 136, 231, 0.2);
+  box-sizing: border-box;
+}
+
+.quiz-result__icon-line {
+  width: 28rpx;
+  height: 4rpx;
+  background: rgba(255, 255, 255, 0.94);
+  border-radius: 3rpx;
+}
+
+.quiz-result__icon-line--short {
+  width: 19rpx;
+}
+
+.quiz-result__heading {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+}
+
+.quiz-result__title {
+  overflow: hidden;
+  color: #1c344f;
+  font-size: 29rpx;
+  font-weight: 650;
+  line-height: 42rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.quiz-result__count {
+  flex-shrink: 0;
+  padding: 5rpx 14rpx;
+  color: #3188d9;
+  font-size: 20rpx;
+  line-height: 30rpx;
+  background: rgba(59, 155, 237, 0.1);
+  border-radius: 20rpx;
+}
+
+.quiz-result__questions {
+  padding: 0 24rpx;
+}
+
+.quiz-result__question {
+  display: flex;
+  gap: 18rpx;
+  padding: 24rpx 0;
+  border-bottom: 1rpx solid #edf2f7;
+}
+
+.quiz-result__question:last-child {
+  border-bottom: 0;
+}
+
+.quiz-result__number {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 42rpx;
+  height: 42rpx;
+  color: #fff;
+  font-size: 22rpx;
+  font-weight: 650;
+  line-height: 42rpx;
+  text-align: center;
+  background: #3b9bed;
+  border-radius: 50%;
+}
+
+.quiz-result__question-main {
+  min-width: 0;
+  flex: 1;
+}
+
+.quiz-result__question-title {
+  display: block;
+  color: #1e293b;
+  font-size: 27rpx;
+  font-weight: 600;
+  line-height: 40rpx;
+  word-break: break-all;
+}
+
+.quiz-result__answer {
+  display: flex;
+  align-items: flex-start;
+  gap: 12rpx;
+  margin-top: 12rpx;
+  padding: 14rpx 16rpx;
+  background: #f7f9fc;
+  border-radius: 12rpx;
+}
+
+.quiz-result__answer-label {
+  flex-shrink: 0;
+  padding: 2rpx 10rpx;
+  color: #3b82c4;
+  font-size: 20rpx;
+  line-height: 30rpx;
+  background: #e7f2fc;
+  border-radius: 8rpx;
+}
+
+.quiz-result__answer-value {
+  min-width: 0;
+  color: #314158;
+  font-size: 25rpx;
+  line-height: 34rpx;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.quiz-result__answer-value--empty {
+  color: #9aa6b5;
+}
+
+.quiz-result__empty {
+  padding: 36rpx 24rpx;
+  color: #9aa6b5;
+  font-size: 24rpx;
+  text-align: center;
+}
+
 .task-detail-field__image {
   width: 196rpx;
   height: 196rpx;

+ 86 - 0
src/pages-task/task-detail/components/quizResult.ts

@@ -0,0 +1,86 @@
+export interface QuizResultQuestion {
+  question: string
+  answer: string
+  answered: boolean
+}
+
+export interface QuizResultView {
+  name: string
+  questions: QuizResultQuestion[]
+}
+
+const MAX_SERIALIZATION_DEPTH = 2
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+const serializeDisplayValue = (value: unknown): string => {
+  if (value === null || value === undefined) return ''
+
+  if (Array.isArray(value)) {
+    return value.map(serializeDisplayValue).filter(Boolean).join('、')
+  }
+
+  if (isRecord(value)) {
+    const preferredValue = value.label ?? value.name ?? value.value
+    if (preferredValue !== undefined && preferredValue !== value) {
+      return serializeDisplayValue(preferredValue)
+    }
+
+    try {
+      return JSON.stringify(value)
+    } catch {
+      return String(value)
+    }
+  }
+
+  return String(value).trim()
+}
+
+const deserializeQuizValue = (value: unknown): unknown => {
+  let result = value
+
+  for (let depth = 0; depth < MAX_SERIALIZATION_DEPTH && typeof result === 'string'; depth++) {
+    const serializedValue = result.trim()
+    if (!serializedValue) return null
+
+    try {
+      result = JSON.parse(serializedValue) as unknown
+    } catch {
+      return null
+    }
+  }
+
+  return result
+}
+
+/**
+ * 将 quiz 字段保存的序列化问卷结果转换成详情页可直接展示的数据。
+ * 返回 null 时由调用方保留原始 value,确保异常旧数据仍可回显。
+ */
+export const parseQuizResult = (value: unknown, fallbackName = '问卷'): QuizResultView | null => {
+  const result = deserializeQuizValue(value)
+  if (!isRecord(result)) return null
+
+  const answerList = Array.isArray(result.answerList) ? result.answerList : null
+  if (!answerList) return null
+
+  const serializedName = serializeDisplayValue(result.name)
+  const questions = answerList.map((item, index): QuizResultQuestion => {
+    const itemRecord = isRecord(item) ? item : null
+    const question = serializeDisplayValue(itemRecord?.question)
+    const answer = serializeDisplayValue(itemRecord ? itemRecord.answer : item)
+
+    return {
+      question: question || `第 ${index + 1} 题`,
+      answer: answer || '未作答',
+      answered: Boolean(answer),
+    }
+  })
+
+  return {
+    name: serializedName || fallbackName.trim() || '问卷',
+    questions,
+  }
+}