Selaa lähdekoodia

完成客户拜访业务逻辑

yuanmingze 1 kuukausi sitten
vanhempi
commit
b7553f9a8c
24 muutettua tiedostoa jossa 1517 lisäystä ja 495 poistoa
  1. 2 2
      .env.development
  2. 5 4
      src/pages-task/task-form/components/ImgUpload.vue
  3. 21 458
      src/pages-task/task-visit/components/visit-status/index.vue
  4. 71 0
      src/pages-task/task-visit/components/visit-template/TemplateContent.vue
  5. 354 0
      src/pages-task/task-visit/components/visit-template/index.vue
  6. 134 0
      src/pages-task/task-visit/components/visit-template/shared/CommonVisitFields.vue
  7. 108 0
      src/pages-task/task-visit/components/visit-template/shared/DepartmentFields.vue
  8. 66 0
      src/pages-task/task-visit/components/visit-template/shared/EvaluationFields.vue
  9. 22 0
      src/pages-task/task-visit/components/visit-template/shared/HospitalRegisterField.vue
  10. 28 0
      src/pages-task/task-visit/components/visit-template/shared/ScenePhotoField.vue
  11. 32 0
      src/pages-task/task-visit/components/visit-template/shared/VisitRemark.vue
  12. 34 0
      src/pages-task/task-visit/components/visit-template/templates/CompanyTemplate1.vue
  13. 44 0
      src/pages-task/task-visit/components/visit-template/templates/CompanyTemplate2.vue
  14. 23 0
      src/pages-task/task-visit/components/visit-template/templates/HospitalDefault.vue
  15. 48 0
      src/pages-task/task-visit/components/visit-template/templates/HospitalTemplate1.vue
  16. 48 0
      src/pages-task/task-visit/components/visit-template/templates/HospitalTemplate2.vue
  17. 35 0
      src/pages-task/task-visit/components/visit-template/templates/PharmacyTemplate1.vue
  18. 36 0
      src/pages-task/task-visit/components/visit-template/templates/PharmacyTemplate2.vue
  19. 46 0
      src/pages-task/task-visit/components/visit-template/templates/PharmacyTemplate3.vue
  20. 50 0
      src/pages-task/task-visit/components/visit-template/types.ts
  21. 107 0
      src/pages-task/task-visit/components/visit-template/utils.ts
  22. 153 22
      src/pages/mine/index.vue
  23. 4 4
      src/services/modules/task/taskVisit/index.ts
  24. 46 5
      src/services/modules/task/taskVisit/type.d.ts

+ 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

+ 5 - 4
src/pages-task/task-form/components/ImgUpload.vue

@@ -35,7 +35,7 @@
       </view>
     </FormField>
 
-    <canvas canvas-id="imgUploadWatermarkCanvas" :style="watermarkCanvasStyle" />
+    <canvas :canvas-id="canvasId" :style="watermarkCanvasStyle" />
   </view>
 </template>
 
