Ver Fonte

完成任务详情针对转发类任务、分享类任务重构

yuanmingze há 1 mês atrás
pai
commit
30e4362848

+ 2 - 2
.env.development

@@ -1,6 +1,6 @@
 VITE_MODE=development
 VITE_APP_TYPE=dev
 VITE_PROJECT_TITLE=要易小助手
-# VITE_BASE_API=https://preapi.yaoyi.net
-VITE_BASE_API=https://pre1.yaoyi.net
+VITE_BASE_API=https://preapi.yaoyi.net
+# VITE_BASE_API=https://pre1.yaoyi.net
 VITE_WX_APPID=wxd03398e1bff2b241

+ 0 - 2
components.d.ts

@@ -17,7 +17,6 @@ declare module 'vue' {
     FormField: typeof import('./src/components/FormField/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
-    WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['WdActionSheet']
     WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['WdButton']
     WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['WdCalendar']
     WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['WdCascader']
@@ -58,7 +57,6 @@ declare global {
   const FormField: typeof import('./src/components/FormField/index.vue')['default']
   const RouterLink: typeof import('vue-router')['RouterLink']
   const RouterView: typeof import('vue-router')['RouterView']
-  const WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['WdActionSheet']
   const WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['WdButton']
   const WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['WdCalendar']
   const WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['WdCascader']

+ 1 - 1
src/pages-task/completed-record/index.vue

@@ -281,7 +281,7 @@ const handleShowRejectReason = (item: TaskRecordItem) => {
 
 const handleOpenDetail = (id: TaskRecordItem['id']) => {
   uni.navigateTo({
-    url: `/pages/task/task-detail/index?id=${id}`,
+    url: `/pages-task/task-detail/index?taskId=${encodeURIComponent(String(id))}`,
   })
 }
 

+ 205 - 0
src/pages-task/task-detail/components/NormalTaskDetail.vue

@@ -0,0 +1,205 @@
+<template>
+  <TaskDetailSummary :task-detail="taskDetail" />
+
+  <view class="task-detail-content">
+    <view class="task-detail-content__heading">
+      <view class="task-detail-content__heading-mark" />
+      <text>任务内容</text>
+    </view>
+
+    <view
+      v-for="field in contentFields"
+      :key="`${field.seq}-${field.name}`"
+      class="task-detail-field"
+    >
+      <view class="task-detail-field__name">
+        <text v-if="field.required" class="task-detail-field__required">*</text>
+        {{ field.name }}
+      </view>
+
+      <view v-if="field.imageUrls.length" class="task-detail-field__images">
+        <image
+          v-for="(url, index) in field.imageUrls"
+          :key="`${url}-${index}`"
+          class="task-detail-field__image"
+          :src="url"
+          mode="aspectFill"
+          @click="previewImages(field.imageUrls, index)"
+        />
+      </view>
+
+      <view v-if="field.text" class="task-detail-field__text">{{ field.text }}</view>
+      <view
+        v-else-if="!field.imageUrls.length"
+        class="task-detail-field__text task-detail-field__text--empty"
+      >
+        -
+      </view>
+    </view>
+
+    <view v-if="!contentFields.length" class="task-detail-content__empty"> 暂无任务内容 </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type {
+  TaskDetailContentField,
+  TaskDetailContentItem,
+  TaskFormDetailResponse,
+} from '@/services/modules/task/taskDetail/type'
+
+import { previewImages, toFullUrl } from '../utils'
+import TaskDetailSummary from './TaskDetailSummary.vue'
+
+interface TaskDetailContentDisplayItem extends Omit<TaskDetailContentItem, 'label'> {
+  label: string
+}
+
+interface TaskDetailContentDisplayField extends Omit<TaskDetailContentField, 'items'> {
+  name: string
+  items: TaskDetailContentDisplayItem[]
+  imageUrls: string[]
+  text: string
+}
+
+const IMAGE_FIELD_TYPES = new Set(['img', 'sign'])
+
+const props = defineProps<{
+  taskDetail: TaskFormDetailResponse
+}>()
+
+/**
+ * wmTaskContent 按 seq 排序;item.label 为空时用 value 回显。
+ * img、sign 类型转成图片列表,其余类型显示标准化后的 label。
+ */
+const contentFields = computed<TaskDetailContentDisplayField[]>(() => {
+  const content = props.taskDetail.wmTaskContent
+  if (!content) return []
+
+  return Object.entries(content)
+    .sort(([, left], [, right]) => left.seq - right.seq)
+    .map(([name, field]) => {
+      const items = field.items.map(normalizeContentItem)
+      const imageUrls = items
+        .filter((item) => isImageFieldType(item.type))
+        .flatMap((item) => parseImageUrls(item.label))
+      const text = items
+        .filter((item) => !isImageFieldType(item.type))
+        .map((item) => item.label)
+        .filter(Boolean)
+        .join('、')
+
+      return {
+        name,
+        seq: field.seq,
+        required: field.required,
+        items,
+        imageUrls: [...new Set(imageUrls)],
+        text,
+      }
+    })
+})
+
+const normalizeContentItem = (item: TaskDetailContentItem): TaskDetailContentDisplayItem => {
+  return {
+    ...item,
+    label: item.label?.trim() || item.value,
+  }
+}
+
+const isImageFieldType = (type: string) => {
+  return IMAGE_FIELD_TYPES.has(type.trim().toLowerCase())
+}
+
+const parseImageUrls = (value: string): string[] => {
+  return value
+    .split(',')
+    .map((url) => toFullUrl(url))
+    .filter(Boolean)
+}
+</script>
+
+<style lang="scss" scoped>
+.task-detail-content {
+  margin-top: 24rpx;
+  overflow: hidden;
+  background: #fff;
+  border-radius: 20rpx;
+  box-shadow: 0 8rpx 24rpx rgba(24, 39, 75, 0.05);
+}
+
+.task-detail-content__heading {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  min-height: 86rpx;
+  padding: 0 28rpx;
+  color: #1e293b;
+  font-size: 29rpx;
+  font-weight: 650;
+  border-bottom: 1rpx solid #eef2f6;
+}
+
+.task-detail-content__heading-mark {
+  width: 7rpx;
+  height: 30rpx;
+  background: #3b9bed;
+  border-radius: 4rpx;
+}
+
+.task-detail-field {
+  padding: 24rpx 28rpx 28rpx;
+  border-bottom: 1rpx solid #f1f4f7;
+}
+
+.task-detail-field:last-child {
+  border-bottom: 0;
+}
+
+.task-detail-field__name {
+  color: #6f7d8f;
+  font-size: 24rpx;
+  line-height: 36rpx;
+}
+
+.task-detail-field__required {
+  margin-right: 4rpx;
+  color: #e5484d;
+}
+
+.task-detail-field__text {
+  margin-top: 12rpx;
+  color: #1e293b;
+  font-size: 27rpx;
+  line-height: 42rpx;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.task-detail-field__text--empty {
+  color: #9aa6b5;
+}
+
+.task-detail-field__images {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-top: 18rpx;
+}
+
+.task-detail-field__image {
+  width: 196rpx;
+  height: 196rpx;
+  background: #eef2f6;
+  border-radius: 14rpx;
+}
+
+.task-detail-content__empty {
+  padding: 80rpx 28rpx;
+  color: #9aa6b5;
+  font-size: 25rpx;
+  text-align: center;
+}
+</style>

+ 280 - 0
src/pages-task/task-detail/components/ShareTaskDetail.vue

@@ -0,0 +1,280 @@
+<template>
+  <TaskDetailSummary :task-detail="taskDetail" />
+
+  <view class="share-detail-card">
+    <view class="share-detail-card__heading">
+      <view class="share-detail-card__heading-mark" />
+      <text>分享内容</text>
+    </view>
+
+    <view class="share-detail-info">
+      <view class="share-detail-info__row">
+        <text class="share-detail-info__label">转发时间</text>
+        <text class="share-detail-info__value">{{ forwardTime }}</text>
+      </view>
+      <view class="share-detail-info__row">
+        <text class="share-detail-info__label">文章标题</text>
+        <text class="share-detail-info__value">{{ shareTitle }}</text>
+      </view>
+      <view class="share-detail-info__row">
+        <text class="share-detail-info__label">获得积分</text>
+        <text class="share-detail-info__value share-detail-info__value--score">
+          +{{ scoreText }}
+        </text>
+      </view>
+      <view class="share-detail-info__row">
+        <text class="share-detail-info__label">备注</text>
+        <text class="share-detail-info__value">{{ remarkText }}</text>
+      </view>
+    </view>
+
+    <image
+      v-if="shareImageUrl"
+      class="share-detail-poster"
+      :src="shareImageUrl"
+      mode="widthFix"
+      @click="previewImages([shareImageUrl])"
+    />
+
+    <view class="share-detail-qrcode">
+      <view class="share-detail-qrcode__box">
+        <view v-if="qrLoading" class="share-detail-qrcode__state">
+          <wd-loading color="#3b9bed" />
+          <text>二维码生成中</text>
+        </view>
+
+        <image
+          v-if="qrCodeUrl"
+          class="share-detail-qrcode__image"
+          :src="qrCodeUrl"
+          mode="aspectFit"
+          @load="onQrImgLoad"
+          @error="onQrImgError"
+        />
+
+        <view v-if="qrError" class="share-detail-qrcode__state">
+          <text>{{ qrError }}</text>
+          <button class="share-detail-qrcode__retry" @click="loadQrCode">重新生成</button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+
+import { getQrCodeByUrlApi } from '@/services/modules/task/taskDetail'
+import type { ShareTaskDetailResponse } from '@/services/modules/task/taskDetail/type'
+
+import { getAppBaseUrl, normalizeText, previewImages, toFullUrl } from '../utils'
+import TaskDetailSummary from './TaskDetailSummary.vue'
+
+const ARTICLE_TASK_TYPES = new Set(['9', '10'])
+
+const props = defineProps<{
+  taskDetail: ShareTaskDetailResponse
+}>()
+
+const qrCodeUrl = ref('')
+const qrError = ref('')
+const qrLoading = ref(false)
+
+const forwardTime = computed(() => normalizeText(props.taskDetail.taskInfo.createTime))
+const shareTitle = computed(() => normalizeText(props.taskDetail.title))
+const scoreText = computed(() => normalizeText(props.taskDetail.score, '0'))
+const remarkText = computed(() => normalizeText(props.taskDetail.remark))
+const shareImageUrl = computed(() => {
+  const url = props.taskDetail.taskInfo.shareImgUrl
+  return url ? toFullUrl(url) : ''
+})
+
+const buildShareUrl = () => {
+  const shareId = props.taskDetail.shareId
+  if (shareId === undefined || shareId === null || shareId === '') return ''
+
+  const taskType = String(props.taskDetail.taskType)
+  const shareType = ARTICLE_TASK_TYPES.has(taskType) ? 'article' : 'null'
+
+  return (
+    `${getAppBaseUrl()}/h5/#/pages/artile/detail` +
+    `?type=${encodeURIComponent(shareType)}` +
+    `&id=${encodeURIComponent(String(shareId))}`
+  )
+}
+
+const loadQrCode = async () => {
+  if (qrLoading.value) return
+
+  const shareUrl = buildShareUrl()
+  qrCodeUrl.value = ''
+  qrError.value = ''
+
+  if (!shareUrl) {
+    qrError.value = '缺少分享内容 ID,无法生成二维码'
+    return
+  }
+
+  qrLoading.value = true
+
+  try {
+    const res = await getQrCodeByUrlApi(shareUrl)
+    const result = res.data
+    const imagePath = result?.data?.url
+
+    if (!result?.success || result.code !== 0 || !imagePath) {
+      throw new Error(result?.msg || '二维码接口未返回图片地址')
+    }
+
+    const fullUrl = toFullUrl(imagePath)
+    qrCodeUrl.value = `${fullUrl}${fullUrl.includes('?') ? '&' : '?'}t=${Date.now()}`
+  } catch (error) {
+    console.error('[task-detail] 分享二维码生成失败', error)
+    qrLoading.value = false
+    qrError.value = error instanceof Error ? error.message : '二维码生成失败'
+  }
+}
+
+const onQrImgLoad = () => {
+  qrLoading.value = false
+}
+
+const onQrImgError = () => {
+  qrLoading.value = false
+  qrCodeUrl.value = ''
+  qrError.value = '二维码图片加载失败'
+}
+
+onMounted(() => {
+  void loadQrCode()
+})
+</script>
+
+<style lang="scss" scoped>
+.share-detail-card {
+  margin-top: 24rpx;
+  overflow: hidden;
+  background: #fff;
+  border-radius: 20rpx;
+  box-shadow: 0 8rpx 24rpx rgba(24, 39, 75, 0.05);
+}
+
+.share-detail-card__heading {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  min-height: 86rpx;
+  padding: 0 28rpx;
+  color: #1e293b;
+  font-size: 29rpx;
+  font-weight: 650;
+  border-bottom: 1rpx solid #eef2f6;
+}
+
+.share-detail-card__heading-mark {
+  width: 7rpx;
+  height: 30rpx;
+  background: #3b9bed;
+  border-radius: 4rpx;
+}
+
+.share-detail-info {
+  padding: 18rpx 28rpx 6rpx;
+}
+
+.share-detail-info__row {
+  display: flex;
+  align-items: flex-start;
+  padding: 12rpx 0;
+  font-size: 26rpx;
+  line-height: 40rpx;
+}
+
+.share-detail-info__label {
+  flex: 0 0 150rpx;
+  color: #53647a;
+  font-size: 27rpx;
+  font-weight: 600;
+}
+
+.share-detail-info__value {
+  flex: 1;
+  min-width: 0;
+  color: #1e293b;
+  word-break: break-all;
+}
+
+.share-detail-info__value--score {
+  color: #f59e0b;
+  font-weight: 650;
+}
+
+.share-detail-poster {
+  display: block;
+  width: calc(100% - 56rpx);
+  margin: 18rpx 28rpx 0;
+  background: #eef2f6;
+  border-radius: 16rpx;
+}
+
+.share-detail-qrcode {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 24rpx 28rpx 34rpx;
+}
+
+.share-detail-qrcode__box {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 340rpx;
+  height: 340rpx;
+  padding: 12rpx;
+  background: #f8fafc;
+  border: 1rpx solid #e8edf3;
+  border-radius: 18rpx;
+  box-sizing: border-box;
+}
+
+.share-detail-qrcode__image {
+  width: 316rpx;
+  height: 316rpx;
+}
+
+.share-detail-qrcode__state {
+  position: absolute;
+  z-index: 1;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 18rpx;
+  padding: 28rpx;
+  color: #8491a2;
+  font-size: 23rpx;
+  line-height: 34rpx;
+  text-align: center;
+  background: #f8fafc;
+  border-radius: 18rpx;
+  box-sizing: border-box;
+}
+
+.share-detail-qrcode__retry {
+  height: 58rpx;
+  margin: 0;
+  padding: 0 28rpx;
+  color: #fff;
+  font-size: 23rpx;
+  line-height: 58rpx;
+  background: #3b9bed;
+  border: 0;
+  border-radius: 29rpx;
+}
+
+.share-detail-qrcode__retry::after {
+  border: 0;
+}
+</style>

+ 68 - 0
src/pages-task/task-detail/components/SignTaskDetail.vue

@@ -0,0 +1,68 @@
+<template>
+  <TaskDetailSummary :task-detail="taskDetail" />
+
+  <view class="sign-detail-card">
+    <view class="sign-detail-card__heading">
+      <view class="sign-detail-card__title">拜访记录</view>
+    </view>
+
+    <view v-if="displayRecords.length" class="sign-detail-card__list">
+      <SignRecordCard v-for="item in displayRecords" :key="item.record.id" :item="item" />
+    </view>
+
+    <view v-else class="sign-detail-card__empty">暂无签到/拜访记录</view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { SignTaskDetailResponse } from '@/services/modules/task/taskDetail/type'
+import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
+
+import SignRecordCard from './sign-task-detail/SignRecordCard.vue'
+import { toSignRecordDisplays } from './sign-task-detail/transform'
+import TaskDetailSummary from './TaskDetailSummary.vue'
+
+const props = defineProps<{
+  taskDetail: SignTaskDetailResponse
+  records: VisitSignRecordItem[]
+}>()
+
+const displayRecords = computed(() => {
+  return toSignRecordDisplays(props.records, props.taskDetail.taskType)
+})
+</script>
+
+<style lang="scss" scoped>
+.sign-detail-card {
+  margin-top: 24rpx;
+  overflow: hidden;
+  background: #fff;
+  border-radius: 20rpx;
+  box-shadow: 0 8rpx 24rpx rgba(24, 39, 75, 0.05);
+}
+
+.sign-detail-card__heading {
+  padding: 24rpx 28rpx;
+  border-bottom: 1rpx solid #eef2f6;
+}
+
+.sign-detail-card__title {
+  color: #1e293b;
+  font-size: 29rpx;
+  font-weight: 650;
+  line-height: 42rpx;
+}
+
+.sign-detail-card__list {
+  padding: 0;
+}
+
+.sign-detail-card__empty {
+  padding: 90rpx 28rpx;
+  color: #9aa6b5;
+  font-size: 25rpx;
+  text-align: center;
+}
+</style>

+ 85 - 0
src/pages-task/task-detail/components/TaskDetailSummary.vue

@@ -0,0 +1,85 @@
+<template>
+  <view class="task-detail-summary">
+    <text class="task-detail-summary__score">+{{ scoreText }} 积分</text>
+    <view class="task-detail-summary__title">{{ taskTitle }}</view>
+    <view class="task-detail-summary__number">任务编号:{{ taskNumber }}</view>
+    <view class="task-detail-summary__time">提交时间:{{ submitTime }}</view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TaskDetailResponse } from '@/services/modules/task/taskDetail/type'
+
+import { normalizeText } from '../utils'
+
+const props = defineProps<{
+  taskDetail: TaskDetailResponse
+}>()
+
+const taskTitle = computed(() => {
+  return normalizeText(
+    props.taskDetail.taskInfo.taskTypeId ??
+      props.taskDetail.taskInfo.taskTypeName ??
+      props.taskDetail.taskType,
+    '任务详情'
+  )
+})
+
+const taskNumber = computed(() => {
+  return normalizeText(props.taskDetail.taskInfo.taskNumber ?? props.taskDetail.taskInfo.id)
+})
+
+const submitTime = computed(() => normalizeText(props.taskDetail.taskInfo.createTime))
+
+const scoreText = computed(() => {
+  const score =
+    'score' in props.taskDetail ? props.taskDetail.score : props.taskDetail.taskInfo.score
+
+  return normalizeText(score, '0')
+})
+</script>
+
+<style lang="scss" scoped>
+.task-detail-summary {
+  position: relative;
+  padding: 32rpx;
+  color: #fff;
+  background: linear-gradient(135deg, #42b6f5 0%, #267ae8 100%);
+  border-radius: 24rpx;
+  box-shadow: 0 14rpx 34rpx rgba(47, 140, 247, 0.22);
+}
+
+.task-detail-summary__score {
+  position: absolute;
+  top: 32rpx;
+  right: 32rpx;
+  color: #fff4bd;
+  font-size: 26rpx;
+  font-weight: 650;
+  line-height: 42rpx;
+}
+
+.task-detail-summary__title {
+  min-height: 42rpx;
+  padding-right: 190rpx;
+  font-size: 38rpx;
+  font-weight: 700;
+  line-height: 52rpx;
+  word-break: break-all;
+}
+
+.task-detail-summary__number,
+.task-detail-summary__time {
+  margin-top: 10rpx;
+  font-size: 23rpx;
+  line-height: 34rpx;
+  opacity: 0.78;
+  word-break: break-all;
+}
+
+.task-detail-summary__time {
+  margin-top: 4rpx;
+}
+</style>

+ 70 - 0
src/pages-task/task-detail/components/sign-task-detail/SignEvaluation.vue

@@ -0,0 +1,70 @@
+<template>
+  <view class="sign-evaluation">
+    <view class="sign-evaluation__title">拜访评价</view>
+    <view v-for="row in rows" :key="row.label" class="sign-evaluation__item">
+      <text>{{ row.label }}</text>
+      <text
+        class="sign-evaluation__result"
+        :class="row.value ? 'sign-evaluation__result--yes' : 'sign-evaluation__result--no'"
+      >
+        {{ row.value ? '是' : '否' }}
+      </text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { VisitEvaluationDetail } from '@/services/modules/task/taskVisit/type'
+
+const props = defineProps<{
+  evaluation: VisitEvaluationDetail
+}>()
+
+const rows = computed(() => [
+  { label: '是否针对了正确的 HCP', value: props.evaluation.rightHcp },
+  { label: '是否传递了正确的信息', value: props.evaluation.rightInfo },
+  { label: '活动形式和频率是否正确', value: props.evaluation.rightActivity },
+])
+</script>
+
+<style lang="scss" scoped>
+.sign-evaluation {
+  margin: 16rpx 0 10rpx;
+  padding: 18rpx 0;
+  border-top: 1rpx solid #e8edf3;
+  border-bottom: 1rpx solid #e8edf3;
+}
+
+.sign-evaluation__title {
+  margin-bottom: 8rpx;
+  color: #3f5167;
+  font-size: 26rpx;
+  font-weight: 650;
+}
+
+.sign-evaluation__item {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 20rpx;
+  padding: 8rpx 0;
+  color: #526278;
+  font-size: 24rpx;
+  line-height: 36rpx;
+}
+
+.sign-evaluation__result {
+  flex-shrink: 0;
+  font-weight: 650;
+}
+
+.sign-evaluation__result--yes {
+  color: #16a36a;
+}
+
+.sign-evaluation__result--no {
+  color: #dc6262;
+}
+</style>

+ 73 - 0
src/pages-task/task-detail/components/sign-task-detail/SignPhotoSection.vue

@@ -0,0 +1,73 @@
+<template>
+  <view class="sign-photo-section">
+    <view class="sign-photo-section__label">{{ label }}</view>
+
+    <view
+      v-for="(group, groupIndex) in groups"
+      :key="`${group.title || 'photo'}-${groupIndex}`"
+      class="sign-photo-section__group"
+    >
+      <view v-if="group.title" class="sign-photo-section__group-title">
+        {{ group.title }}
+      </view>
+      <view class="sign-photo-section__images">
+        <image
+          v-for="(url, imageIndex) in group.urls"
+          :key="`${url}-${imageIndex}`"
+          class="sign-photo-section__image"
+          :src="url"
+          mode="aspectFill"
+          @click="previewImages(group.urls, imageIndex)"
+        />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { previewImages } from '../../utils'
+import type { SignPhotoGroupDisplay } from './types'
+
+defineProps<{
+  label: string
+  groups: SignPhotoGroupDisplay[]
+}>()
+</script>
+
+<style lang="scss" scoped>
+.sign-photo-section {
+  padding: 12rpx 0;
+}
+
+.sign-photo-section__label {
+  color: #4f6075;
+  font-size: 26rpx;
+  font-weight: 600;
+  line-height: 40rpx;
+}
+
+.sign-photo-section__group {
+  margin-top: 14rpx;
+}
+
+.sign-photo-section__group-title {
+  color: #34465c;
+  font-size: 25rpx;
+  font-weight: 600;
+  line-height: 38rpx;
+}
+
+.sign-photo-section__images {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14rpx;
+  margin-top: 12rpx;
+}
+
+.sign-photo-section__image {
+  width: 176rpx;
+  height: 176rpx;
+  background: #e9eef5;
+  border-radius: 12rpx;
+}
+</style>

+ 85 - 0
src/pages-task/task-detail/components/sign-task-detail/SignRecordCard.vue

@@ -0,0 +1,85 @@
+<template>
+  <view class="sign-record-card">
+    <view class="sign-record-card__header">
+      <view class="sign-record-card__header-content">
+        <view class="sign-record-card__name">
+          {{ item.record.signEntName || '未命名拜访地点' }}
+        </view>
+        <view class="sign-record-card__time">{{ item.record.signDate || '-' }} 打卡</view>
+      </view>
+    </view>
+
+    <view v-if="item.record.address" class="sign-record-card__location">
+      <view class="sign-record-card__address">{{ item.record.address }}</view>
+      <view v-if="item.hasCoordinates" class="sign-record-card__coordinate">
+        任务定位:{{ item.record.longitude }}, {{ item.record.latitude }}
+      </view>
+    </view>
+
+    <SignVisitDetails v-if="item.hasDetail" :item="item" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import SignVisitDetails from './SignVisitDetails.vue'
+import type { SignRecordDisplay } from './types'
+
+defineProps<{
+  item: SignRecordDisplay
+}>()
+</script>
+
+<style lang="scss" scoped>
+.sign-record-card {
+  background: #fff;
+  border-bottom: 12rpx solid #f3f6fa;
+}
+
+.sign-record-card:last-child {
+  border-bottom: 0;
+}
+
+.sign-record-card__header {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 28rpx 14rpx;
+}
+
+.sign-record-card__header-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.sign-record-card__name {
+  color: #1e293b;
+  font-size: 28rpx;
+  font-weight: 650;
+  line-height: 40rpx;
+  word-break: break-all;
+}
+
+.sign-record-card__time {
+  margin-top: 5rpx;
+  color: #8997a8;
+  font-size: 22rpx;
+  line-height: 32rpx;
+}
+
+.sign-record-card__location {
+  margin: 0 28rpx 22rpx;
+}
+
+.sign-record-card__address {
+  color: #53647a;
+  font-size: 25rpx;
+  line-height: 38rpx;
+  word-break: break-all;
+}
+
+.sign-record-card__coordinate {
+  margin-top: 6rpx;
+  color: #8b99aa;
+  font-size: 22rpx;
+  line-height: 32rpx;
+}
+</style>

+ 36 - 0
src/pages-task/task-detail/components/sign-task-detail/SignTextField.vue

@@ -0,0 +1,36 @@
+<template>
+  <view class="sign-text-field">
+    <text class="sign-text-field__label">{{ label }}</text>
+    <text class="sign-text-field__value">{{ value }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+defineProps<{
+  label: string
+  value: string
+}>()
+</script>
+
+<style lang="scss" scoped>
+.sign-text-field {
+  display: flex;
+  align-items: flex-start;
+  padding: 12rpx 0;
+  font-size: 26rpx;
+  line-height: 40rpx;
+}
+
+.sign-text-field__label {
+  flex: 0 0 150rpx;
+  color: #4f6075;
+  font-weight: 600;
+}
+
+.sign-text-field__value {
+  flex: 1;
+  min-width: 0;
+  color: #25354a;
+  word-break: break-all;
+}
+</style>

+ 51 - 0
src/pages-task/task-detail/components/sign-task-detail/SignVisitDetails.vue

@@ -0,0 +1,51 @@
+<template>
+  <view class="sign-visit-details">
+    <view class="sign-visit-details__heading">拜访明细</view>
+
+    <SignTextField v-if="item.drugText" label="推广药品" :value="item.drugText" />
+    <SignTextField v-if="item.departmentText" label="拜访科室" :value="item.departmentText" />
+    <SignPhotoSection
+      v-if="item.departmentPhotos.length"
+      label="拜访科室"
+      :groups="item.departmentPhotos"
+    />
+    <SignTextField v-if="item.onsiteName" label="现场照片" :value="item.onsiteName" />
+    <SignPhotoSection v-if="item.scenePhotos.length" label="现场照片" :groups="item.scenePhotos" />
+    <SignTextField v-if="item.visitorText" label="拜访人员" :value="item.visitorText" />
+    <SignTextField v-if="item.purposeText" label="拜访目的" :value="item.purposeText" />
+    <SignTextField v-if="item.resultText" label="拜访结果" :value="item.resultText" />
+    <SignEvaluation v-if="item.evaluation" :evaluation="item.evaluation" />
+    <SignPhotoSection
+      v-if="item.registerPhotos.length"
+      label="医院登记记录"
+      :groups="item.registerPhotos"
+    />
+    <SignTextField label="备注" :value="item.remarkText" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import SignEvaluation from './SignEvaluation.vue'
+import SignPhotoSection from './SignPhotoSection.vue'
+import SignTextField from './SignTextField.vue'
+import type { SignRecordDisplay } from './types'
+
+defineProps<{
+  item: SignRecordDisplay
+}>()
+</script>
+
+<style lang="scss" scoped>
+.sign-visit-details {
+  padding: 22rpx 28rpx 26rpx;
+  border-top: 1rpx solid #e8eef5;
+}
+
+.sign-visit-details__heading {
+  margin-bottom: 14rpx;
+  color: #1e293b;
+  font-size: 29rpx;
+  font-weight: 650;
+  line-height: 42rpx;
+}
+</style>

+ 127 - 0
src/pages-task/task-detail/components/sign-task-detail/transform.ts

@@ -0,0 +1,127 @@
+import type { SignTaskType } from '@/services/modules/task/taskDetail/type'
+import type {
+  VisitDepartmentDetail,
+  VisitEvaluationDetail,
+  VisitPhotoGroup,
+  VisitSignDetail,
+  VisitSignRecordItem,
+} from '@/services/modules/task/taskVisit/type'
+
+import { toFullUrl } from '../../utils'
+import type { SignPhotoGroupDisplay, SignRecordDisplay } from './types'
+
+const SCENE_PHOTO_NAME = ''
+const REGISTER_PHOTO_NAME = 'registerPhotos'
+
+export const toSignRecordDisplays = (
+  records: VisitSignRecordItem[],
+  taskType: SignTaskType
+): SignRecordDisplay[] => {
+  return records.map((record) => toSignRecordDisplay(record, taskType))
+}
+
+const toSignRecordDisplay = (
+  record: VisitSignRecordItem,
+  taskType: SignTaskType
+): SignRecordDisplay => {
+  const detail = record.userSignDetail
+  const signEntType = String(record.signEntType)
+
+  return {
+    record,
+    hasDetail: detail !== null,
+    hasCoordinates: Boolean(record.longitude && record.latitude),
+    drugText: joinText(detail?.drug),
+    departmentText: getDepartmentText(detail, signEntType),
+    departmentPhotos: getDepartmentPhotos(detail),
+    onsiteName: getOnsiteName(detail, signEntType),
+    scenePhotos: getScenePhotos(detail, signEntType),
+    visitorText: String(detail?.visitor ?? '').trim(),
+    purposeText: getPurposeText(detail),
+    resultText: String(detail?.resultName ?? detail?.result ?? '').trim(),
+    evaluation: getEvaluation(detail, taskType),
+    registerPhotos: getRegisterPhotos(detail, signEntType),
+    remarkText: String(detail?.remark ?? '').trim() || '-',
+  }
+}
+
+const getDepartmentText = (detail: VisitSignDetail | null, signEntType: string) => {
+  return signEntType === '1' ? String(detail?.department ?? '').trim() : ''
+}
+
+const getDepartmentPhotos = (detail: VisitSignDetail | null): SignPhotoGroupDisplay[] => {
+  if (!detail || detail.templateType === 'TEMPLATE2') return []
+
+  return detail.detail.filter(isDepartmentDetail).map((department) => ({
+    title: department.departmentName || '未填写科室',
+    urls: toImageUrls(department.fileUrl),
+  }))
+}
+
+const getOnsiteName = (detail: VisitSignDetail | null, signEntType: string) => {
+  if (signEntType !== '2' && signEntType !== '3') return ''
+  return String(detail?.name ?? '').trim()
+}
+
+const getScenePhotos = (
+  detail: VisitSignDetail | null,
+  signEntType: string
+): SignPhotoGroupDisplay[] => {
+  if (!detail) return []
+
+  const isSupportedTemplate =
+    (signEntType === '2' && detail.templateType === 'TEMPLATE2') ||
+    (signEntType === '3' && detail.templateType === 'TEMPLATE3')
+
+  return isSupportedTemplate ? getPhotoGroups(detail.scenePhotoJson, SCENE_PHOTO_NAME) : []
+}
+
+const getPurposeText = (detail: VisitSignDetail | null): string => {
+  if (!detail) return ''
+  if (detail.purposeName?.length) return joinText(detail.purposeName)
+  if (detail.safePurposeName) return detail.safePurposeName
+  return joinText(detail.purpose)
+}
+
+const getEvaluation = (
+  detail: VisitSignDetail | null,
+  taskType: SignTaskType
+): VisitEvaluationDetail | null => {
+  if (detail?.templateType !== 'TEMPLATE2' || taskType === '33') return null
+  return detail.detail.find(isEvaluationDetail) ?? null
+}
+
+const getRegisterPhotos = (
+  detail: VisitSignDetail | null,
+  signEntType: string
+): SignPhotoGroupDisplay[] => {
+  return signEntType === '1' ? getPhotoGroups(detail?.scenePhotoJson, REGISTER_PHOTO_NAME) : []
+}
+
+const getPhotoGroups = (
+  groups: VisitPhotoGroup[] | undefined,
+  name: string
+): SignPhotoGroupDisplay[] => {
+  return (groups ?? [])
+    .filter((group) => group.name === name)
+    .map((group) => ({ urls: toImageUrls(group.fileUrl) }))
+    .filter((group) => group.urls.length > 0)
+}
+
+const toImageUrls = (urls: string[]) => urls.map(toFullUrl).filter(Boolean)
+
+const joinText = (values: string[] | undefined): string => {
+  return (values ?? []).filter(Boolean).join(',')
+}
+
+const isDepartmentDetail = (
+  detail: VisitDepartmentDetail | VisitEvaluationDetail
+): detail is VisitDepartmentDetail => {
+  return 'departmentName' in detail
+}
+
+const isEvaluationDetail = (
+  detail: VisitDepartmentDetail | VisitEvaluationDetail
+): detail is VisitEvaluationDetail => {
+  return 'rightHcp' in detail
+}

+ 26 - 0
src/pages-task/task-detail/components/sign-task-detail/types.ts

@@ -0,0 +1,26 @@
+import type {
+  VisitEvaluationDetail,
+  VisitSignRecordItem,
+} from '@/services/modules/task/taskVisit/type'
+
+export interface SignPhotoGroupDisplay {
+  title?: string
+  urls: string[]
+}
+
+export interface SignRecordDisplay {
+  record: VisitSignRecordItem
+  hasDetail: boolean
+  hasCoordinates: boolean
+  drugText: string
+  departmentText: string
+  departmentPhotos: SignPhotoGroupDisplay[]
+  onsiteName: string
+  scenePhotos: SignPhotoGroupDisplay[]
+  visitorText: string
+  purposeText: string
+  resultText: string
+  evaluation: VisitEvaluationDetail | null
+  registerPhotos: SignPhotoGroupDisplay[]
+  remarkText: string
+}

+ 193 - 0
src/pages-task/task-detail/index.vue

@@ -0,0 +1,193 @@
+<template>
+  <view class="task-detail-page">
+    <view v-if="loading" class="task-detail-state">
+      <wd-loading color="#3b9bed" />
+      <text class="task-detail-state__text">正在加载任务详情</text>
+    </view>
+
+    <view v-else-if="errorMessage" class="task-detail-state">
+      <view class="task-detail-state__icon task-detail-state__icon--error">!</view>
+      <text class="task-detail-state__title">详情加载失败</text>
+      <text class="task-detail-state__text">{{ errorMessage }}</text>
+      <button class="task-detail-state__retry" @click="loadTaskDetail">重新加载</button>
+    </view>
+
+    <template v-else-if="taskDetail">
+      <SignTaskDetail v-if="signTaskDetail" :task-detail="signTaskDetail" :records="signRecords" />
+      <ShareTaskDetail v-else-if="shareTaskDetail" :task-detail="shareTaskDetail" />
+      <NormalTaskDetail v-else-if="normalTaskDetail" :task-detail="normalTaskDetail" />
+    </template>
+
+    <view v-else class="task-detail-state">
+      <view class="task-detail-state__icon">⌕</view>
+      <text class="task-detail-state__title">暂无任务详情</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import { getTaskInfoByIdApi } from '@/services/modules/task/taskDetail'
+import type {
+  ShareTaskDetailResponse,
+  SignTaskDetailResponse,
+  TaskDetailResponse,
+  TaskFormDetailResponse,
+} from '@/services/modules/task/taskDetail/type'
+import { getSignInfoByTaskIdApi } from '@/services/modules/task/taskVisit'
+import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
+
+import NormalTaskDetail from './components/NormalTaskDetail.vue'
+import ShareTaskDetail from './components/ShareTaskDetail.vue'
+import SignTaskDetail from './components/SignTaskDetail.vue'
+
+interface PageLoadOptions {
+  id?: string
+  taskId?: string
+}
+
+const SHARE_TASK_TYPES = new Set(['8', '9', '10', '11'])
+const SIGN_TASK_TYPES = new Set(['5', '6', '33'])
+
+const taskId = ref('')
+const loading = ref(false)
+const errorMessage = ref('')
+const taskDetail = ref<TaskDetailResponse | null>(null)
+const signRecords = ref<VisitSignRecordItem[]>([])
+
+const isShareTaskDetail = (detail: TaskDetailResponse): detail is ShareTaskDetailResponse => {
+  return SHARE_TASK_TYPES.has(String(detail.taskType))
+}
+
+const isSignTaskDetail = (detail: TaskDetailResponse): detail is SignTaskDetailResponse => {
+  return SIGN_TASK_TYPES.has(String(detail.taskType))
+}
+
+const signTaskDetail = computed<SignTaskDetailResponse | null>(() => {
+  const detail = taskDetail.value
+  return detail && isSignTaskDetail(detail) ? detail : null
+})
+
+const shareTaskDetail = computed<ShareTaskDetailResponse | null>(() => {
+  const detail = taskDetail.value
+  return detail && isShareTaskDetail(detail) ? detail : null
+})
+
+const normalTaskDetail = computed<TaskFormDetailResponse | null>(() => {
+  const detail = taskDetail.value
+  if (!detail || isShareTaskDetail(detail) || isSignTaskDetail(detail)) return null
+  return detail
+})
+
+const loadTaskDetail = async () => {
+  if (!taskId.value || loading.value) return
+
+  loading.value = true
+  errorMessage.value = ''
+  signRecords.value = []
+
+  try {
+    const res = await getTaskInfoByIdApi(taskId.value)
+    const detail = res.data ?? null
+    taskDetail.value = detail
+
+    if (!detail) {
+      errorMessage.value = '接口未返回任务详情数据'
+      return
+    }
+
+    if (isSignTaskDetail(detail)) {
+      const signRes = await getSignInfoByTaskIdApi({ id: taskId.value })
+      signRecords.value = Array.isArray(signRes.data) ? signRes.data : []
+    }
+  } catch (error) {
+    console.error('[task-detail] 任务详情加载失败', error)
+    taskDetail.value = null
+    signRecords.value = []
+    errorMessage.value = '请检查网络后重试'
+  } finally {
+    loading.value = false
+  }
+}
+
+onLoad((options: PageLoadOptions = {}) => {
+  taskId.value = String(options.taskId ?? options.id ?? '').trim()
+
+  if (!taskId.value) {
+    errorMessage.value = '缺少 taskId'
+    uni.showToast({ title: errorMessage.value, icon: 'none' })
+    return
+  }
+
+  void loadTaskDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+.task-detail-page {
+  min-height: 100vh;
+  padding: 24rpx 24rpx 48rpx;
+  background: linear-gradient(180deg, #edf6ff 0%, #f5f7fa 360rpx, #f5f7fa 100%);
+  box-sizing: border-box;
+}
+
+.task-detail-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 620rpx;
+  padding: 40rpx;
+  color: #8491a2;
+  text-align: center;
+}
+
+.task-detail-state__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 92rpx;
+  height: 92rpx;
+  color: #3b9bed;
+  font-size: 48rpx;
+  background: #eaf5ff;
+  border-radius: 50%;
+}
+
+.task-detail-state__icon--error {
+  color: #d97706;
+  background: #fff5df;
+}
+
+.task-detail-state__title {
+  margin-top: 24rpx;
+  color: #334155;
+  font-size: 30rpx;
+  font-weight: 650;
+}
+
+.task-detail-state__text {
+  margin-top: 16rpx;
+  font-size: 24rpx;
+  line-height: 36rpx;
+}
+
+.task-detail-state__retry {
+  width: 220rpx;
+  height: 68rpx;
+  margin: 28rpx 0 0;
+  color: #fff;
+  font-size: 25rpx;
+  line-height: 68rpx;
+  background: #3b9bed;
+  border: 0;
+  border-radius: 34rpx;
+}
+
+.task-detail-state__retry::after {
+  border: 0;
+}
+</style>

+ 30 - 0
src/pages-task/task-detail/utils.ts

@@ -0,0 +1,30 @@
+const EMPTY_TEXT = '-'
+const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
+
+export const normalizeText = (value: unknown, fallback = EMPTY_TEXT): string => {
+  if (value === undefined || value === null || value === '') return fallback
+  return String(value)
+}
+
+export const toFullUrl = (url: string): string => {
+  const cleanUrl = url.trim().replace(/;[1-4](?=$|[?#])/i, '')
+
+  if (!cleanUrl) return ''
+
+  if (/^(https?:|wxfile:|blob:|data:)/i.test(cleanUrl)) {
+    return cleanUrl
+  }
+
+  return `${baseUrl}${cleanUrl.startsWith('/') ? cleanUrl : `/${cleanUrl}`}`
+}
+
+export const previewImages = (urls: string[], index = 0) => {
+  if (!urls.length) return
+
+  uni.previewImage({
+    urls,
+    current: urls[index],
+  })
+}
+
+export const getAppBaseUrl = () => baseUrl

+ 7 - 0
src/pages.json

@@ -267,6 +267,13 @@
             "navigationBarTitleText": "任务完成记录",
             "navigationStyle": "default"
           }
+        },
+        {
+          "path": "task-detail/index",
+          "style": {
+            "navigationBarTitleText": "任务详情",
+            "navigationStyle": "default"
+          }
         }
       ]
     },

+ 14 - 0
src/services/modules/task/taskDetail/index.ts

@@ -0,0 +1,14 @@
+import http from '../../../index'
+import type { QrCodeResult, TaskDetailResponse } from './type'
+
+export const getTaskInfoByIdApi = (taskId: string) => {
+  return http.get<TaskDetailResponse>('/admin/api/getTaskInfoById', { taskId })
+}
+
+export const getQrCodeByUrlApi = (url: string) => {
+  return http.get<QrCodeResult>('/admin/api/getQrCodeByUrl', {
+    url,
+    width: 170,
+    height: 170,
+  })
+}

+ 153 - 0
src/services/modules/task/taskDetail/type.d.ts

@@ -0,0 +1,153 @@
+export interface TaskDetailContentItem {
+  label: string | null
+  type: string
+  value: string
+}
+
+export interface TaskDetailContentField {
+  seq: number
+  required: boolean
+  items: TaskDetailContentItem[]
+}
+
+export type TaskDetailContent = Record<string, TaskDetailContentField>
+
+export interface TaskDetailInfo {
+  actualStatus: string | null
+  actualTime: string | null
+  address: string | null
+  approvalInfo: string | null
+  approvalOpinion: string | null
+  approvalTime: string | null
+  approvalUserId: string | null
+  approvalUserName: string | null
+  area: string | null
+  auditStatusTag: string
+  city: string | null
+  compareResult: string | null
+  contentAliasMap: Record<string, string> | null
+  createTime: string
+  createUser: string | null
+  delFlag: string
+  deptApprovalTime: string | null
+  deptApprovalUserId: string | null
+  deptApprovalUserName: string | null
+  deptId: string
+  deptReviewTaskCount: number
+  drugEntId: string | null
+  enableFlag: string
+  extIds: string | null
+  id: string
+  ip2region: string
+  lookintoDate: string
+  lookintoTypeId: string | null
+  notifyDate: string | null
+  packageStatus: string | null
+  pkgSn: string | null
+  platAuditStatus: string
+  province: string | null
+  realFlag: string
+  relationPkgSn: string | null
+  relationScorePackageName: string | null
+  remoteIp: string
+  reportDrugApprovalInfo: string | null
+  reportDrugApprovalOpinion: string | null
+  reportDrugApprovalStatus: string
+  reportDrugId: string | null
+  reportOneApprovalInfo: string | null
+  reportOneApprovalOpinion: string | null
+  reportOneApprovalStatus: string
+  reportOneId: string | null
+  reportSecondApprovalInfo: string | null
+  reportSecondApprovalOpinion: string | null
+  reportSecondApprovalStatus: string
+  reportSecondId: string | null
+  reviewShow: string | null
+  reviewTaskCount: number
+  score: number
+  scorePackageDrugId: string | null
+  scorePackageId: string
+  scorePackageLevel1Id: string | null
+  scorePackageName: string | null
+  settleStatus: string | null
+  shareImgUrl: string | null
+  subTime: string | null
+  subToGigTime: string | null
+  submitStatus: string
+  taskContentId: string
+  taskFrom: string
+  taskInfoImg: string | null
+  taskNumber: string
+  taskRuleId: string | null
+  taskSettleStatus: string | null
+  taskStatus: string
+  taskStatusInfo: string | null
+  taskTypeId: string
+  taskTypeName: string | null
+  taskTypeParentId: string | null
+  taskUserId: string
+  taskUserType: string
+  taskUsername: string | null
+  tenantId: number
+  tmpId: string | null
+  type: string | null
+  updateTime: string
+  updateUser: string | null
+  userList: unknown[] | null
+  username: string | null
+  version: number
+  wmTaskContent: TaskDetailContent | null
+  wmTaskStatusFlowList: unknown[] | null
+}
+
+export interface TaskDetailBaseResponse {
+  taskInfo: TaskDetailInfo
+  taskType: string
+}
+
+/** 普通动态表单任务详情。 */
+export interface TaskFormDetailResponse extends TaskDetailBaseResponse {
+  packageName: string | null
+  wmTaskContent: TaskDetailContent
+}
+
+export type ShareTaskType = '8' | '9' | '10' | '11'
+
+/** 分享任务详情,内容由分享字段组成,不返回动态表单内容。 */
+export interface ShareTaskDetailResponse extends TaskDetailBaseResponse {
+  remark: string | null
+  score: number
+  shareId: string
+  taskType: ShareTaskType
+  title: string | null
+  wmTaskContent: null
+}
+
+export type SignTaskType = '5' | '6' | '33'
+
+/** 签到/拜访任务的内容通过 getSignInfoByTaskId 单独获取。 */
+export interface SignTaskDetailResponse extends TaskDetailBaseResponse {
+  packageName?: string | null
+  taskType: SignTaskType
+  wmTaskContent: TaskDetailContent | null
+}
+
+export type TaskDetailResponse =
+  TaskFormDetailResponse | ShareTaskDetailResponse | SignTaskDetailResponse
+
+export interface QrCodeFileInfo {
+  bucketName: string
+  fileId: number | string
+  fileName: string
+  url: string
+}
+
+/**
+ * 二维码接口的 data 本身仍是一个业务响应对象,页面需再读取一层 data。
+ */
+export interface QrCodeResult {
+  code: number
+  data: QrCodeFileInfo | null
+  msg: string | null
+  success: boolean
+}

+ 5 - 0
src/services/modules/task/taskVisit/index.ts

@@ -1,6 +1,7 @@
 import http from '../../../index'
 import type {
   GetPointSignInfoRequest,
+  GetSignInfoByTaskIdRequest,
   GetSignListByUserIdRequest,
   GetTemplateRequest,
   GetTemplateResult,
@@ -18,6 +19,10 @@ export const getSignListByUserIdApi = (params: GetSignListByUserIdRequest) => {
   return http.get<VisitSignRecordItem[]>('/admin/api/getSignListByUserId', params)
 }
 
+export const getSignInfoByTaskIdApi = (params: GetSignInfoByTaskIdRequest) => {
+  return http.get<VisitSignRecordItem[]>('/admin/api/getSignInfoByTaskId', params)
+}
+
 export const getVisitTimeApi = (params: GetVisitTimeRequest) => {
   return http.get<VisitTimeItem[]>('/admin/api/getVisitTime', params)
 }

+ 14 - 0
src/services/modules/task/taskVisit/type.d.ts

@@ -2,6 +2,10 @@ export interface GetSignListByUserIdRequest {
   signUserid: string
 }
 
+export interface GetSignInfoByTaskIdRequest {
+  id: string | number
+}
+
 export type VisitToType = 'HOSPITAL_VISIT' | 'BUSINESS_COMPANY_VISIT' | 'PHARMACY_VISIT'
 
 export interface GetVisitTimeRequest {
@@ -120,4 +124,14 @@ export interface VisitSignDetail {
   detail: Array<VisitDepartmentDetail | VisitEvaluationDetail>
   scenePhotoJson: VisitPhotoGroup[]
   remark: string
+  /** 以下字段由签到详情接口补充返回。 */
+  id?: number
+  signId?: number
+  department?: string | null
+  name?: string | null
+  purposeName?: string[]
+  resultName?: string | null
+  safeDepartment?: string[]
+  safePurpose?: string[]
+  safePurposeName?: string | null
 }