| | |
| | | <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> |
| | |
| | | <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> |
| | | |
| | | <view class="form-card detail-card"> |
| | | <!-- 体检医院:商品详情就是检查项目表(后台团单同款字段),不再用富文本/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 v-if="!isHealthCheckMerchant" class="form-card detail-card"> |
| | | <text class="section-title">图文详情</text> |
| | | <view class="gallery-row"> |
| | | <image v-for="(imagePath, index) in detailImages" :key="index" class="detail-thumb" :src="imagePath" mode="aspectFill"></image> |
| | |
| | | <textarea 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> |
| | |
| | | <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> |
| | |
| | | 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(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) => { |
| | |
| | | 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() : [] |
| | | isHealthCheckMerchant.value = detail.merchantTypeCode == 'medical' |
| | | 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) => { |
| | |
| | | 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 = () => { |
| | |
| | | nameError.value = '' |
| | | amountError.value = '' |
| | | coverError.value = '' |
| | | reserveError.value = '' |
| | | itemError.value = '' |
| | | productName.value = productName.value.trim() |
| | | productAmount.value = productAmount.value.trim() |
| | | let hasError = false |
| | |
| | | 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 = '请上传商品封面图' |
| | |
| | | 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) |
| | |
| | | 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' }) |
| | | }) |
| | |
| | | .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; } |