@@ -69,13 +69,14 @@ interface LocationResult {
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
-
   watermarkText?: string
+  canvasId?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
   watermarkText: '',
+  canvasId: 'imgUploadWatermarkCanvas',
 })
 
 const value = defineModel<FieldValue>('value', {
@@ -314,7 +315,7 @@ const addWatermark = (filePath: string): Promise<string> => {
 
         const componentInstance = instance?.proxy
         const ctx = uni.createCanvasContext(
-          'imgUploadWatermarkCanvas',
+          props.canvasId,
           componentInstance
         ) as UniApp.CanvasContext & {
           measureText: (text: string) => { width: number }
@@ -372,7 +373,7 @@ const addWatermark = (filePath: string): Promise<string> => {
           setTimeout(() => {
             uni.canvasToTempFilePath(
               {
-                canvasId: 'imgUploadWatermarkCanvas',
+                canvasId: props.canvasId,
                 fileType: 'jpg',
                 success: (res) => {
                   resolve(res.tempFilePath)

+ 21 - 458
src/pages-task/task-visit/components/visit-status/index.vue

@@ -78,147 +78,15 @@
     @confirm="confirmPackageSelection"
   />
 
-  <wd-popup
-    v-model="templateVisible"
-    position="bottom"
-    root-portal
-    lock-scroll
-    :close-on-click-modal="false"
-    custom-style="border-radius: 32rpx 32rpx 0 0; overflow: hidden;"
-    @close="resetTemplateForm"
-  >
-    <view class="visit-template">
-      <view class="visit-template__header">
-        <view>
-          <view class="visit-template__title">拜访明细</view>
-          <view class="visit-template__subtitle">{{ templateTarget?.name }}</view>
-        </view>
-        <view class="visit-template__close" @click="templateVisible = false">
-          <wd-icon name="close" size="32rpx" color="#8b98a8" />
-        </view>
-      </view>
-
-      <view v-if="templateLoading" class="visit-template__loading">正在加载拜访模板</view>
-
-      <scroll-view v-else class="visit-template__scroll" scroll-y>
-        <wd-cell title="拜访时间" :value="templateSignDate" />
-
-        <wd-cell
-          v-if="templateTarget?.type === 'pharmacy'"
-          title="拜访人员"
-          required
-        >
-          <wd-input
-            v-model="templateForm.visitor"
-            no-border
-            align-right
-            placeholder="请输入拜访人员"
-          />
-        </wd-cell>
-
-        <wd-cell title="推广药品" required is-link :value="templateDrugText" @click="openDrugPicker" />
-        <wd-cell
-          title="拜访目的"
-          required
-          is-link
-          :value="templatePurposeText"
-          @click="purposePickerVisible = true"
-        />
-        <wd-cell
-          title="拜访结果"
-          required
-          is-link
-          :value="templateResultText"
-          @click="resultPickerVisible = true"
-        />
-
-        <wd-cell v-if="requiresDepartment" title="拜访科室" required>
-          <wd-input
-            v-model="templateForm.department"
-            no-border
-            align-right
-            placeholder="请输入拜访科室"
-          />
-        </wd-cell>
-
-        <ImgUpload
-          v-if="requiresTemplatePhoto"
-          v-model:value="templateForm.photoUrls"
-          :task-field-config="templatePhotoFieldConfig"
-        />
-
-        <template v-if="templateTarget?.type === 'hospital' && templateType === 'TEMPLATE2'">
-          <view
-            v-for="item in templateEvaluationItems"
-            :key="item.key"
-            class="visit-template__field"
-          >
-            <view class="visit-template__field-label">
-              <text class="visit-template__required">*</text>{{ item.label }}
-            </view>
-            <wd-radio-group
-              v-model="templateForm[item.key]"
-              direction="horizontal"
-              type="button"
-            >
-              <wd-radio value="true">是</wd-radio>
-              <wd-radio value="false">否</wd-radio>
-            </wd-radio-group>
-          </view>
-        </template>
-
-        <view class="visit-template__field visit-template__field--vertical">
-          <view class="visit-template__field-label visit-template__field-label--vertical">
-            备注
-          </view>
-          <wd-textarea
-            v-model="templateForm.remark"
-            placeholder="请输入备注"
-            clearable
-            auto-height
-            custom-style="min-height: 180rpx;"
-          />
-        </view>
-      </scroll-view>
-
-      <view class="visit-template__actions">
-        <view
-          class="visit-template__button visit-template__button--cancel"
-          @click="templateVisible = false"
-        >
-          取消
-        </view>
-        <view
-          class="visit-template__button visit-template__button--submit"
-          :class="{ 'visit-template__button--disabled': signSubmitting }"
-          @click="submitTemplateSign"
-        >
-          {{ signSubmitting ? '提交中' : '提交打卡' }}
-        </view>
-      </view>
-    </view>
-  </wd-popup>
-
-  <wd-select-picker
-    v-model="templateForm.drug"
-    v-model:visible="drugPickerVisible"
-    title="选择推广药品"
-    :columns="drugOptions"
-    root-portal
-  />
-  <wd-select-picker
-    v-model="templateForm.purpose"
-    v-model:visible="purposePickerVisible"
-    title="选择拜访目的"
-    :columns="purposeOptions"
-    root-portal
-  />
-  <wd-picker
-    v-model="templateForm.result"
-    v-model:visible="resultPickerVisible"
-    title="选择拜访结果"
-    :columns="resultOptions"
-    root-portal
+  <VisitTemplate
+    v-model:visible="templateVisible"
+    :target="templateTarget"
+    :package-item="templatePackageItem"
+    :dept-id="deptId"
+    :task-type-id="templateTaskTypeId"
+    :sign-date="templateSignDate"
+    :submitting="signSubmitting"
+    @confirm="submitTemplateSign"
   />
 </template>
 
@@ -229,28 +97,24 @@ import dayjs from 'dayjs'
 
 import type { LocationResult } from '@/lib/location'
 
-import { getDictTypeApi } from '@/services/modules/common'
-import type { DictItem } from '@/services/modules/common/type'
 import { getPackageListByTaskTypeApi } from '@/services/modules/information'
 import type { SharePackageItem } from '@/services/modules/information/type'
-import { getListDrugTableApi } from '@/services/modules/task/taskFrom'
-import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 import {
   getSignListByUserIdApi,
-  getVisitTemplateApi,
   isVisitTimeApi,
   saveSignInfoApi,
 } from '@/services/modules/task/taskVisit'
 import type {
   SaveSignInfoRequest,
+  VisitSignDetail,
   VisitSignRecordItem,
   VisitToType,
 } from '@/services/modules/task/taskVisit/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
-import ImgUpload from '../../../task-form/components/ImgUpload.vue'
 import type { VisitLocationStatus, VisitTarget, VisitTargetType } from '../../types'
+import VisitTemplate from '../visit-template/index.vue'
 
 type RequestStatus = 'idle' | 'loading' | 'ready' | 'error'
 
@@ -269,21 +133,6 @@ interface PackagePickerConfirmEvent {
   selectedItems: SharePackageItem[]
 }
 
-interface TemplateFormState {
-  drug: string[]
-  purpose: string[]
-  result: Array<string | number>
-  visitor: string
-  department: string
-  photoUrls: string
-  rightHcp: string
-  rightInfo: string
-  rightActivity: string
-  remark: string
-}
-
-type TemplateEvaluationKey = 'rightHcp' | 'rightInfo' | 'rightActivity'
-
 const SAME_TARGET_COOLDOWN_SECONDS = 4 * 60 * 60
 const GLOBAL_COOLDOWN_SECONDS =
   import.meta.env.VITE_MODE === 'production_luoxin' ? 60 * 60 : 30 * 60
@@ -323,45 +172,10 @@ const packageList = ref<SharePackageItem[]>([])
 const selectedPackageValues = ref<Array<string | number>>([])
 
 const templateVisible = ref(false)
-const templateLoading = ref(false)
-const templateType = ref('')
 const templateTarget = ref<VisitTarget>()
 const templatePackageItem = ref<SharePackageItem>()
+const templateTaskTypeId = ref('')
 const templateSignDate = ref('')
-const drugPickerVisible = ref(false)
-const purposePickerVisible = ref(false)
-const resultPickerVisible = ref(false)
-const drugOptions = ref<Array<{ label: string; value: string }>>([])
-const purposeOptions = ref<Array<{ label: string; value: string }>>([])
-const resultOptions = ref<Array<{ label: string; value: string }>>([])
-
-const createTemplateForm = (): TemplateFormState => ({
-  drug: [],
-  purpose: [],
-  result: [],
-  visitor: '',
-  department: '',
-  photoUrls: '',
-  rightHcp: '',
-  rightInfo: '',
-  rightActivity: '',
-  remark: '',
-})
-
-const templateForm = ref<TemplateFormState>(createTemplateForm())
-
-const templateEvaluationItems: Array<{ key: TemplateEvaluationKey; label: string }> = [
-  { key: 'rightHcp', label: '是否针对了正确的 HCP' },
-  { key: 'rightInfo', label: '是否传递了正确的信息' },
-  { key: 'rightActivity', label: '是否采取了正确的活动形式和频率' },
-]
-
-const templatePhotoFieldConfig = {
-  taskFiledValue: '现场照片',
-  isMustfill: '1',
-  taskFiledMinsize: 1,
-  taskFiledMaxsize: 5,
-} as TaskFieldConfigItem
 
 const deptId = computed(() => userStore.currentUserInfo?.deptId || '')
 const userId = computed(() => userStore.currentUserInfo?.userId || '')
@@ -373,30 +187,6 @@ const selectedPackage = computed(() => {
   return packageList.value.find((item) => String(item.value) === String(selectedValue))
 })
 
-const templateDrugText = computed(() => templateForm.value.drug.join('、') || '请选择')
-const templatePurposeText = computed(() => {
-  const selectedValues = new Set(templateForm.value.purpose.map(String))
-  return purposeOptions.value
-    .filter((item) => selectedValues.has(item.value))
-    .map((item) => item.label)
-    .join('、') || '请选择'
-})
-const templateResultText = computed(() => {
-  const selectedValue = templateForm.value.result[0]
-  return resultOptions.value.find((item) => item.value === String(selectedValue))?.label || '请选择'
-})
-const requiresDepartment = computed(() => {
-  return templateTarget.value?.type === 'hospital' && templateType.value !== 'TEMPLATE2'
-})
-const requiresTemplatePhoto = computed(() => {
-  const targetType = templateTarget.value?.type
-  return (
-    requiresDepartment.value ||
-    (targetType === 'company' && templateType.value === 'TEMPLATE2') ||
-    (targetType === 'pharmacy' && templateType.value === 'TEMPLATE3')
-  )
-})
-
 const cooldownDeadline = computed(() => {
   const targetId = props.target?.signEntId
   let latestSignTime = 0
@@ -613,126 +403,30 @@ const confirmPackageSelection = async (event: PackagePickerConfirmEvent) => {
   const hasTaskTypeTemplate = userStore.currentUserInfo?.hasTaskTypeTemplate || []
 
   if (hasTaskTypeTemplate.includes(taskTypeId)) {
-    await openTemplateForm(target, packageItem, taskTypeId)
+    openTemplateForm(target, packageItem, taskTypeId)
     return
   }
 
   await submitSign(target, packageItem)
 }
 
-const DICT_TYPE_MAP: Record<VisitTargetType, string> = {
-  hospital: 'user_sign_detail_purpose',
-  company: 'user_sign_detail_distribution_purpose',
-  pharmacy: 'user_sign_detail_pharmacy_purpose',
-}
-
-const toDictOptions = (list: DictItem[]) => {
-  return list.map((item) => ({ label: item.label, value: item.value }))
-}
-
-const openTemplateForm = async (
+const openTemplateForm = (
   target: VisitTarget,
   packageItem: SharePackageItem,
   taskTypeId: string
 ) => {
-  const currentDeptId = deptId.value
-  if (!currentDeptId) return
-
   templateTarget.value = target
   templatePackageItem.value = packageItem
+  templateTaskTypeId.value = taskTypeId
   templateSignDate.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
-  templateForm.value = createTemplateForm()
-  templateLoading.value = true
   templateVisible.value = true
-
-  try {
-    const templateRes = await getVisitTemplateApi({ deptId: currentDeptId, taskTypeId })
-    templateType.value = templateRes.data?.template || ''
-
-    const purposeDictType =
-      target.type === 'hospital' && templateType.value === 'TEMPLATE2'
-        ? 'user_sign_detail_template2_purpose'
-        : DICT_TYPE_MAP[target.type]
-
-    const [drugRes, purposeRes, resultRes] = await Promise.all([
-      getListDrugTableApi(String(packageItem.value)),
-      getDictTypeApi(purposeDictType),
-      getDictTypeApi('user_sign_detail_result'),
-    ])
-
-    if (templateTarget.value?.id !== target.id) return
-
-    drugOptions.value = (Array.isArray(drugRes.data) ? drugRes.data : []).map((item) => ({
-      label: String(item),
-      value: String(item),
-    }))
-    purposeOptions.value = toDictOptions(Array.isArray(purposeRes.data) ? purposeRes.data : [])
-    resultOptions.value = toDictOptions(Array.isArray(resultRes.data) ? resultRes.data : [])
-  } catch {
-    templateVisible.value = false
-  } finally {
-    templateLoading.value = false
-  }
 }
 
-const openDrugPicker = () => {
-  if (drugOptions.value.length === 0) {
-    showToast('暂无可选推广药品')
-    return
-  }
-  drugPickerVisible.value = true
-}
-
-const submitTemplateSign = async () => {
+const submitTemplateSign = async (userSignDetail: VisitSignDetail) => {
   const target = templateTarget.value
   const packageItem = templatePackageItem.value
   if (!target || !packageItem) return
 
-  const form = templateForm.value
-  const result = String(form.result[0] ?? '')
-  const needsEvaluation = target.type === 'hospital' && templateType.value === 'TEMPLATE2'
-  const photoUrls = form.photoUrls
-    .split(',')
-    .map((item) => item.trim())
-    .filter(Boolean)
-
-  if (
-    form.drug.length === 0 ||
-    form.purpose.length === 0 ||
-    !result ||
-    (target.type === 'pharmacy' && !form.visitor.trim()) ||
-    (requiresDepartment.value && !form.department.trim()) ||
-    (requiresTemplatePhoto.value && photoUrls.length === 0) ||
-    (needsEvaluation && (!form.rightHcp || !form.rightInfo || !form.rightActivity))
-  ) {
-    showToast('请完善拜访信息')
-    return
-  }
-
-  const userSignDetail: Record<string, unknown> = {
-    templateType: templateType.value,
-    drug: form.drug,
-    purpose: form.purpose,
-    result,
-    remark: form.remark.trim(),
-    visitor: form.visitor.trim(),
-    detail: needsEvaluation
-      ? [
-          {
-            rightHcp: form.rightHcp === 'true',
-            rightInfo: form.rightInfo === 'true',
-            rightActivity: form.rightActivity === 'true',
-          },
-        ]
-      : requiresDepartment.value
-        ? [{ departmentName: form.department.trim(), fileUrl: photoUrls }]
-        : [],
-    scenePhotoJson:
-      requiresTemplatePhoto.value && !requiresDepartment.value
-        ? [{ name: '', fileUrl: photoUrls }]
-        : [],
-  }
-
   const submitted = await submitSign(target, packageItem, userSignDetail, templateSignDate.value)
 
   if (submitted) {
@@ -740,21 +434,10 @@ const submitTemplateSign = async () => {
   }
 }
 
-const resetTemplateForm = () => {
-  templateTarget.value = undefined
-  templatePackageItem.value = undefined
-  templateType.value = ''
-  templateSignDate.value = ''
-  templateForm.value = createTemplateForm()
-  drugOptions.value = []
-  purposeOptions.value = []
-  resultOptions.value = []
-}
-
 const submitSign = async (
   target: VisitTarget,
   packageItem: SharePackageItem,
-  userSignDetail: Record<string, unknown> = {},
+  userSignDetail: VisitSignDetail | Record<string, never> = {},
   signDate = dayjs().format('YYYY-MM-DD HH:mm:ss')
 ) => {
   const location = props.currentLocation
@@ -808,6 +491,10 @@ watch(
   () => {
     packagePickerVisible.value = false
     templateVisible.value = false
+    templateTarget.value = undefined
+    templatePackageItem.value = undefined
+    templateTaskTypeId.value = ''
+    templateSignDate.value = ''
     packageLoading.value = false
     packageList.value = []
     selectedPackageValues.value = []
@@ -1054,128 +741,4 @@ const showToast = (title: string, duration?: number) => {
   font-weight: 650;
   line-height: 34rpx;
 }
-
-.visit-template {
-  display: flex;
-  flex-direction: column;
-  max-height: 88vh;
-  padding-bottom: env(safe-area-inset-bottom);
-  background: #f5f7fa;
-}
-
-.visit-template__header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 28rpx 30rpx;
-  background: #ffffff;
-  border-bottom: 2rpx solid #edf1f5;
-}
-
-.visit-template__title {
-  color: #172033;
-  font-size: 32rpx;
-  font-weight: 700;
-  line-height: 44rpx;
-}
-
-.visit-template__subtitle {
-  max-width: 560rpx;
-  margin-top: 4rpx;
-  overflow: hidden;
-  color: #8491a2;
-  font-size: 22rpx;
-  line-height: 32rpx;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.visit-template__close {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 60rpx;
-  height: 60rpx;
-  background: #f4f7fa;
-  border-radius: 50%;
-}
-
-.visit-template__loading {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  height: 420rpx;
-  color: #8491a2;
-  font-size: 26rpx;
-}
-
-.visit-template__scroll {
-  height: 860rpx;
-  padding-top: 18rpx;
-}
-
-.visit-template__field {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  min-height: 104rpx;
-  padding: 20rpx 30rpx;
-  background: #ffffff;
-  border-top: 2rpx solid #f1f3f6;
-  box-sizing: border-box;
-}
-
-.visit-template__field--vertical {
-  align-items: stretch;
-  flex-direction: column;
-}
-
-.visit-template__field-label {
-  margin-right: 24rpx;
-  color: #303a49;
-  font-size: 26rpx;
-  line-height: 38rpx;
-}
-
-.visit-template__field-label--vertical {
-  margin: 0 0 16rpx;
-}
-
-.visit-template__required {
-  margin-right: 6rpx;
-  color: #e65d5d;
-}
-
-.visit-template__actions {
-  display: flex;
-  gap: 18rpx;
-  padding: 20rpx 28rpx;
-  background: #ffffff;
-  box-shadow: 0 -8rpx 24rpx rgba(31, 51, 73, 0.06);
-}
-
-.visit-template__button {
-  flex: 1;
-  height: 82rpx;
-  margin: 0;
-  padding: 0;
-  border-radius: 16rpx;
-  font-size: 28rpx;
-  line-height: 82rpx;
-}
-
-.visit-template__button--cancel {
-  color: #53657a;
-  background: #eef2f6;
-}
-
-.visit-template__button--submit {
-  color: #ffffff;
-  background: linear-gradient(135deg, #50b5ff 0%, #2f8cf7 100%);
-}
-
-.visit-template__button--disabled {
-  opacity: 0.6;
-  pointer-events: none;
-}
 </style>

+ 71 - 0
src/pages-task/task-visit/components/visit-template/TemplateContent.vue

@@ -0,0 +1,71 @@
+<template>
+  <HospitalDefault
+    v-if="routeKey === 'hospital:default'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <HospitalTemplate1
+    v-else-if="routeKey === 'hospital:TEMPLATE1'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <HospitalTemplate2
+    v-else-if="routeKey === 'hospital:TEMPLATE2'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <CompanyTemplate1
+    v-else-if="routeKey === 'company:TEMPLATE1'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <CompanyTemplate2
+    v-else-if="routeKey === 'company:TEMPLATE2'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <PharmacyTemplate1
+    v-else-if="routeKey === 'pharmacy:TEMPLATE1'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <PharmacyTemplate2
+    v-else-if="routeKey === 'pharmacy:TEMPLATE2'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+  <PharmacyTemplate3
+    v-else-if="routeKey === 'pharmacy:TEMPLATE3'"
+    ref="activeTemplateRef"
+    v-bind="formProps"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import CompanyTemplate1 from './templates/CompanyTemplate1.vue'
+import CompanyTemplate2 from './templates/CompanyTemplate2.vue'
+import HospitalDefault from './templates/HospitalDefault.vue'
+import HospitalTemplate1 from './templates/HospitalTemplate1.vue'
+import HospitalTemplate2 from './templates/HospitalTemplate2.vue'
+import PharmacyTemplate1 from './templates/PharmacyTemplate1.vue'
+import PharmacyTemplate2 from './templates/PharmacyTemplate2.vue'
+import PharmacyTemplate3 from './templates/PharmacyTemplate3.vue'
+import type { TemplateFormProps, TemplateRouteKey, VisitTemplateFormExpose } from './types'
+
+const props = defineProps<TemplateFormProps & { routeKey: TemplateRouteKey }>()
+
+const activeTemplateRef = ref<VisitTemplateFormExpose>()
+const formProps = computed<TemplateFormProps>(() => ({
+  drugOptions: props.drugOptions,
+  purposeOptions: props.purposeOptions,
+  resultOptions: props.resultOptions,
+  visitorOptions: props.visitorOptions,
+  scenePhotoMinCount: props.scenePhotoMinCount,
+}))
+
+const submit = () => activeTemplateRef.value?.submit() || null
+
+defineExpose({ submit })
+</script>

+ 354 - 0
src/pages-task/task-visit/components/visit-template/index.vue

@@ -0,0 +1,354 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="bottom"
+    root-portal
+    lock-scroll
+    :close-on-click-modal="false"
+    custom-style="border-radius: 32rpx 32rpx 0 0; overflow: hidden;"
+    @close="resetTemplate"
+  >
+    <view class="visit-template">
+      <view class="visit-template__header">
+        <view class="visit-template__heading">
+          <view class="visit-template__title">拜访明细</view>
+          <view class="visit-template__subtitle">{{ target?.name }}</view>
+        </view>
+        <view class="visit-template__close" @click="visible = false">
+          <wd-icon name="close" size="32rpx" color="#8b98a8" />
+        </view>
+      </view>
+
+      <view v-if="loading" class="visit-template__loading">
+        <wd-loading color="#2f8cf7" />
+        <text class="visit-template__loading-text">正在加载拜访模板</text>
+      </view>
+
+      <scroll-view v-else class="visit-template__scroll" scroll-y>
+        <view class="visit-template__content">
+          <wd-cell :title="targetNameLabel" :value="target?.name" />
+          <wd-cell title="拜访时间" :value="signDate" />
+
+          <TemplateContent
+            v-if="templateRouteKey"
+            ref="templateFormRef"
+            :route-key="templateRouteKey"
+            :drug-options="drugOptions"
+            :purpose-options="purposeOptions"
+            :result-options="resultOptions"
+            :visitor-options="visitorOptions"
+            :scene-photo-min-count="scenePhotoMinCount"
+          />
+          <view v-else class="visit-template__unsupported">暂不支持当前拜访模板</view>
+        </view>
+      </scroll-view>
+
+      <view class="visit-template__actions">
+        <view
+          class="visit-template__button visit-template__button--cancel"
+          @click="visible = false"
+        >
+          取消
+        </view>
+        <view
+          class="visit-template__button visit-template__button--confirm"
+          :class="{ 'visit-template__button--disabled': submitting || loading }"
+          @click="handleConfirm"
+        >
+          {{ submitting ? '确定中' : '确定' }}
+        </view>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import { getDictTypeApi } from '@/services/modules/common'
+import type { DictItem } from '@/services/modules/common/type'
+import type { SharePackageItem } from '@/services/modules/information/type'
+import { getListDrugTableApi } from '@/services/modules/task/taskFrom'
+import { getTemplateApi } from '@/services/modules/task/taskVisit'
+import type { VisitSignDetail, VisitTemplateType } from '@/services/modules/task/taskVisit/type'
+
+import type { VisitTarget, VisitTargetType } from '../../types'
+import TemplateContent from './TemplateContent.vue'
+import type { PickerOption, TemplateRouteKey, VisitTemplateFormExpose } from './types'
+
+const props = withDefaults(
+  defineProps<{
+    target?: VisitTarget
+    packageItem?: SharePackageItem
+    deptId: string
+    taskTypeId: string
+    signDate: string
+    submitting?: boolean
+  }>(),
+  {
+    target: undefined,
+    packageItem: undefined,
+    submitting: false,
+  }
+)
+
+const emit = defineEmits<{
+  (event: 'confirm', detail: VisitSignDetail): void
+}>()
+
+const visible = defineModel<boolean>('visible', { default: false })
+
+const supportedTemplateRoutes: TemplateRouteKey[] = [
+  'hospital:default',
+  'hospital:TEMPLATE1',
+  'hospital:TEMPLATE2',
+  'company:TEMPLATE1',
+  'company:TEMPLATE2',
+  'pharmacy:TEMPLATE1',
+  'pharmacy:TEMPLATE2',
+  'pharmacy:TEMPLATE3',
+]
+
+const purposeDictTypeMap: Record<VisitTargetType, string> = {
+  hospital: 'user_sign_detail_purpose',
+  company: 'user_sign_detail_distribution_purpose',
+  pharmacy: 'user_sign_detail_pharmacy_purpose',
+}
+
+const targetNameMap: Record<VisitTargetType, string> = {
+  hospital: '医院名称',
+  company: '商业公司名称',
+  pharmacy: '药店名称',
+}
+
+const loading = ref(false)
+const templateType = ref<VisitTemplateType>('')
+const scenePhotoMinCount = ref(1)
+const drugOptions = ref<PickerOption[]>([])
+const purposeOptions = ref<PickerOption[]>([])
+const resultOptions = ref<PickerOption[]>([])
+const visitorOptions = ref<PickerOption[]>([])
+const templateFormRef = ref<VisitTemplateFormExpose>()
+
+const targetNameLabel = computed(() => {
+  return props.target ? targetNameMap[props.target.type] : '拜访对象'
+})
+
+const templateRouteKey = computed<TemplateRouteKey | ''>(() => {
+  const targetType = props.target?.type
+  if (!targetType) return ''
+  if (targetType === 'hospital' && !templateType.value) return 'hospital:default'
+
+  const routeKey = `${targetType}:${templateType.value}` as TemplateRouteKey
+  return supportedTemplateRoutes.includes(routeKey) ? routeKey : ''
+})
+
+watch(visible, (isVisible) => {
+  if (isVisible) void loadTemplate()
+})
+
+const loadTemplate = async () => {
+  if (!props.target || !props.packageItem || !props.deptId || !props.taskTypeId) {
+    visible.value = false
+    return
+  }
+
+  resetTemplateState()
+  loading.value = true
+
+  try {
+    const templateRes = await getTemplateApi({
+      deptId: props.deptId,
+      taskTypeId: props.taskTypeId,
+    })
+
+    if (!visible.value) return
+
+    templateType.value = templateRes.data?.template || ''
+    scenePhotoMinCount.value = Math.max(Number(templateRes.data?.detail?.numberLimit) || 1, 1)
+
+    if (templateRouteKey.value === 'hospital:default') return
+
+    const purposeDictType =
+      templateRouteKey.value === 'hospital:TEMPLATE2'
+        ? 'user_sign_detail_template2_purpose'
+        : purposeDictTypeMap[props.target.type]
+
+    const [drugRes, purposeRes, resultRes, visitorRes] = await Promise.all([
+      getListDrugTableApi(String(props.packageItem.value)),
+      getDictTypeApi(purposeDictType),
+      getDictTypeApi('user_sign_detail_result'),
+      needsVisitorOptions(templateRouteKey.value)
+        ? getDictTypeApi('user_sign_detail_pharmacy_person')
+        : Promise.resolve(null),
+    ])
+
+    if (!visible.value) return
+
+    drugOptions.value = (Array.isArray(drugRes.data) ? drugRes.data : []).map((item) => ({
+      label: String(item),
+      value: String(item),
+    }))
+    purposeOptions.value = toDictOptions(purposeRes.data)
+    resultOptions.value = toDictOptions(resultRes.data)
+    visitorOptions.value = toDictOptions(visitorRes?.data)
+  } catch {
+    visible.value = false
+  } finally {
+    loading.value = false
+  }
+}
+
+const needsVisitorOptions = (routeKey: TemplateRouteKey | '') => {
+  return routeKey === 'pharmacy:TEMPLATE2' || routeKey === 'pharmacy:TEMPLATE3'
+}
+
+const toDictOptions = (list: DictItem[] | null | undefined): PickerOption[] => {
+  return (Array.isArray(list) ? list : []).map((item) => ({
+    label: item.label,
+    value: item.value,
+  }))
+}
+
+const handleConfirm = () => {
+  if (props.submitting || loading.value) return
+
+  const detail = templateFormRef.value?.submit()
+  if (detail) emit('confirm', detail)
+}
+
+const resetTemplate = () => {
+  resetTemplateState()
+  loading.value = false
+}
+
+const resetTemplateState = () => {
+  templateType.value = ''
+  scenePhotoMinCount.value = 1
+  drugOptions.value = []
+  purposeOptions.value = []
+  resultOptions.value = []
+  visitorOptions.value = []
+  templateFormRef.value = undefined
+}
+</script>
+
+<style lang="scss" scoped>
+.visit-template {
+  display: flex;
+  flex-direction: column;
+  height: 94vh;
+  max-height: 94vh;
+  background: #f5f7fa;
+}
+
+.visit-template__header {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: space-between;
+  padding: 28rpx 30rpx;
+  background: #ffffff;
+  border-bottom: 2rpx solid #edf1f5;
+}
+
+.visit-template__heading {
+  min-width: 0;
+}
+
+.visit-template__title {
+  color: #172033;
+  font-size: 32rpx;
+  font-weight: 700;
+  line-height: 44rpx;
+}
+
+.visit-template__subtitle {
+  max-width: 560rpx;
+  margin-top: 4rpx;
+  overflow: hidden;
+  color: #8491a2;
+  font-size: 22rpx;
+  line-height: 32rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-template__close {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 60rpx;
+  height: 60rpx;
+  background: #f4f7fa;
+  border-radius: 50%;
+}
+
+.visit-template__loading {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+}
+
+.visit-template__loading-text {
+  margin-left: 16rpx;
+  color: #8491a2;
+  font-size: 26rpx;
+}
+
+.visit-template__scroll {
+  flex: 1;
+  min-height: 0;
+}
+
+.visit-template__content {
+  padding: 18rpx 0 28rpx;
+}
+
+.visit-template__unsupported {
+  padding: 120rpx 30rpx;
+  color: #8491a2;
+  font-size: 26rpx;
+  text-align: center;
+}
+
+.visit-template__actions {
+  display: flex;
+  flex-shrink: 0;
+  gap: 18rpx;
+  padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
+  background: #ffffff;
+  box-shadow: 0 -8rpx 24rpx rgba(31, 51, 73, 0.08);
+  box-sizing: border-box;
+}
+
+.visit-template__button {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  border-radius: 18rpx;
+  font-size: 28rpx;
+  font-weight: 600;
+  box-sizing: border-box;
+}
+
+.visit-template__button--cancel {
+  color: #53657a;
+  background: #eef2f6;
+}
+
+.visit-template__button--confirm {
+  color: #ffffff;
+  background: linear-gradient(135deg, #50b5ff 0%, #2f8cf7 100%);
+  box-shadow: 0 10rpx 22rpx rgba(47, 140, 247, 0.22);
+}
+
+.visit-template__button--disabled {
+  opacity: 0.55;
+  pointer-events: none;
+}
+</style>

+ 134 - 0
src/pages-task/task-visit/components/visit-template/shared/CommonVisitFields.vue

@@ -0,0 +1,134 @@
+<template>
+  <wd-cell
+    v-if="visitorMode === 'picker'"
+    title="拜访人员"
+    required
+    is-link
+    :value="form.visitor"
+    placeholder="请选择"
+    @click="openVisitorPicker"
+  />
+
+  <wd-cell v-else-if="visitorMode === 'input'" title="拜访人员" required>
+    <wd-input
+      v-model="form.visitor"
+      compact
+      align-right
+      placeholder="请输入拜访人员"
+      custom-style="width: 100%; padding: 0; background: transparent;"
+    />
+  </wd-cell>
+
+  <wd-cell title="推广药品" required is-link :value="drugText" @click="openDrugPicker" />
+  <wd-cell title="拜访目的" required is-link :value="purposeText" @click="openPurposePicker" />
+  <wd-cell title="拜访结果" required is-link :value="resultText" @click="openResultPicker" />
+
+  <wd-select-picker
+    v-model="form.drug"
+    v-model:visible="drugPickerVisible"
+    title="选择推广药品"
+    :columns="drugOptions"
+    root-portal
+  />
+  <wd-select-picker
+    v-model="form.purpose"
+    v-model:visible="purposePickerVisible"
+    title="选择拜访目的"
+    :columns="purposeOptions"
+    root-portal
+  />
+  <wd-picker
+    v-model="form.result"
+    v-model:visible="resultPickerVisible"
+    title="选择拜访结果"
+    :columns="resultOptions"
+    root-portal
+  />
+  <wd-picker
+    v-if="visitorMode === 'picker'"
+    v-model="visitorPickerValue"
+    v-model:visible="visitorPickerVisible"
+    title="选择拜访人员"
+    :columns="visitorOptions"
+    root-portal
+    @confirm="confirmVisitor"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import type { CommonVisitForm, PickerOption, VisitorMode } from '../types'
+
+interface PickerConfirmEvent {
+  selectedItems: PickerOption[]
+}
+
+const props = withDefaults(
+  defineProps<{
+    drugOptions: PickerOption[]
+    purposeOptions: PickerOption[]
+    resultOptions: PickerOption[]
+    visitorOptions?: PickerOption[]
+    visitorMode?: VisitorMode
+  }>(),
+  {
+    visitorOptions: () => [],
+    visitorMode: 'none',
+  }
+)
+
+const form = defineModel<CommonVisitForm>({ required: true })
+
+const drugPickerVisible = ref(false)
+const purposePickerVisible = ref(false)
+const resultPickerVisible = ref(false)
+const visitorPickerVisible = ref(false)
+const visitorPickerValue = ref<Array<string | number>>([])
+
+const drugText = computed(() => form.value.drug.join('、') || '请选择')
+const purposeText = computed(() => {
+  const selected = new Set(form.value.purpose.map(String))
+  return (
+    props.purposeOptions
+      .filter((item) => selected.has(item.value))
+      .map((item) => item.label)
+      .join('、') || '请选择'
+  )
+})
+const resultText = computed(() => {
+  const selectedValue = String(form.value.result[0] ?? '')
+  return props.resultOptions.find((item) => item.value === selectedValue)?.label || '请选择'
+})
+
+const openDrugPicker = () => {
+  if (showEmptyOptions(props.drugOptions, '暂无可选推广药品')) return
+  drugPickerVisible.value = true
+}
+
+const openPurposePicker = () => {
+  if (showEmptyOptions(props.purposeOptions, '暂无可选拜访目的')) return
+  purposePickerVisible.value = true
+}
+
+const openResultPicker = () => {
+  if (showEmptyOptions(props.resultOptions, '暂无可选拜访结果')) return
+  resultPickerVisible.value = true
+}
+
+const openVisitorPicker = () => {
+  if (props.visitorMode !== 'picker') return
+  if (showEmptyOptions(props.visitorOptions, '暂无可选拜访人员')) return
+  visitorPickerVisible.value = true
+}
+
+const confirmVisitor = (event: PickerConfirmEvent) => {
+  form.value.visitor = event.selectedItems[0]?.label || ''
+}
+
+const showEmptyOptions = (options: PickerOption[], message: string) => {
+  if (options.length > 0) return false
+  uni.showToast({ title: message, icon: 'none' })
+  return true
+}
+</script>

+ 108 - 0
src/pages-task/task-visit/components/visit-template/shared/DepartmentFields.vue

@@ -0,0 +1,108 @@
+<template>
+  <view v-for="(department, index) in departments" :key="department.id" class="department-field">
+    <view class="department-field__header">
+      <view class="department-field__label">
+        <text class="department-field__required">*</text>
+        拜访科室{{ departments.length > 1 ? index + 1 : '' }}
+      </view>
+      <view
+        class="department-field__action"
+        @click="index === 0 ? addDepartment() : removeDepartment(index)"
+      >
+        {{ index === 0 ? '+' : '−' }}
+      </view>
+    </view>
+
+    <view class="department-field__input">
+      <wd-input v-model="department.departmentName" no-border placeholder="请输入拜访科室" />
+    </view>
+
+    <ImgUpload
+      v-model:value="department.photoUrls"
+      :task-field-config="photoFieldConfig"
+      :canvas-id="`visitDepartmentCanvas${department.id}`"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+import type { DepartmentFormItem } from '../types'
+import { createDepartment, parsePhotoUrls } from '../utils'
+
+import ImgUpload from '@/pages-task/task-form/components/ImgUpload.vue'
+
+const departments = defineModel<DepartmentFormItem[]>({ required: true })
+
+const photoFieldConfig = {
+  taskFiledValue: '科室照片',
+  isMustfill: '1',
+  taskFiledMinsize: 1,
+  taskFiledMaxsize: 1,
+} as TaskFieldConfigItem
+
+const addDepartment = () => {
+  const lastDepartment = departments.value.at(-1)
+
+  if (
+    !lastDepartment?.departmentName.trim() ||
+    parsePhotoUrls(lastDepartment.photoUrls).length === 0
+  ) {
+    uni.showToast({ title: '请先完善当前拜访科室', icon: 'none' })
+    return
+  }
+
+  departments.value.push(createDepartment())
+}
+
+const removeDepartment = (index: number) => {
+  if (index > 0) departments.value.splice(index, 1)
+}
+</script>
+
+<style lang="scss" scoped>
+.department-field {
+  padding: 22rpx 30rpx 8rpx;
+  background: #ffffff;
+  border-top: 2rpx solid #f1f3f6;
+}
+
+.department-field__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.department-field__label {
+  color: #303a49;
+  font-size: 26rpx;
+  line-height: 38rpx;
+}
+
+.department-field__required {
+  margin-right: 6rpx;
+  color: #e65d5d;
+}
+
+.department-field__action {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 54rpx;
+  height: 54rpx;
+  color: #4a91ea;
+  font-size: 38rpx;
+  line-height: 54rpx;
+  background: #eef6ff;
+  border-radius: 50%;
+}
+
+.department-field__input {
+  margin-top: 16rpx;
+  padding: 0 20rpx;
+  background: #f7f9fc;
+  border: 2rpx solid #e8edf3;
+  border-radius: 14rpx;
+}
+</style>

+ 66 - 0
src/pages-task/task-visit/components/visit-template/shared/EvaluationFields.vue

@@ -0,0 +1,66 @@
+<template>
+  <view v-for="item in items" :key="item.key" class="evaluation-field">
+    <view class="evaluation-field__label">
+      <text class="evaluation-field__required">*</text>{{ item.label }}
+    </view>
+    <view class="evaluation-field__options">
+      <wd-radio-group
+        v-model="evaluation[item.key]"
+        direction="horizontal"
+        type="button"
+        custom-style="display: flex; flex-wrap: nowrap; gap: 12rpx; --wot-radio-button-min-width: 88rpx; --wot-radio-button-max-width: 88rpx; --wot-radio-button-margin: 0; --wot-radio-button-padding: 10rpx 16rpx;"
+      >
+        <wd-radio value="true">是</wd-radio>
+        <wd-radio value="false">否</wd-radio>
+      </wd-radio-group>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import type { EvaluationForm } from '../types'
+
+type EvaluationKey = keyof EvaluationForm
+
+const evaluation = defineModel<EvaluationForm>({ required: true })
+
+const items: Array<{ key: EvaluationKey; label: string }> = [
+  { key: 'rightHcp', label: '是否针对了正确的 HCP' },
+  { key: 'rightInfo', label: '是否传递了正确的信息' },
+  { key: 'rightActivity', label: '是否采取了正确的活动形式和频率' },
+]
+</script>
+
+<style lang="scss" scoped>
+.evaluation-field {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 104rpx;
+  padding: 20rpx 30rpx;
+  background: #ffffff;
+  border-top: 2rpx solid #f1f3f6;
+  box-sizing: border-box;
+}
+
+.evaluation-field__label {
+  flex: 1;
+  min-width: 0;
+  margin-right: 16rpx;
+  color: #303a49;
+  font-size: 26rpx;
+  line-height: 38rpx;
+  white-space: nowrap;
+}
+
+.evaluation-field__required {
+  margin-right: 6rpx;
+  color: #e65d5d;
+}
+
+.evaluation-field__options {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+}
+</style>

+ 22 - 0
src/pages-task/task-visit/components/visit-template/shared/HospitalRegisterField.vue

@@ -0,0 +1,22 @@
+<template>
+  <ImgUpload
+    v-model:value="photoUrls"
+    :task-field-config="fieldConfig"
+    canvas-id="visitRegisterPhotoCanvas"
+  />
+</template>
+
+<script setup lang="ts">
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+import ImgUpload from '@/pages-task/task-form/components/ImgUpload.vue'
+
+const photoUrls = defineModel<string>({ default: '' })
+
+const fieldConfig = {
+  taskFiledValue: '医院登记记录',
+  isMustfill: '0',
+  taskFiledMinsize: 0,
+  taskFiledMaxsize: 5,
+} as TaskFieldConfigItem
+</script>

+ 28 - 0
src/pages-task/task-visit/components/visit-template/shared/ScenePhotoField.vue

@@ -0,0 +1,28 @@
+<template>
+  <ImgUpload
+    v-model:value="photoUrls"
+    :task-field-config="fieldConfig"
+    canvas-id="visitScenePhotoCanvas"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+import ImgUpload from '@/pages-task/task-form/components/ImgUpload.vue'
+
+const props = withDefaults(defineProps<{ minCount?: number }>(), { minCount: 1 })
+const photoUrls = defineModel<string>({ default: '' })
+
+const fieldConfig = computed(
+  () =>
+    ({
+      taskFiledValue: '现场照片',
+      isMustfill: '1',
+      taskFiledMinsize: Math.max(props.minCount, 1),
+      taskFiledMaxsize: 5,
+    }) as TaskFieldConfigItem
+)
+</script>

+ 32 - 0
src/pages-task/task-visit/components/visit-template/shared/VisitRemark.vue

@@ -0,0 +1,32 @@
+<template>
+  <view class="visit-remark">
+    <view class="visit-remark__label">备注</view>
+    <wd-textarea
+      v-model="remark"
+      placeholder="请输入备注"
+      clearable
+      auto-height
+      custom-style="min-height: 200rpx;"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+const remark = defineModel<string>({ default: '' })
+</script>
+
+<style lang="scss" scoped>
+.visit-remark {
+  padding: 24rpx 30rpx;
+  background: #ffffff;
+  border-top: 2rpx solid #f1f3f6;
+  box-sizing: border-box;
+}
+
+.visit-remark__label {
+  margin-bottom: 16rpx;
+  color: #303a49;
+  font-size: 26rpx;
+  line-height: 38rpx;
+}
+</style>

+ 34 - 0
src/pages-task/task-visit/components/visit-template/templates/CompanyTemplate1.vue

@@ -0,0 +1,34 @@
+<template>
+  <CommonVisitFields
+    v-model="form"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+  />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createSignDetail,
+  showValidation,
+  validateCommonFields,
+} from '../utils'
+
+defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+
+const submit = () => {
+  if (showValidation(validateCommonFields(form.value))) return null
+  return createSignDetail('TEMPLATE1', form.value)
+}
+
+defineExpose({ submit })
+</script>

+ 44 - 0
src/pages-task/task-visit/components/visit-template/templates/CompanyTemplate2.vue

@@ -0,0 +1,44 @@
+<template>
+  <ScenePhotoField v-model="scenePhotoUrls" :min-count="scenePhotoMinCount" />
+  <CommonVisitFields
+    v-model="form"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+  />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import ScenePhotoField from '../shared/ScenePhotoField.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createSignDetail,
+  parsePhotoUrls,
+  showValidation,
+  validateCommonFields,
+} from '../utils'
+
+const props = defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+const scenePhotoUrls = ref('')
+
+const submit = () => {
+  const commonMessage = validateCommonFields(form.value)
+  const photoMessage =
+    parsePhotoUrls(scenePhotoUrls.value).length < props.scenePhotoMinCount
+      ? `请至少上传${props.scenePhotoMinCount}张现场照片`
+      : ''
+  if (showValidation(commonMessage || photoMessage)) return null
+
+  return createSignDetail('TEMPLATE2', form.value, { scenePhotoUrls: scenePhotoUrls.value })
+}
+
+defineExpose({ submit })
+</script>

+ 23 - 0
src/pages-task/task-visit/components/visit-template/templates/HospitalDefault.vue

@@ -0,0 +1,23 @@
+<template>
+  <HospitalRegisterField v-model="registerPhotoUrls" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import HospitalRegisterField from '../shared/HospitalRegisterField.vue'
+import type { TemplateFormProps } from '../types'
+import { createCommonVisitForm, createSignDetail } from '../utils'
+
+defineProps<TemplateFormProps>()
+
+const registerPhotoUrls = ref('')
+
+const submit = () => {
+  return createSignDetail('', createCommonVisitForm(), {
+    registerPhotoUrls: registerPhotoUrls.value,
+  })
+}
+
+defineExpose({ submit })
+</script>

+ 48 - 0
src/pages-task/task-visit/components/visit-template/templates/HospitalTemplate1.vue

@@ -0,0 +1,48 @@
+<template>
+  <DepartmentFields v-model="departments" />
+  <CommonVisitFields
+    v-model="form"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+  />
+  <HospitalRegisterField v-model="registerPhotoUrls" />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import DepartmentFields from '../shared/DepartmentFields.vue'
+import HospitalRegisterField from '../shared/HospitalRegisterField.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createDepartment,
+  createSignDetail,
+  showValidation,
+  toDepartmentDetail,
+  validateCommonFields,
+  validateDepartments,
+} from '../utils'
+
+defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+const departments = ref([createDepartment()])
+const registerPhotoUrls = ref('')
+
+const submit = () => {
+  const message = validateDepartments(departments.value) || validateCommonFields(form.value)
+  if (showValidation(message)) return null
+
+  return createSignDetail('TEMPLATE1', form.value, {
+    detail: toDepartmentDetail(departments.value),
+    registerPhotoUrls: registerPhotoUrls.value,
+  })
+}
+
+defineExpose({ submit })
+</script>

+ 48 - 0
src/pages-task/task-visit/components/visit-template/templates/HospitalTemplate2.vue

@@ -0,0 +1,48 @@
+<template>
+  <CommonVisitFields
+    v-model="form"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+  />
+  <EvaluationFields v-model="evaluation" />
+  <HospitalRegisterField v-model="registerPhotoUrls" />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import EvaluationFields from '../shared/EvaluationFields.vue'
+import HospitalRegisterField from '../shared/HospitalRegisterField.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createEvaluationForm,
+  createSignDetail,
+  showValidation,
+  toEvaluationDetail,
+  validateCommonFields,
+  validateEvaluation,
+} from '../utils'
+
+defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+const evaluation = ref(createEvaluationForm())
+const registerPhotoUrls = ref('')
+
+const submit = () => {
+  const message = validateCommonFields(form.value) || validateEvaluation(evaluation.value)
+  if (showValidation(message)) return null
+
+  return createSignDetail('TEMPLATE2', form.value, {
+    detail: [toEvaluationDetail(evaluation.value)],
+    registerPhotoUrls: registerPhotoUrls.value,
+  })
+}
+
+defineExpose({ submit })
+</script>

+ 35 - 0
src/pages-task/task-visit/components/visit-template/templates/PharmacyTemplate1.vue

@@ -0,0 +1,35 @@
+<template>
+  <CommonVisitFields
+    v-model="form"
+    visitor-mode="input"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+  />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createSignDetail,
+  showValidation,
+  validateCommonFields,
+} from '../utils'
+
+defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+
+const submit = () => {
+  if (showValidation(validateCommonFields(form.value, true))) return null
+  return createSignDetail('TEMPLATE1', form.value)
+}
+
+defineExpose({ submit })
+</script>

+ 36 - 0
src/pages-task/task-visit/components/visit-template/templates/PharmacyTemplate2.vue

@@ -0,0 +1,36 @@
+<template>
+  <CommonVisitFields
+    v-model="form"
+    visitor-mode="picker"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+    :visitor-options="visitorOptions"
+  />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createSignDetail,
+  showValidation,
+  validateCommonFields,
+} from '../utils'
+
+defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+
+const submit = () => {
+  if (showValidation(validateCommonFields(form.value, true))) return null
+  return createSignDetail('TEMPLATE2', form.value)
+}
+
+defineExpose({ submit })
+</script>

+ 46 - 0
src/pages-task/task-visit/components/visit-template/templates/PharmacyTemplate3.vue

@@ -0,0 +1,46 @@
+<template>
+  <ScenePhotoField v-model="scenePhotoUrls" :min-count="scenePhotoMinCount" />
+  <CommonVisitFields
+    v-model="form"
+    visitor-mode="picker"
+    :drug-options="drugOptions"
+    :purpose-options="purposeOptions"
+    :result-options="resultOptions"
+    :visitor-options="visitorOptions"
+  />
+  <VisitRemark v-model="form.remark" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import CommonVisitFields from '../shared/CommonVisitFields.vue'
+import ScenePhotoField from '../shared/ScenePhotoField.vue'
+import VisitRemark from '../shared/VisitRemark.vue'
+import type { TemplateFormProps } from '../types'
+import {
+  createCommonVisitForm,
+  createSignDetail,
+  parsePhotoUrls,
+  showValidation,
+  validateCommonFields,
+} from '../utils'
+
+const props = defineProps<TemplateFormProps>()
+
+const form = ref(createCommonVisitForm())
+const scenePhotoUrls = ref('')
+
+const submit = () => {
+  const commonMessage = validateCommonFields(form.value, true)
+  const photoMessage =
+    parsePhotoUrls(scenePhotoUrls.value).length < props.scenePhotoMinCount
+      ? `请至少上传${props.scenePhotoMinCount}张现场照片`
+      : ''
+  if (showValidation(commonMessage || photoMessage)) return null
+
+  return createSignDetail('TEMPLATE3', form.value, { scenePhotoUrls: scenePhotoUrls.value })
+}
+
+defineExpose({ submit })
+</script>

+ 50 - 0
src/pages-task/task-visit/components/visit-template/types.ts

@@ -0,0 +1,50 @@
+import type { VisitSignDetail } from '@/services/modules/task/taskVisit/type'
+
+export interface PickerOption {
+  label: string
+  value: string
+}
+
+export interface TemplateFormProps {
+  drugOptions: PickerOption[]
+  purposeOptions: PickerOption[]
+  resultOptions: PickerOption[]
+  visitorOptions: PickerOption[]
+  scenePhotoMinCount: number
+}
+
+export interface CommonVisitForm {
+  drug: string[]
+  purpose: string[]
+  result: Array<string | number>
+  visitor: string
+  remark: string
+}
+
+export interface DepartmentFormItem {
+  id: number
+  departmentName: string
+  photoUrls: string
+}
+
+export interface EvaluationForm {
+  rightHcp: string
+  rightInfo: string
+  rightActivity: string
+}
+
+export interface VisitTemplateFormExpose {
+  submit: () => VisitSignDetail | null
+}
+
+export type VisitorMode = 'none' | 'input' | 'picker'
+
+export type TemplateRouteKey =
+  | 'hospital:default'
+  | 'hospital:TEMPLATE1'
+  | 'hospital:TEMPLATE2'
+  | 'company:TEMPLATE1'
+  | 'company:TEMPLATE2'
+  | 'pharmacy:TEMPLATE1'
+  | 'pharmacy:TEMPLATE2'
+  | 'pharmacy:TEMPLATE3'

+ 107 - 0
src/pages-task/task-visit/components/visit-template/utils.ts

@@ -0,0 +1,107 @@
+import type {
+  VisitDepartmentDetail,
+  VisitEvaluationDetail,
+  VisitSignDetail,
+  VisitTemplateType,
+} from '@/services/modules/task/taskVisit/type'
+
+import type { CommonVisitForm, DepartmentFormItem, EvaluationForm } from './types'
+
+let departmentId = 0
+
+export const createCommonVisitForm = (): CommonVisitForm => ({
+  drug: [],
+  purpose: [],
+  result: [],
+  visitor: '',
+  remark: '',
+})
+
+export const createDepartment = (): DepartmentFormItem => ({
+  id: ++departmentId,
+  departmentName: '',
+  photoUrls: '',
+})
+
+export const createEvaluationForm = (): EvaluationForm => ({
+  rightHcp: '',
+  rightInfo: '',
+  rightActivity: '',
+})
+
+export const parsePhotoUrls = (value: string) => {
+  return value
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+}
+
+export const validateCommonFields = (form: CommonVisitForm, visitorRequired = false) => {
+  if (visitorRequired && !form.visitor.trim()) return '请填写拜访人员'
+  if (form.drug.length === 0) return '请选择推广药品'
+  if (form.purpose.length === 0) return '请选择拜访目的'
+  if (form.result.length === 0) return '请选择拜访结果'
+  return ''
+}
+
+export const validateDepartments = (departments: DepartmentFormItem[]) => {
+  for (const department of departments) {
+    const name = department.departmentName.trim()
+    const photos = parsePhotoUrls(department.photoUrls)
+
+    if (!name && photos.length === 0) return '请填写拜访科室'
+    if (!name) return '请填写科室照片对应的拜访科室'
+    if (photos.length === 0) return `请上传“${name}”的科室照片`
+  }
+
+  return ''
+}
+
+export const validateEvaluation = (evaluation: EvaluationForm) => {
+  if (!evaluation.rightHcp || !evaluation.rightInfo || !evaluation.rightActivity) {
+    return '请完成拜访评价'
+  }
+
+  return ''
+}
+
+export const toDepartmentDetail = (departments: DepartmentFormItem[]): VisitDepartmentDetail[] => {
+  return departments.map((department) => ({
+    departmentName: department.departmentName.trim(),
+    fileUrl: parsePhotoUrls(department.photoUrls),
+  }))
+}
+
+export const toEvaluationDetail = (evaluation: EvaluationForm): VisitEvaluationDetail => ({
+  rightHcp: evaluation.rightHcp === 'true',
+  rightInfo: evaluation.rightInfo === 'true',
+  rightActivity: evaluation.rightActivity === 'true',
+})
+
+export const createSignDetail = (
+  templateType: VisitTemplateType,
+  form: CommonVisitForm,
+  options: {
+    detail?: Array<VisitDepartmentDetail | VisitEvaluationDetail>
+    scenePhotoUrls?: string
+    registerPhotoUrls?: string
+  } = {}
+): VisitSignDetail => ({
+  templateType,
+  drug: [...form.drug],
+  purpose: [...form.purpose],
+  result: String(form.result[0] ?? ''),
+  visitor: form.visitor.trim(),
+  detail: options.detail || [],
+  scenePhotoJson: [
+    { name: '', fileUrl: parsePhotoUrls(options.scenePhotoUrls || '') },
+    { name: 'registerPhotos', fileUrl: parsePhotoUrls(options.registerPhotoUrls || '') },
+  ],
+  remark: form.remark.trim(),
+})
+
+export const showValidation = (message: string) => {
+  if (!message) return false
+  uni.showToast({ title: message, icon: 'none' })
+  return true
+}

+ 153 - 22
src/pages/mine/index.vue

@@ -159,14 +159,40 @@
     </view>
 
     <!-- 切换企业 -->
-    <wd-action-sheet
-      title="选择企业"
+    <wd-popup
       v-model="handleCompanyShow"
-      :actions="companyList"
+      position="bottom"
+      round
+      safe-area-inset-bottom
+      modal-style="background: rgba(15, 23, 42, 0.46);"
+      custom-style="max-height: 78vh; overflow: hidden; background: #f5f7fa; border-radius: 36rpx 36rpx 0 0;"
       @close="handleCompanyShow = false"
-      @select="handleCompanyConfirm"
-      cancel-text="取消"
-    />
+    >
+      <view class="company-switch-panel">
+        <view class="company-switch-handle" />
+
+        <view class="company-switch-header">
+          <view class="company-switch-title">切换企业</view>
+          <view class="company-switch-close" @click="handleCompanyShow = false">
+            <wd-icon name="close" size="36rpx" />
+          </view>
+        </view>
+
+        <scroll-view class="company-switch-list" scroll-y :style="{ height: companyListHeight }">
+          <view
+            v-for="(company, index) in companyList"
+            :key="company.deptId || index"
+            class="company-switch-item"
+            :class="{ 'is-current': company.isCurrent }"
+            hover-class="company-switch-item--hover"
+            :hover-stay-time="80"
+            @click="handleCompanyConfirm(index)"
+          >
+            <text class="company-switch-name">{{ company.name }}</text>
+          </view>
+        </scroll-view>
+      </view>
+    </wd-popup>
   </view>
 </template>
 
@@ -200,9 +226,22 @@ const showOSAIS = computed(() => {
 })
 
 const hasUnreadNotification = ref(false)
-const companyList = ref<{ name: string; color: string }[]>([])
 const handleCompanyShow = ref(false)
 
+const companyList = computed(() => {
+  const currentIndex = userStore.currentUserInfoIndex ?? 0
+
+  return userStore.userInfoList.map((info, index) => ({
+    deptId: info.deptId,
+    name: info.deptName || '未命名企业',
+    isCurrent: index === currentIndex,
+  }))
+})
+
+const companyListHeight = computed(() => {
+  return `${Math.min(companyList.value.length * 156, 720)}rpx`
+})
+
 onShow(() => {
   if (!isLoggedIn.value) return
   getInternalMsgCountUnread()
@@ -284,26 +323,23 @@ const getRolesName = (roles: number[]) => {
 const handleCompanySelect = () => {
   if (!checkBeforeNavigate()) return
 
-  const userInfoList = userStore.userInfoList
-  const currentUserInfoIndex = userStore.currentUserInfoIndex || 0
-
-  const arr = userInfoList.map((info) => ({
-    name: info?.deptName,
-    color: '#333333',
-  }))
-
-  if (arr[currentUserInfoIndex]) {
-    arr[currentUserInfoIndex].color = '#3FA3F1'
+  if (!companyList.value.length) {
+    uni.showToast({
+      title: '暂无可切换企业',
+      icon: 'none',
+    })
+    return
   }
 
-  companyList.value = arr
   handleCompanyShow.value = true
 }
 
-const handleCompanyConfirm = (curr: { item: { name: string; color: string }; index: number }) => {
-  const index = curr.index
-  if (index === userStore.currentUserInfoIndex) return
-  userStore.setCurrentUserInfoIndex(index)
+const handleCompanyConfirm = (index: number) => {
+  if (index !== userStore.currentUserInfoIndex) {
+    userStore.setCurrentUserInfoIndex(index)
+  }
+
+  handleCompanyShow.value = false
 }
 
 const navigateToAuth = () => {
@@ -632,5 +668,100 @@ const handleLogoutClick = () => {
       font-size: 32rpx;
     }
   }
+
+  .company-switch-panel {
+    display: flex;
+    flex-direction: column;
+    max-height: 78vh;
+  }
+
+  .company-switch-handle {
+    flex-shrink: 0;
+    width: 68rpx;
+    height: 8rpx;
+    margin: 16rpx auto 2rpx;
+    border-radius: 999rpx;
+    background: #d9dee7;
+  }
+
+  .company-switch-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-shrink: 0;
+    min-height: 112rpx;
+    padding: 18rpx 36rpx 22rpx;
+    box-sizing: border-box;
+  }
+
+  .company-switch-title {
+    color: #182230;
+    font-size: 34rpx;
+    font-weight: 700;
+    line-height: 48rpx;
+  }
+
+  .company-switch-close {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 64rpx;
+    height: 64rpx;
+    margin-right: -8rpx;
+    border-radius: 50%;
+    color: #7f8a9b;
+    background: #edf1f6;
+  }
+
+  .company-switch-close:active {
+    color: #4f5b6c;
+    background: #e3e8ef;
+  }
+
+  .company-switch-list {
+    flex: 1;
+    min-height: 0;
+    max-height: 62vh;
+    padding: 4rpx 32rpx 28rpx;
+    box-sizing: border-box;
+  }
+
+  .company-switch-item {
+    display: flex;
+    align-items: center;
+    min-height: 128rpx;
+    margin-bottom: 24rpx;
+    padding: 30rpx 32rpx;
+    border: 2rpx solid #e1e6ed;
+    border-radius: 22rpx;
+    box-sizing: border-box;
+    background: #ffffff;
+  }
+
+  .company-switch-item.is-current {
+    border-color: #69baf2;
+    background: #eaf6ff;
+  }
+
+  .company-switch-item--hover {
+    border-color: #dce3eb;
+    background: #f3f6f9;
+  }
+
+  .company-switch-name {
+    flex: 1;
+    min-width: 0;
+    color: #344054;
+    font-size: 30rpx;
+    font-weight: 500;
+    line-height: 46rpx;
+    white-space: normal;
+    word-break: break-all;
+  }
+
+  .company-switch-item.is-current .company-switch-name {
+    color: #188fdf;
+    font-weight: 700;
+  }
 }
 </style>

+ 4 - 4
src/services/modules/task/taskVisit/index.ts

@@ -2,8 +2,8 @@ import http from '../../../index'
 import type {
   GetPointSignInfoRequest,
   GetSignListByUserIdRequest,
-  GetVisitTemplateRequest,
-  GetVisitTemplateResult,
+  GetTemplateRequest,
+  GetTemplateResult,
   GetVisitTimeRequest,
   IsVisitTimeRequest,
   IsVisitTimeResult,
@@ -30,8 +30,8 @@ export const getPointSignInfoApi = (params: GetPointSignInfoRequest) => {
   return http.get<PointSignInfoItem[]>('/admin/api/getPointSignInfo', params)
 }
 
-export const getVisitTemplateApi = (params: GetVisitTemplateRequest) => {
-  return http.get<GetVisitTemplateResult>('/admin/dept-task-type-template/get-template', params)
+export const getTemplateApi = (params: GetTemplateRequest) => {
+  return http.get<GetTemplateResult>('/admin/dept-task-type-template/get-template', params)
 }
 
 export const saveSignInfoApi = (data: SaveSignInfoRequest) => {

+ 46 - 5
src/services/modules/task/taskVisit/type.d.ts

@@ -16,13 +16,29 @@ export interface IsVisitTimeRequest {
 
 export type IsVisitTimeResult = boolean
 
-export interface GetVisitTemplateRequest {
+export type VisitTemplateType = '' | 'TEMPLATE1' | 'TEMPLATE2' | 'TEMPLATE3'
+
+export interface GetTemplateRequest {
   deptId: string
   taskTypeId: string
 }
 
-export interface GetVisitTemplateResult {
-  template: string
+export interface VisitTemplateConfigDetail {
+  numberLimit: number
+}
+
+export interface GetTemplateResult {
+  id: number
+  deptId: number | string
+  taskTypeId: string
+  taskTypeName: string | null
+  score: number | null
+  template: VisitTemplateType
+  detail: VisitTemplateConfigDetail | null
+  createTime: string
+  createUser: number | string | null
+  updateTime: string | null
+  updateUser: number | string | null
 }
 
 export interface GetPointSignInfoRequest {
@@ -49,7 +65,7 @@ export interface SaveSignInfoRequest {
   signEntName: string
   signEntType: string
   signUserid: string
-  userSignDetail: Record<string, unknown>
+  userSignDetail: VisitSignDetail | Record<string, never>
 }
 
 export interface SaveSignInfoResult {
@@ -79,4 +95,29 @@ export interface VisitSignRecordItem {
   userSignDetail: VisitSignDetail | null
 }
 
-export type VisitSignDetail = Record<string, unknown>
+export interface VisitDepartmentDetail {
+  departmentName: string
+  fileUrl: string[]
+}
+
+export interface VisitEvaluationDetail {
+  rightHcp: boolean
+  rightInfo: boolean
+  rightActivity: boolean
+}
+
+export interface VisitPhotoGroup {
+  name: string
+  fileUrl: string[]
+}
+
+export interface VisitSignDetail {
+  templateType: VisitTemplateType
+  drug: string[]
+  purpose: string[]
+  result: string
+  visitor: string
+  detail: Array<VisitDepartmentDetail | VisitEvaluationDetail>
+  scenePhotoJson: VisitPhotoGroup[]
+  remark: string
+}