shinnosuke
2026-09-21 7844f5af457ff3b7d3f8eeac8799e8d281e635a1
rencaika_merchant/pages/product-detail/product-detail.uvue
@@ -12,8 +12,9 @@
            <input class="form-input" :class="nameError.length > 0 ? 'input-invalid' : ''" maxlength="40" v-model="productName" placeholder="请输入" placeholder-class="input-placeholder" @input="clearNameError" />
            <text v-if="nameError.length > 0" class="field-error">{{nameError}}</text>
            <view class="field-label field-spacing"><text class="required-mark">*</text><text>商品金额</text></view>
            <view class="amount-shell" :class="amountError.length > 0 ? 'input-invalid' : ''"><input class="amount-input" type="digit" maxlength="10" v-model="productAmount" placeholder="请输入" placeholder-class="input-placeholder" @input="clearAmountError" /><text class="currency">元</text></view>
            <!-- 体检医院在后台团单里没有「商品金额」,这一块整段不显示。 -->
            <view v-if="!isHealthCheckMerchant" class="field-label field-spacing"><text class="required-mark">*</text><text>商品金额</text></view>
            <view v-if="!isHealthCheckMerchant" class="amount-shell" :class="amountError.length > 0 ? 'input-invalid' : ''"><input class="amount-input" type="digit" maxlength="10" v-model="productAmount" placeholder="请输入" placeholder-class="input-placeholder" @input="clearAmountError" /><text class="currency">元</text></view>
            <text v-if="amountError.length > 0" class="field-error">{{amountError}}</text>
            <view class="field-label field-spacing"><text class="required-mark">*</text><text>商品封面图</text></view>
@@ -22,8 +23,35 @@
            <image class="cover-image" :src="coverImage" mode="aspectFill"></image>
            <text v-if="coverError.length > 0" class="field-error">{{coverError}}</text>
            <view class="change-cover" @tap="changeCover"><text class="change-plus">+</text><text>更换</text></view>
            <!-- 体检医院:可预约时间,对应后台团单的「可预约时间」 -->
            <view v-if="isHealthCheckMerchant" class="field-label field-spacing"><text class="required-mark">*</text><text>可预约时间</text></view>
            <view v-if="isHealthCheckMerchant" class="reserve-range">
               <picker class="reserve-picker" mode="date" :value="reserveStartTerm" start="2000-01-01" end="2099-12-31" @change="onReserveStartChange">
                  <view class="select-shell" :class="reserveError.length > 0 ? 'field-invalid' : ''"><text :class="reserveStartTerm.length == 0 ? 'select-placeholder' : ''">{{reserveStartTerm.length > 0 ? reserveStartTerm : '开始日期'}}</text></view>
               </picker>
               <text class="reserve-separator">至</text>
               <picker class="reserve-picker" mode="date" :value="reserveEndTerm" start="2000-01-01" end="2099-12-31" @change="onReserveEndChange">
                  <view class="select-shell" :class="reserveError.length > 0 ? 'field-invalid' : ''"><text :class="reserveEndTerm.length == 0 ? 'select-placeholder' : ''">{{reserveEndTerm.length > 0 ? reserveEndTerm : '结束日期'}}</text></view>
               </picker>
            </view>
            <text v-if="reserveError.length > 0" class="field-error">{{reserveError}}</text>
         </view>
         <!-- 体检医院:商品详情就是检查项目表(后台团单同款字段),不再用富文本/JSON 文本框。 -->
         <view v-if="isHealthCheckMerchant" class="form-card detail-card">
            <text class="section-title">商品详情</text>
            <view v-for="(item, index) in checkItems" :key="index" class="check-card">
               <view class="check-card-head"><text class="check-card-title">检查项目 {{index + 1}}</text><text class="check-remove" @tap="removeCheckItem(index)">删除</text></view>
               <view class="notice-field"><text class="notice-label">检查项目</text><input class="form-input compact-input" :class="itemError.length > 0 && item.name.length == 0 ? 'input-invalid' : ''" maxlength="40" v-model="item.name" placeholder="请输入" placeholder-class="input-placeholder" /></view>
               <view class="notice-field"><text class="notice-label">项目意义</text><input class="form-input compact-input" :class="itemError.length > 0 && item.meaning.length == 0 ? 'input-invalid' : ''" maxlength="60" v-model="item.meaning" placeholder="请输入" placeholder-class="input-placeholder" /></view>
               <view class="notice-field last-notice"><text class="notice-label">适用性别</text><view class="gender-row"><text class="gender-chip" :class="hasGender(item, '男') ? 'gender-chip-active' : ''" @tap="toggleGender(index, '男')">男</text><text class="gender-chip" :class="hasGender(item, '女') ? 'gender-chip-active' : ''" @tap="toggleGender(index, '女')">女</text></view></view>
            </view>
            <text v-if="itemError.length > 0" class="field-error">{{itemError}}</text>
            <view class="change-cover" @tap="addCheckItem"><text class="change-plus">+</text><text>新增检查项目</text></view>
         </view>
         <!-- 图文详情:所有商家都能传(体检医院的商品详情是下面的检查项目表,这里只放图片,不放富文本)。 -->
         <view class="form-card detail-card">
            <text class="section-title">图文详情</text>
            <view class="gallery-row">
