<template>
|
<view class="page">
|
<view class="navigation-bar">
|
<view class="back-button" @tap.stop="goBack"><image class="back-icon" src="/static/icons/lucide/chevron-right.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 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>
|
<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>
|
|
<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 class="detail-textarea" maxlength="1000" v-model="detailText" placeholder="请输入" placeholder-class="input-placeholder" :auto-height="false"></textarea>
|
</view>
|
|
<view 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 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="submitting ? 'action-disabled' : ''" @tap="offlineProduct"><text>商品下架</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('')
|
const loadState = ref('loading')
|
const traceId = ref('')
|
const submitting = ref(false)
|
const imageUploadCount = ref(0)
|
let cancelProductRequest: (() => void) | null = null
|
|
const getDraftStorageKey = (): string => 'merchant-cache-product-draft-' + productId.value
|
|
const buildSnapshot = (): string => {
|
return [productName.value, productAmount.value, detailText.value, validity.value, availableTime.value, usage.value, otherRules.value, coverImage.value, detailImages.value.join(',')].join('|')
|
}
|
|
const applyDetail = (detail: MerchantProductDetail) => {
|
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() : []
|
}
|
|
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
|
}
|
|
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 = '' }
|
|
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' })
|
}
|
})
|
}
|
|
const offlineProduct = () => {
|
if (submitting.value) return
|
uni.showModal({
|
title: '商品下架',
|
content: '确认下架当前商品吗?',
|
confirmText: '确认下架',
|
confirmColor: '#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: '商品已下架', icon: 'success' })
|
}
|
if (useDemoData) {
|
updateDemoMerchantProductStatus(productId.value, 'offline')
|
finish()
|
return
|
}
|
updateMerchantProductStatus(productId.value, 'offline', (_product: MerchantProductDetail, _traceId: string) => finish(), (error: any) => {
|
submitting.value = false
|
uni.showToast({ title: error.message != null ? error.message : '商品下架失败', icon: 'none' })
|
})
|
}
|
})
|
}
|
|
const saveProduct = () => {
|
if (submitting.value) return
|
if (imageUploadCount.value > 0) {
|
uni.showToast({ title: '图片上传中,请稍后保存', icon: 'none' })
|
return
|
}
|
nameError.value = ''
|
amountError.value = ''
|
coverError.value = ''
|
productName.value = productName.value.trim()
|
productAmount.value = productAmount.value.trim()
|
let hasError = false
|
if (productName.value.length == 0) {
|
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 (coverImage.value.length == 0) {
|
coverError.value = '请上传商品封面图'
|
hasError = true
|
}
|
if (hasError) return
|
const payload = {
|
productName: productName.value,
|
salePrice: 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 }
|
}
|
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
|
})
|
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; transform: rotate(180deg); }
|
.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; }
|
.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; }
|
.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>
|