<template>
|
<view class="page">
|
<view class="navigation-bar">
|
<view class="back-button" @tap.stop="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view>
|
<text class="page-title">查看商品详情</text>
|
</view>
|
|
<view v-if="loadState != 'success'" class="feedback-shell"><page-feedback :state="loadState" loading-text="商品详情加载中" error-text="商品详情加载失败,点击重试" :trace-id="traceId" @retry="loadProduct"></page-feedback></view>
|
<scroll-view v-else class="detail-scroll" scroll-y="true" :show-scrollbar="false">
|
<view class="form-card basic-card">
|
<view class="field-label"><text class="required-mark">*</text><text>商品名称</text></view>
|
<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 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>
|
<text class="image-count">(1张)</text>
|
<text class="image-rule">{{productImageRuleText}}</text>
|
<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">
|
<image v-for="(imagePath, index) in detailImages" :key="index" class="detail-thumb" :src="imagePath" mode="aspectFill"></image>
|
<view v-if="detailImages.length < productDetailImageMaxCount" class="add-image" @tap="addDetailImage"><text>+</text></view>
|
</view>
|
<text class="image-rule">已选择 {{detailImages.length}}/{{productDetailImageMaxCount}} 张</text>
|
<textarea v-if="!isHealthCheckMerchant" class="detail-textarea" maxlength="1000" v-model="detailText" placeholder="请输入" placeholder-class="input-placeholder" :auto-height="false"></textarea>
|
</view>
|
|
<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"><text class="notice-label">使用方式</text><input class="form-input compact-input" maxlength="80" v-model="usage" 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 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>
|
<view class="benefit-tags"><text class="benefit-tag primary-tag">免费5次/年</text><text class="benefit-tag">8.8折</text></view>
|
</view>
|
<view class="benefit-row"><view class="benefit-copy"><text class="benefit-name">杰出人才卡</text><text class="benefit-price">¥128.00</text></view><view class="benefit-tags"><text class="benefit-tag">8.8折</text></view></view>
|
<view class="benefit-row last-benefit"><view class="benefit-copy"><text class="benefit-name">菁英人才卡</text><text class="benefit-price">¥128.00</text></view><view class="benefit-tags"><text class="benefit-tag">8.8折</text></view></view>
|
</view>
|
<view class="scroll-spacer"></view>
|
</scroll-view>
|
|
<view v-if="loadState == 'success'" class="bottom-actions">
|
<!-- 按钮按商品当前状态取反:已下架显示「商品上架」,已上架显示「商品下架」。 -->
|
<view class="offline-button" :class="[productStatus == 'offline' ? 'is-online' : '', submitting ? 'action-disabled' : '']" @tap="toggleProductStatus"><text>{{productStatus == 'offline' ? '商品上架' : '商品下架'}}</text></view>
|
<view class="save-button" :class="submitting ? 'action-disabled' : ''" @tap="saveProduct"><text>{{submitting ? '保存中' : '保存'}}</text></view>
|
</view>
|
</view>
|
</template>
|
|
<script setup lang="uts">
|
import { apiEnvironment, useMerchantManagementDemoDataInDevelopment } from '../../config/api.uts'
|
import { productImageMaxBytes, productDetailImageMaxCount, productImageRuleText } from '../../config/upload.uts'
|
import { hasActiveSession } from '../../services/session.uts'
|
import { MerchantProductDetail, loadDemoMerchantProduct, loadMerchantProduct, saveDemoMerchantProduct, saveMerchantProduct, toMerchantUploadPath, updateDemoMerchantProductStatus, updateMerchantProductStatus, uploadMerchantProductImage } from '../../services/merchant-management.uts'
|
|
const useDemoData = apiEnvironment == 'development' && useMerchantManagementDemoDataInDevelopment
|
const productId = ref('')
|
const reviewId = ref('')
|
const productName = ref('')
|
const productAmount = ref('')
|
const detailText = ref('')
|
const validity = ref('')
|
const availableTime = ref('')
|
const usage = ref('')
|
const otherRules = ref('')
|
const coverImage = ref('/static/product-sauerkraut-fish.jpg')
|
const detailImages = ref(['/static/product-detail-food.jpg', '/static/product-sauerkraut-fish.jpg'])
|
const nameError = ref('')
|
const amountError = ref('')
|
const coverError = ref('')
|
const initialSnapshot = ref('')
|
// 商品当前上下架状态:'online' / 'offline',决定底部按钮显示「商品下架」还是「商品上架」。
|
const productStatus = ref('')
|
const loadState = ref('loading')
|
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(','), noticeValidPeriod.value, reserveStartTerm.value, reserveEndTerm.value, serializeCheckItems()].join('|')
|
}
|
|
const applyDetail = (detail: MerchantProductDetail) => {
|
productStatus.value = detail.status
|
productName.value = detail.productName
|
productAmount.value = detail.salePrice.toFixed(2)
|
detailText.value = detail.detailText
|
validity.value = detail.purchaseNotice != null && detail.purchaseNotice.validity != null ? '' + detail.purchaseNotice.validity : ''
|
availableTime.value = detail.purchaseNotice != null && detail.purchaseNotice.availableTime != null ? '' + detail.purchaseNotice.availableTime : ''
|
usage.value = detail.purchaseNotice != null && detail.purchaseNotice.usage != null ? '' + detail.purchaseNotice.usage : ''
|
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) => {
|
productId.value = options.id != null ? options.id as string : ''
|
reviewId.value = options.reviewId != null ? options.reviewId as string : ''
|
loadProduct()
|
})
|
|
const loadProduct = () => {
|
if (productId.value.length == 0) {
|
loadState.value = 'error'
|
return
|
}
|
if (cancelProductRequest != null) {
|
cancelProductRequest()
|
cancelProductRequest = null
|
}
|
loadState.value = 'loading'
|
traceId.value = ''
|
const applyLoadedProduct = (detail: MerchantProductDetail) => {
|
applyDetail(detail)
|
const draft = uni.getStorageSync(getDraftStorageKey())
|
if (draft != null && draft != '') applyDraft(draft as any)
|
initialSnapshot.value = buildSnapshot()
|
loadState.value = 'success'
|
}
|
if (useDemoData) {
|
const detail = loadDemoMerchantProduct(productId.value)
|
if (detail == null) loadState.value = 'error'
|
else applyLoadedProduct(detail)
|
return
|
}
|
if (!hasActiveSession()) {
|
loadState.value = 'error'
|
return
|
}
|
cancelProductRequest = loadMerchantProduct(productId.value, (detail: MerchantProductDetail, currentTraceId: string) => {
|
cancelProductRequest = null
|
traceId.value = currentTraceId
|
applyLoadedProduct(detail)
|
}, (error: any) => {
|
cancelProductRequest = null
|
traceId.value = error.traceId != null ? error.traceId : ''
|
loadState.value = 'error'
|
})
|
}
|
|
const applyDraft = (draft: any) => {
|
productName.value = draft.name != null ? draft.name as string : productName.value
|
productAmount.value = draft.amount != null ? draft.amount as string : productAmount.value
|
detailText.value = draft.detailText != null ? draft.detailText as string : detailText.value
|
validity.value = draft.validity != null ? draft.validity as string : validity.value
|
availableTime.value = draft.availableTime != null ? draft.availableTime as string : availableTime.value
|
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 = () => {
|
uni.navigateBack()
|
}
|
|
const goBack = () => {
|
if (buildSnapshot() == initialSnapshot.value) {
|
navigateBack()
|
return
|
}
|
uni.showModal({
|
title: '内容尚未保存',
|
content: '返回后本次修改将丢失,确定返回吗?',
|
cancelText: '继续编辑',
|
confirmText: '确定返回',
|
confirmColor: '#F25353',
|
success: (result: any) => {
|
if (result.confirm) navigateBack()
|
}
|
})
|
}
|
|
const clearNameError = () => { nameError.value = '' }
|
const clearAmountError = () => { amountError.value = '' }
|
|
// 体检医院「可预约时间」的两个日期选择器:模板上挂的 @change 一直没有对应处理函数,
|
// 选完日期不写回表单,表现就是「选了没生效」。
|
const onReserveStartChange = (event: any) => {
|
reserveStartTerm.value = event.detail.value as string
|
reserveError.value = ''
|
}
|
|
const onReserveEndChange = (event: any) => {
|
reserveEndTerm.value = event.detail.value as string
|
reserveError.value = ''
|
}
|
|
const changeCover = () => {
|
coverError.value = ''
|
uni.chooseImage({
|
count: 1,
|
sizeType: ['compressed'],
|
sourceType: ['album', 'camera'],
|
success: (result: any) => {
|
const files = result.tempFiles as any[]
|
if (files.length == 0) return
|
if ((files[0].size as number) > productImageMaxBytes) {
|
coverError.value = '封面图片不能超过5MB'
|
return
|
}
|
const path = files[0].path as string
|
if (useDemoData) {
|
coverImage.value = path
|
return
|
}
|
imageUploadCount.value++
|
uploadMerchantProductImage(path, (url: string) => {
|
imageUploadCount.value--
|
coverImage.value = url
|
}, (error: any) => {
|
imageUploadCount.value--
|
uni.showToast({ title: error.message != null ? error.message : '封面上传失败', icon: 'none' })
|
})
|
}
|
})
|
}
|
|
const addDetailImage = () => {
|
const remaining = productDetailImageMaxCount - detailImages.value.length
|
if (remaining <= 0) {
|
uni.showToast({ title: '详情图最多6张', icon: 'none' })
|
return
|
}
|
uni.chooseImage({
|
count: remaining,
|
sizeType: ['compressed'],
|
sourceType: ['album', 'camera'],
|
success: (result: any) => {
|
const files = result.tempFiles as any[]
|
let oversizedCount = 0
|
files.forEach((file: any) => {
|
if ((file.size as number) > productImageMaxBytes) {
|
oversizedCount++
|
return
|
}
|
const path = file.path as string
|
if (useDemoData) {
|
detailImages.value.push(path)
|
return
|
}
|
imageUploadCount.value++
|
uploadMerchantProductImage(path, (url: string) => {
|
imageUploadCount.value--
|
if (detailImages.value.length < productDetailImageMaxCount) detailImages.value.push(url)
|
}, (error: any) => {
|
imageUploadCount.value--
|
uni.showToast({ title: error.message != null ? error.message : '详情图片上传失败', icon: 'none' })
|
})
|
})
|
if (oversizedCount > 0) uni.showToast({ title: oversizedCount.toString() + '张图片超过5MB', icon: 'none' })
|
}
|
})
|
}
|
|
// 底部按钮按当前状态取反:已下架就上架,已上架就下架(后端 PATCH 支持 online/offline,
|
// 上架时后端会校验商品必须已审核通过)。
|
const toggleProductStatus = () => {
|
if (submitting.value) return
|
const goingOnline = productStatus.value == 'offline'
|
const nextStatus = goingOnline ? 'online' : 'offline'
|
uni.showModal({
|
title: goingOnline ? '商品上架' : '商品下架',
|
content: goingOnline ? '确认上架当前商品吗?' : '确认下架当前商品吗?',
|
confirmText: goingOnline ? '确认上架' : '确认下架',
|
confirmColor: goingOnline ? '#F07A2E' : '#F25353',
|
success: (result: any) => {
|
if (!result.confirm) return
|
submitting.value = true
|
const finish = () => {
|
submitting.value = false
|
initialSnapshot.value = buildSnapshot()
|
uni.removeStorageSync(getDraftStorageKey())
|
uni.showToast({ title: goingOnline ? '商品已上架' : '商品已下架', icon: 'success' })
|
}
|
if (useDemoData) {
|
const updated = updateDemoMerchantProductStatus(productId.value, nextStatus)
|
if (updated != null) productStatus.value = updated.status
|
finish()
|
return
|
}
|
updateMerchantProductStatus(productId.value, nextStatus, (product: MerchantProductDetail, _traceId: string) => {
|
productStatus.value = product.status
|
finish()
|
}, (error: any) => {
|
submitting.value = false
|
uni.showToast({ title: error.message != null ? error.message : (goingOnline ? '商品上架失败' : '商品下架失败'), icon: 'none' })
|
})
|
}
|
})
|
}
|
|
const saveProduct = () => {
|
if (submitting.value) return
|
if (imageUploadCount.value > 0) {
|
uni.showToast({ title: '图片上传中,请稍后保存', icon: 'none' })
|
return
|
}
|
nameError.value = ''
|
amountError.value = ''
|
coverError.value = ''
|
reserveError.value = ''
|
itemError.value = ''
|
productName.value = productName.value.trim()
|
productAmount.value = productAmount.value.trim()
|
let hasError = false
|
if (productName.value.length == 0) {
|
nameError.value = '请输入商品名称'
|
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 = '请上传商品封面图'
|
hasError = true
|
}
|
if (hasError) return
|
const payload = {
|
productName: productName.value,
|
salePrice: isHealthCheckMerchant.value ? null : parseFloat(productAmount.value),
|
coverUrl: toMerchantUploadPath(coverImage.value),
|
productImages: detailImages.value.map((item: string) => toMerchantUploadPath(item)),
|
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)
|
initialSnapshot.value = buildSnapshot()
|
uni.removeStorageSync(getDraftStorageKey())
|
submitting.value = false
|
uni.showToast({ title: '已提交审核', icon: 'success' })
|
}
|
submitting.value = true
|
if (useDemoData) {
|
const detail = saveDemoMerchantProduct(productId.value, payload)
|
if (detail == null) {
|
submitting.value = false
|
uni.showToast({ title: '商品不存在', icon: 'none' })
|
return
|
}
|
finish(detail)
|
return
|
}
|
saveMerchantProduct(productId.value, payload, finish, (error: any) => {
|
submitting.value = false
|
uni.setStorageSync(getDraftStorageKey(), {
|
name: productName.value,
|
amount: productAmount.value,
|
detailText: detailText.value,
|
validity: validity.value,
|
availableTime: availableTime.value,
|
usage: usage.value,
|
otherRules: otherRules.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' })
|
})
|
}
|
</script>
|
|
<style>
|
.page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #F4F6F9; }
|
.navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex-direction: row; align-items: center; background-color: #F4F6F9; box-sizing: border-box; position: relative; flex: none; }
|
.back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
|
.back-icon { width: 38rpx; height: 38rpx; }
|
.page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
|
|
.detail-scroll { flex: 1; height: 0; padding: 0 34rpx 164rpx; background-color: #F4F6F9; box-sizing: border-box; }
|
.feedback-shell { display: flex; flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; }
|
.form-card { padding: 32rpx 30rpx; margin-bottom: 28rpx; border-radius: 34rpx; background-color: #FFFFFF; box-sizing: border-box; }
|
.basic-card { padding-bottom: 30rpx; }
|
.field-label { display: flex; flex-direction: row; align-items: center; color: #202B3E; font-size: 29rpx; line-height: 41rpx; font-weight: 700; }
|
.required-mark { color: #FF5546; margin-right: 7rpx; }
|
.field-spacing { margin-top: 30rpx; }
|
.form-input, .amount-shell { height: 86rpx; margin-top: 16rpx; border-width: 1rpx; border-style: solid; border-color: #F0DDD5; border-radius: 20rpx; background-color: #FFFEFD; box-sizing: border-box; }
|
.form-input { width: 100%; padding: 0 24rpx; color: #202B3E; font-size: 27rpx; line-height: 86rpx; }
|
.amount-shell { display: flex; flex-direction: row; align-items: center; padding-right: 24rpx; }
|
.amount-input { flex: 1; height: 84rpx; padding-left: 24rpx; color: #202B3E; font-size: 27rpx; }
|
.currency { color: #4C5665; font-size: 27rpx; line-height: 38rpx; font-weight: 600; }
|
.image-count { color: #657184; font-size: 23rpx; line-height: 32rpx; margin-top: 8rpx; }
|
.image-rule { display: block; margin-top: 8rpx; color: #9AA4B3; font-size: 22rpx; line-height: 32rpx; }
|
.cover-image { width: 100%; height: 224rpx; margin-top: 16rpx; border-radius: 28rpx; background-color: #EEF1F4; }
|
.change-cover { display: flex; height: 80rpx; margin-top: 18rpx; justify-content: center; align-items: center; border-width: 1rpx; border-style: solid; border-color: #FFD1BD; border-radius: 40rpx; color: #FF5A42; font-size: 27rpx; line-height: 38rpx; font-weight: 600; background-color: #FFF9F5; }
|
.change-plus { font-size: 32rpx; line-height: 38rpx; margin-right: 10rpx; font-weight: 400; }
|
|
.section-title { color: #202B3E; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
|
.gallery-row { display: flex; margin-top: 24rpx; flex-direction: row; }
|
.detail-thumb, .add-image { width: 114rpx; height: 114rpx; margin-right: 20rpx; border-radius: 22rpx; flex: none; }
|
.detail-thumb { background-color: #EEF1F4; }
|
.add-image { display: flex; justify-content: center; align-items: center; border-width: 1rpx; border-style: solid; border-color: #F0DDD5; color: #A7AFBA; font-size: 43rpx; line-height: 58rpx; background-color: #FFFEFD; box-sizing: border-box; }
|
.detail-textarea { width: 100%; height: 186rpx; padding: 20rpx 24rpx; margin-top: 26rpx; color: #202B3E; font-size: 27rpx; line-height: 39rpx; border-width: 1rpx; border-style: solid; border-color: #F0DDD5; border-radius: 22rpx; background-color: #FFFEFD; box-sizing: border-box; }
|
|
.notice-card { padding-bottom: 26rpx; }
|
.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; }
|
.benefit-row { display: flex; min-height: 104rpx; padding: 23rpx 22rpx; margin-top: 18rpx; flex-direction: row; align-items: center; justify-content: space-between; border-radius: 20rpx; background-color: #FFFCFA; box-sizing: border-box; }
|
.benefit-copy { display: flex; flex-direction: column; }
|
.benefit-name { color: #303A49; font-size: 27rpx; line-height: 38rpx; font-weight: 700; }
|
.benefit-price { color: #657184; font-size: 25rpx; line-height: 35rpx; margin-top: 5rpx; }
|
.benefit-tags { display: flex; flex-direction: row; align-items: center; }
|
.benefit-tag { padding: 8rpx 16rpx; margin-left: 12rpx; border-radius: 24rpx; color: #FFFFFF; font-size: 23rpx; line-height: 32rpx; font-weight: 600; background-color: #CF9B35; }
|
.primary-tag { background-color: #CF9B35; }
|
.last-benefit { margin-bottom: 0; }
|
.scroll-spacer { height: 24rpx; }
|
|
.bottom-actions { display: flex; height: 142rpx; padding: 20rpx 34rpx 28rpx; flex-direction: row; background-color: #FFFFFF; box-shadow: 0 -8rpx 24rpx rgba(41, 51, 67, 0.04); box-sizing: border-box; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; }
|
.offline-button, .save-button { display: flex; height: 84rpx; justify-content: center; align-items: center; border-radius: 44rpx; font-size: 29rpx; line-height: 40rpx; font-weight: 700; box-sizing: border-box; }
|
.offline-button { width: 218rpx; margin-right: 22rpx; border-width: 1rpx; border-style: solid; border-color: #FFB7B4; color: #F25353; background-color: #FFF6F5; }
|
/* 已下架时按钮是「商品上架」,用中性偏正向的橙色,和下架的红色区分开。 */
|
.offline-button.is-online { border-color: #FFC79A; color: #F07A2E; background-color: #FFF7EF; }
|
.save-button { flex: 1; color: #FFFFFF; background-image: linear-gradient(90deg, #FF8B1F 0%, #FF4A37 100%); box-shadow: 0 12rpx 22rpx rgba(255, 94, 47, 0.18); }
|
.input-placeholder { color: #B9BEC8; }
|
.input-invalid { border-color: #F0746D; background-color: #FFF9F8; }
|
.action-disabled { opacity: 0.62; }
|
.field-error { display: block; margin-top: 10rpx; color: #E95B55; font-size: 23rpx; line-height: 33rpx; }
|
</style>
|