@@ -31,10 +59,10 @@
               <view v-if="detailImages.length < productDetailImageMaxCount" class="add-image" @tap="addDetailImage"><text>+</text></view>
            </view>
            <text class="image-rule">已选择 {{detailImages.length}}/{{productDetailImageMaxCount}} 张</text>
            <textarea class="detail-textarea" maxlength="1000" v-model="detailText" placeholder="请输入" placeholder-class="input-placeholder" :auto-height="false"></textarea>
            <textarea v-if="!isHealthCheckMerchant" class="detail-textarea" maxlength="1000" v-model="detailText" placeholder="请输入" placeholder-class="input-placeholder" :auto-height="false"></textarea>
         </view>
         <view class="form-card notice-card">
         <view v-if="!isHealthCheckMerchant" class="form-card notice-card">
            <text class="section-title">购买须知设置(非必填)</text>
            <view class="notice-field"><text class="notice-label">有效期</text><input class="form-input compact-input" maxlength="80" v-model="validity" placeholder="请输入" placeholder-class="input-placeholder" /></view>
            <view class="notice-field"><text class="notice-label">可用时间</text><input class="form-input compact-input" maxlength="80" v-model="availableTime" placeholder="请输入" placeholder-class="input-placeholder" /></view>
@@ -42,7 +70,7 @@
            <view class="notice-field last-notice"><text class="notice-label">其他规则</text><input class="form-input compact-input" maxlength="120" v-model="otherRules" placeholder="请输入" placeholder-class="input-placeholder" /></view>
         </view>
         <view class="form-card benefits-card">
         <view v-if="!isHealthCheckMerchant" class="form-card benefits-card">
            <text class="section-title">人才权益</text>
            <view class="benefit-row">
               <view class="benefit-copy"><text class="benefit-name">卓越人才卡</text><text class="benefit-price">¥128.00</text></view>
@@ -90,12 +118,105 @@
   const traceId = ref('')
   const submitting = ref(false)
   const imageUploadCount = ref(0)
   // 体检医院在后台团单里是另一套字段:商品名称 / 封面图 / 可预约时间 / 检查项目,
   // 没有商品金额、图文详情、购买须知、人才权益。商家类型跟着商品详情一起返回。
   const isHealthCheckMerchant = ref(false)
   const reserveStartTerm = ref('')
   const reserveEndTerm = ref('')
   const reserveError = ref('')
   const noticeValidPeriod = ref('')
   const checkItems = ref<any[]>([])
   const itemError = ref('')
   let cancelProductRequest: (() => void) | null = null
   const getDraftStorageKey = (): string => 'merchant-cache-product-draft-' + productId.value
   const emptyCheckItem = (): any => ({ name: '', meaning: '', gender: [] as string[] })
   const serializeCheckItems = (): string => JSON.stringify(checkItems.value)
   const hasGender = (item: any, gender: string): boolean => (item.gender as string[]).indexOf(gender) >= 0
   const toggleGender = (index: number, gender: string) => {
      if (index < 0 || index >= checkItems.value.length) return
      const genders = checkItems.value[index].gender as string[]
      const position = genders.indexOf(gender)
      if (position >= 0) genders.splice(position, 1)
      else genders.push(gender)
      itemError.value = ''
   }
   const addCheckItem = () => {
      checkItems.value.push(emptyCheckItem())
      itemError.value = ''
   }
   const removeCheckItem = (index: number) => {
      if (index < 0 || index >= checkItems.value.length) return
      checkItems.value.splice(index, 1)
      if (checkItems.value.length == 0) checkItems.value.push(emptyCheckItem())
      itemError.value = ''
   }
   /** 判断 detailText 是不是体检检查项目([{name,meaning,gender}] 的 JSON 数组)。 */
   const looksLikeHealthCheckItems = (text: string): boolean => {
      const source = text == null ? '' : ('' + text).trim()
      if (source.length == 0 || source.indexOf('[') != 0) return false
      try {
         const parsed = JSON.parse(source)
         if (!(parsed instanceof Array) || parsed.length == 0) return false
         const first = parsed[0] as any
         return first != null && first.name != null && first.meaning != null && first.gender != null
      } catch (error) {
         return false
      }
   }
   /** 检查项目在后台团单里存成 detailText(JSON 数组);坏数据或空值按一行空项目处理。 */
   const parseCheckItems = (text: string): any[] => {
      const items: any[] = []
      const source = text == null ? '' : ('' + text).trim()
      if (source.length > 0 && source.indexOf('[') == 0) {
         try {
            const parsed = JSON.parse(source)
            if (parsed instanceof Array) {
               const records = parsed as any[]
               for (let index = 0; index < records.length; index++) {
                  const record = records[index] as any
                  const genders: string[] = []
                  const value = record != null && record.gender != null ? record.gender : []
                  if (value instanceof Array) {
                     const rawGenders = value as any[]
                     for (let position = 0; position < rawGenders.length; position++) genders.push('' + rawGenders[position])
                  }
                  items.push({
                     name: record != null && record.name != null ? '' + record.name : '',
                     meaning: record != null && record.meaning != null ? '' + record.meaning : '',
                     gender: genders
                  })
               }
            }
         } catch (error) {
         }
      }
      if (items.length == 0) items.push(emptyCheckItem())
      return items
   }
   const isTermDate = (value: string): boolean => {
      const parts = value.split('-')
      return parts.length == 3 && parts[0].length == 4 && parts[1].length > 0 && parts[1].length <= 2 && parts[2].length > 0 && parts[2].length <= 2
   }
   /** 可预约时间后台存成「2026-09-16 至 2026-09-18」,解析不出来就留空让商家重选。 */
   const parseReserveTerms = (value: string): string[] => {
      const parts = (value == null ? '' : '' + value).replace(/\//g, '-').split('至')
      if (parts.length != 2) return ['', '']
      const start = parts[0].trim()
      const end = parts[1].trim()
      return [isTermDate(start) ? start : '', isTermDate(end) ? end : '']
   }
   const buildSnapshot = (): string => {
      return [productName.value, productAmount.value, detailText.value, validity.value, availableTime.value, usage.value, otherRules.value, coverImage.value, detailImages.value.join(',')].join('|')
      return [productName.value, productAmount.value, detailText.value, validity.value, availableTime.value, usage.value, otherRules.value, coverImage.value, detailImages.value.join(','), noticeValidPeriod.value, reserveStartTerm.value, reserveEndTerm.value, serializeCheckItems()].join('|')
   }
   const applyDetail = (detail: MerchantProductDetail) => {
@@ -109,6 +230,14 @@
      otherRules.value = detail.purchaseNotice != null && detail.purchaseNotice.otherRules != null ? '' + detail.purchaseNotice.otherRules : ''
      coverImage.value = detail.coverUrl.length > 0 ? detail.coverUrl : '/static/product-sauerkraut-fish.jpg'
      detailImages.value = detail.productImages.length > 0 ? detail.productImages.slice() : []
      // 体检医院优先看商家类型;后端还没带上 merchantTypeCode 时,用「商品详情就是检查项目 JSON」兜底识别,
      // 否则页面会退回通用表单,把 detail_text 的原始 JSON 直接摊在文本框里给商户看。
      isHealthCheckMerchant.value = detail.merchantTypeCode == 'medical' || looksLikeHealthCheckItems(detail.detailText)
      noticeValidPeriod.value = detail.purchaseNotice != null && detail.purchaseNotice.validPeriod != null ? '' + detail.purchaseNotice.validPeriod : ''
      checkItems.value = parseCheckItems(detail.detailText)
      const terms = parseReserveTerms(noticeValidPeriod.value)
      reserveStartTerm.value = terms[0]
      reserveEndTerm.value = terms[1]
   }
   onLoad((options: any) => {
@@ -165,6 +294,10 @@
      usage.value = draft.usage != null ? draft.usage as string : usage.value
      otherRules.value = draft.otherRules != null ? draft.otherRules as string : otherRules.value
      coverImage.value = draft.coverImage != null ? draft.coverImage as string : coverImage.value
      noticeValidPeriod.value = draft.validPeriod != null ? draft.validPeriod as string : noticeValidPeriod.value
      reserveStartTerm.value = draft.reserveStart != null ? draft.reserveStart as string : reserveStartTerm.value
      reserveEndTerm.value = draft.reserveEnd != null ? draft.reserveEnd as string : reserveEndTerm.value
      if (draft.checkItems != null) checkItems.value = parseCheckItems('' + draft.checkItems)
   }
   const navigateBack = () => {
@@ -304,6 +437,8 @@
      nameError.value = ''
      amountError.value = ''
      coverError.value = ''
      reserveError.value = ''
      itemError.value = ''
      productName.value = productName.value.trim()
      productAmount.value = productAmount.value.trim()
      let hasError = false
@@ -311,12 +446,37 @@
         nameError.value = '请输入商品名称'
         hasError = true
      }
      if (productAmount.value.length == 0) {
         amountError.value = '请输入商品金额'
         hasError = true
      } else if (!(/^\d+(\.\d{1,2})?$/).test(productAmount.value) || parseFloat(productAmount.value) <= 0) {
         amountError.value = '请输入大于 0 且最多两位小数的金额'
         hasError = true
      // 体检医院没有「商品金额」这一项(后台按免费保存),其余商家仍然必填。
      if (!isHealthCheckMerchant.value) {
         if (productAmount.value.length == 0) {
            amountError.value = '请输入商品金额'
            hasError = true
         } else if (!(/^\d+(\.\d{1,2})?$/).test(productAmount.value) || parseFloat(productAmount.value) <= 0) {
            amountError.value = '请输入大于 0 且最多两位小数的金额'
            hasError = true
         }
      }
      if (isHealthCheckMerchant.value) {
         if (reserveStartTerm.value.length == 0 || reserveEndTerm.value.length == 0) {
            reserveError.value = '请选择可预约时间'
            hasError = true
         } else if (reserveStartTerm.value > reserveEndTerm.value) {
            reserveError.value = '结束日期不能早于开始日期'
            hasError = true
         }
         if (checkItems.value.length == 0) {
            itemError.value = '请至少添加一项检查项目'
            hasError = true
         } else {
            for (let index = 0; index < checkItems.value.length; index++) {
               const item = checkItems.value[index]
               if (('' + item.name).trim().length == 0 || ('' + item.meaning).trim().length == 0 || (item.gender as string[]).length == 0) {
                  itemError.value = '请完善检查项目信息(名称、意义、适用性别均不能为空)'
                  hasError = true
                  break
               }
            }
         }
      }
      if (coverImage.value.length == 0) {
         coverError.value = '请上传商品封面图'
@@ -325,11 +485,13 @@
      if (hasError) return
      const payload = {
         productName: productName.value,
         salePrice: parseFloat(productAmount.value),
         salePrice: isHealthCheckMerchant.value ? null : parseFloat(productAmount.value),
         coverUrl: toMerchantUploadPath(coverImage.value),
         productImages: detailImages.value.map((item: string) => toMerchantUploadPath(item)),
         detailText: detailText.value,
         purchaseNotice: { validity: validity.value, availableTime: availableTime.value, usage: usage.value, otherRules: otherRules.value }
         detailText: isHealthCheckMerchant.value ? serializeCheckItems() : detailText.value,
         purchaseNotice: isHealthCheckMerchant.value
            ? { validity: validity.value, availableTime: '', validPeriod: reserveStartTerm.value + ' 至 ' + reserveEndTerm.value, usage: usage.value, otherRules: otherRules.value }
            : { validity: validity.value, availableTime: availableTime.value, validPeriod: noticeValidPeriod.value, usage: usage.value, otherRules: otherRules.value }
      }
      const finish = (detail: MerchantProductDetail) => {
         applyDetail(detail)
@@ -359,7 +521,11 @@
         availableTime: availableTime.value,
         usage: usage.value,
         otherRules: otherRules.value,
         coverImage: coverImage.value
         coverImage: coverImage.value,
         validPeriod: noticeValidPeriod.value,
         reserveStart: reserveStartTerm.value,
         reserveEnd: reserveEndTerm.value,
         checkItems: isHealthCheckMerchant.value ? serializeCheckItems() : ''
      })
         uni.showToast({ title: error.message != null ? error.message : '商品保存失败,已保留草稿', icon: 'none' })
      })
@@ -402,6 +568,21 @@
   .notice-field { margin-top: 25rpx; }
   .notice-label { color: #202B3E; font-size: 27rpx; line-height: 38rpx; font-weight: 600; }
   .compact-input { margin-top: 14rpx; }
   /* 体检医院:可预约时间与检查项目(与后台团单同一套字段) */
   .reserve-range { display: flex; flex-direction: row; align-items: center; margin-top: 16rpx; }
   .reserve-picker { flex: 1; }
   .select-shell { display: flex; height: 86rpx; padding: 0 24rpx; flex-direction: row; align-items: center; border-width: 1rpx; border-style: solid; border-color: #F0DDD5; border-radius: 20rpx; background-color: #FFFEFD; box-sizing: border-box; }
   .select-shell text { color: #202B3E; font-size: 27rpx; line-height: 38rpx; }
   .select-placeholder { color: #A8B0BF; }
   .field-invalid { border-color: #F0746D; background-color: #FFF9F8; }
   .reserve-separator { margin: 0 16rpx; color: #7A8496; font-size: 26rpx; line-height: 38rpx; }
   .check-card { padding: 24rpx 22rpx; margin-top: 20rpx; border-radius: 24rpx; background-color: #FBFCFD; }
   .check-card-head { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
   .check-card-title { color: #202B3E; font-size: 27rpx; line-height: 38rpx; font-weight: 700; }
   .check-remove { color: #F0746D; font-size: 25rpx; line-height: 36rpx; }
   .gender-row { display: flex; flex-direction: row; margin-top: 14rpx; }
   .gender-chip { padding: 10rpx 34rpx; margin-right: 20rpx; border-width: 1rpx; border-style: solid; border-color: #E3E8EF; border-radius: 30rpx; color: #4C5665; font-size: 26rpx; line-height: 38rpx; }
   .gender-chip-active { border-color: #FF9B70; color: #EF7046; background-color: #FFF3EC; }
   .last-notice { margin-bottom: 0; }
   .benefits-card { padding-bottom: 20rpx; }