| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680 |
- <template>
- <view class="product-detail-page">
- <view v-if="loading" class="loading">
- <ClientSkeleton variant="detail" :rows="2" />
- </view>
- <view v-else-if="!product" class="empty">商品不存在</view>
- <view v-else class="detail-body">
- <view class="detail-header"></view>
- <scroll-view class="detail-scroll" scroll-y :enable-back-to-top="true" :show-scrollbar="false">
- <view class="detail-scroll-inner" :class="{ 'detail-scroll-inner--view-only': viewOnlyMode }">
- <view class="hero-stack">
- <view class="banner-card">
- <swiper
- v-if="bannerList.length > 0"
- class="banner-swiper"
- :indicator-dots="false"
- :autoplay="true"
- :interval="3000"
- :circular="true"
- @change="onBannerChange"
- >
- <swiper-item v-for="(banner, index) in bannerList" :key="index">
- <ClientImageLoader
- v-if="banner.mediaType === 'image'"
- :src="banner.fileUrl"
- mode="aspectFill"
- class="banner-image"
- radius="20rpx"
- />
- <video v-else :src="banner.fileUrl" controls class="banner-video" />
- </swiper-item>
- </swiper>
- <view v-if="bannerList.length > 1" class="banner-dots">
- <view
- v-for="(_, idx) in bannerList"
- :key="`dot-${idx}`"
- class="banner-dot"
- :class="{ 'banner-dot--active': idx === bannerActiveIndex }"
- />
- </view>
- <view v-else class="banner-empty">
- <text class="banner-empty-text">暂无轮播图</text>
- </view>
- </view>
- <view class="product-info section-card">
- <text class="name">{{ product.name }}</text>
- <text class="spec">{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
- </view>
- </view>
- <view class="params-section section-card" v-if="parameterValues.length > 0">
- <view class="section-title">商品参数</view>
- <view class="params-list">
- <view v-for="param in parameterValues" :key="param.parameterId" class="param-item">
- <text class="param-name">{{ param.parameterName }}</text>
- <text class="param-value">{{ param.paramValue }}</text>
- </view>
- </view>
- </view>
- <view class="detail-section section-card" v-if="detailContent || detailMediaList.length > 0">
- <view class="section-title">商品详情</view>
- <view v-if="detailContent" class="rich-content">
- <rich-text :nodes="detailContent"></rich-text>
- </view>
- <view v-if="detailMediaList.length > 0" class="detail-media-list">
- <view v-for="(m, idx) in detailMediaList" :key="m.id || `dm-${idx}`" class="detail-media-item">
- <ClientImageLoader
- v-if="m.contentType === 'image'"
- :src="m.fileUrl"
- mode="widthFix"
- class="detail-media-img"
- radius="20rpx"
- />
- <video v-else :src="m.fileUrl" :poster="ossImg(m.thumbnailUrl || '')" controls class="detail-media-video" />
- <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
- </view>
- </view>
- </view>
- </view>
- </scroll-view>
- </view>
- <view v-if="product && !viewOnlyMode" class="bottom-bar" :class="{ 'bottom-bar--readonly': readonlyMode }">
- <view class="bottom-bar-inner">
- <view v-if="readonlyMode" class="bottom-price-row bottom-price-row--readonly">
- <text class="price-label">报价</text>
- <text class="price-readonly-value">{{ priceInput || '0.00' }}</text>
- <text class="price-unit">元/{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
- </view>
- <view v-else class="bottom-price-row">
- <text class="price-label">报价</text>
- <input
- class="price-input"
- type="text"
- :value="priceInput"
- placeholder="请输入价格"
- @input="onPriceInputEvent"
- @blur="onPriceBlur"
- />
- <text class="price-unit">元/{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
- <button class="save-btn" :disabled="savingPrice" :loading="savingPrice" @click="saveSingleProductPrice">
- 暂存价格
- </button>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref } from 'vue'
- import { onLoad } from '@dcloudio/uni-app'
- import { request } from '@/utils/request'
- import { useUserStore } from '@/stores/user'
- import ClientSkeleton from '@/components/client/client-skeleton.vue'
- import ClientImageLoader from '@/components/client/client-image-loader.vue'
- import { withOssVersion } from '@/utils/ossCache'
- const userStore = useUserStore()
- // 进入页面即展示骨架,避免首帧短暂出现空态
- const loading = ref(true)
- const savingPrice = ref(false)
- const product = ref<any>(null)
- const bannerList = ref<any[]>([])
- const parameterValues = ref<any[]>([])
- const detailContent = ref('')
- const detailMediaList = ref<any[]>([])
- const priceInput = ref('')
- const inquiryId = ref('')
- const quotationId = ref('')
- const readonlyMode = ref(false)
- /** 仅浏览商品详情(备货任务等入口),不展示底部报价栏 */
- const viewOnlyMode = ref(false)
- const inquiryItems = ref<any[]>([])
- const currentProductId = ref('')
- const bannerActiveIndex = ref(0)
- let openerEventChannel: any = null
- const TEMP_PRICE_STORAGE_KEY = 'supplier_inquiry_temp_prices'
- const ossImg = withOssVersion
- function formatMeasureText(measureValue?: number | null, unitName?: string | null) {
- const unitText = (unitName || '').trim()
- if (measureValue == null) {
- return unitText ? `- ${unitText}` : '-'
- }
- const measureText = String(measureValue).replace(/\.?0+$/, '')
- return unitText ? `${measureText} ${unitText}` : measureText || '-'
- }
- function normalizePriceInput(value: string) {
- const cleaned = value.replace(/[^\d.]/g, '')
- const parts = cleaned.split('.')
- if (parts.length <= 1) return cleaned
- return `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}`
- }
- function formatPriceToFixed2(value: string) {
- if (!value) return ''
- const num = Number(value)
- if (Number.isNaN(num)) return ''
- return num.toFixed(2)
- }
- function onPriceInput(value: string) {
- priceInput.value = normalizePriceInput(value)
- }
- function onPriceInputEvent(event: any) {
- const value = event?.detail?.value == null ? '' : String(event.detail.value)
- onPriceInput(value)
- }
- function onPriceBlur() {
- priceInput.value = formatPriceToFixed2(priceInput.value)
- }
- function applyTempPriceForCurrentProduct() {
- if (!currentProductId.value) return
- const inquiryKey = inquiryId.value || '_default'
- const tempPriceMap = uni.getStorageSync(TEMP_PRICE_STORAGE_KEY) || {}
- const inquiryTempMap = tempPriceMap[inquiryKey]
- if (!inquiryTempMap || typeof inquiryTempMap !== 'object') return
- const temp = inquiryTempMap[currentProductId.value]
- if (temp == null || temp === '') return
- const num = Number(temp)
- if (Number.isNaN(num) || num < 0) return
- priceInput.value = num.toFixed(2)
- }
- function onBannerChange(event: any) {
- bannerActiveIndex.value = Number(event?.detail?.current || 0)
- }
- async function loadProductDetail(productId: string) {
- loading.value = true
- try {
- const path = userStore.isDeliverer
- ? `/mini/deliverer/products/${productId}`
- : `/mini/supplier/products/${productId}`
- const res = await request({
- url: path,
- method: 'GET'
- })
- const body = res.data as {
- code: number
- data?: {
- product?: any
- banners?: any[]
- parameterValues?: any[]
- detailMedia?: any[]
- }
- message?: string
- }
- if (body.code !== 200 || !body.data) {
- uni.showToast({ title: body.message || '加载失败', icon: 'none' })
- return
- }
- product.value = body.data.product || null
- bannerList.value = Array.isArray(body.data.banners) ? body.data.banners : []
- bannerActiveIndex.value = 0
- parameterValues.value = Array.isArray(body.data.parameterValues) ? body.data.parameterValues : []
- detailContent.value = product.value?.detailContent || ''
- detailMediaList.value = Array.isArray(body.data.detailMedia) ? body.data.detailMedia : []
- } finally {
- loading.value = false
- }
- }
- async function loadInquiryContext() {
- if (!inquiryId.value) return
- const res = await request({
- url: `/mini/supplier/inquiries/${inquiryId.value}`,
- method: 'GET'
- })
- const body = res.data as { code: number; data?: { items?: any[] } }
- if (body.code !== 200 || !body.data) return
- inquiryItems.value = Array.isArray(body.data.items) ? body.data.items : []
- const current = inquiryItems.value.find((row) => row.productId === currentProductId.value)
- priceInput.value = current?.lastPrice == null ? '' : Number(current.lastPrice).toFixed(2)
- applyTempPriceForCurrentProduct()
- }
- async function loadQuotationContext() {
- if (!quotationId.value) return
- const res = await request({
- url: `/mini/supplier/quotations/${quotationId.value}`,
- method: 'GET'
- })
- const body = res.data as { code: number; data?: { items?: any[] } }
- if (body.code !== 200 || !body.data) return
- const list = Array.isArray(body.data.items) ? body.data.items : []
- const current = list.find((row) => row.productId === currentProductId.value)
- priceInput.value = current?.price == null ? '' : Number(current.price).toFixed(2)
- }
- async function saveSingleProductPrice() {
- if (!currentProductId.value) {
- uni.showToast({ title: '缺少商品信息', icon: 'none' })
- return
- }
- if (!priceInput.value) {
- uni.showToast({ title: '请填写价格', icon: 'none' })
- return
- }
- const currentPrice = Number(priceInput.value)
- if (Number.isNaN(currentPrice) || currentPrice < 0) {
- uni.showToast({ title: '价格格式不正确', icon: 'none' })
- return
- }
- savingPrice.value = true
- try {
- const tempPrice = Number(currentPrice.toFixed(2))
- const tempPriceMap = uni.getStorageSync(TEMP_PRICE_STORAGE_KEY) || {}
- const inquiryKey = inquiryId.value || '_default'
- tempPriceMap[inquiryKey] = {
- ...(tempPriceMap[inquiryKey] || {}),
- [currentProductId.value]: tempPrice
- }
- uni.setStorageSync(TEMP_PRICE_STORAGE_KEY, tempPriceMap)
- openerEventChannel?.emit('tempPriceSaved', {
- inquiryId: inquiryId.value,
- productId: currentProductId.value,
- price: tempPrice,
- message: '暂存成功'
- })
- const pages = getCurrentPages()
- if (pages.length > 1) {
- uni.navigateBack({ delta: 1 })
- } else {
- uni.redirectTo({ url: '/pages/supplier/inquiry/inquiry' })
- }
- } finally {
- savingPrice.value = false
- }
- }
- onLoad((options: any) => {
- currentProductId.value = options?.productId || ''
- inquiryId.value = options?.inquiryId || ''
- quotationId.value = options?.quotationId || ''
- viewOnlyMode.value = options?.viewOnly === '1'
- readonlyMode.value =
- !viewOnlyMode.value && (options?.readonly === '1' || !!quotationId.value)
- const pages = getCurrentPages()
- const currentPage = pages[pages.length - 1] as any
- openerEventChannel = currentPage?.getOpenerEventChannel?.() || null
- if (!currentProductId.value) {
- uni.showToast({ title: '缺少商品ID', icon: 'none' })
- loading.value = false
- return
- }
- loadProductDetail(currentProductId.value)
- if (viewOnlyMode.value) {
- return
- }
- if (readonlyMode.value) {
- loadQuotationContext()
- return
- }
- loadInquiryContext()
- })
- </script>
- <style lang="scss">
- /* 与供应商询价单页背景一致 */
- page {
- height: 100%;
- overflow: hidden;
- background-color: #f5f8f7;
- }
- </style>
- <style lang="scss" scoped>
- .product-detail-page {
- --mint-green: #0a9f6d;
- --mint-green-soft: #e9f9f2;
- --barley-yellow: #d6a343;
- --bg: #f5f8f7;
- --card: #ffffff;
- --text: #1f2a26;
- --muted: #6b7b74;
- --border: #dcebe4;
- --danger: #d14343;
- --shadow: 0 16rpx 40rpx rgba(10, 159, 109, 0.12);
- height: 100%;
- max-height: 100%;
- display: flex;
- flex-direction: column;
- background: var(--bg);
- box-sizing: border-box;
- overflow: hidden;
- color: var(--text);
- }
- .detail-body {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
- }
- .detail-header {
- flex-shrink: 0;
- height: 0;
- }
- .detail-scroll {
- flex: 1;
- min-height: 0;
- height: 0;
- background: var(--bg);
- }
- .detail-scroll-inner {
- padding: 0 32rpx calc(210rpx + env(safe-area-inset-bottom));
- }
- .detail-scroll-inner--view-only {
- padding-bottom: calc(36rpx + env(safe-area-inset-bottom));
- }
- .hero-stack {
- margin-top: 24rpx;
- }
- .loading, .empty {
- padding: 56rpx 40rpx;
- text-align: center;
- color: var(--muted);
- font-size: 26rpx;
- }
- .banner-card {
- background: var(--card);
- border: 1rpx solid var(--border);
- border-radius: 28rpx;
- overflow: hidden;
- position: relative;
- }
- .banner-swiper {
- width: 100%;
- aspect-ratio: 4 / 3;
- height: auto;
- background: linear-gradient(145deg, #f4fbf8, #dff2e9);
- .banner-image, .banner-video {
- width: 100%;
- height: 100%;
- }
- }
- .banner-empty {
- width: 100%;
- aspect-ratio: 4 / 3;
- height: auto;
- background: linear-gradient(145deg, #f4fbf8, #dff2e9);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .banner-dots {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- bottom: 18rpx;
- display: flex;
- gap: 10rpx;
- z-index: 8;
- }
- .banner-dot {
- width: 18rpx;
- height: 8rpx;
- border-radius: 999rpx;
- background: rgba(255, 255, 255, 0.45);
- transition: all 0.2s ease;
- }
- .banner-dot--active {
- width: 30rpx;
- background: #ffffff;
- }
- .banner-empty-text {
- font-size: 28rpx;
- color: var(--muted);
- }
- .product-info {
- margin-top: 24rpx;
- padding: 24rpx;
- .name {
- display: block;
- margin-top: 4rpx;
- font-size: 32rpx;
- color: var(--text);
- font-weight: 600;
- line-height: 1.4;
- }
- .spec {
- display: block;
- margin-top: 12rpx;
- font-size: 24rpx;
- color: var(--muted);
- }
- }
- .section-title {
- padding: 18rpx 0 20rpx;
- margin: 0 24rpx;
- font-size: 30rpx;
- color: var(--text);
- font-weight: 600;
- background: transparent;
- border-bottom: 1rpx solid #edf5f1;
- }
- .section-card {
- margin-top: 24rpx;
- background: var(--card);
- border: 1rpx solid var(--border);
- border-radius: 28rpx;
- overflow: hidden;
- }
- .detail-section {
- .rich-content {
- margin: 0 24rpx 24rpx;
- padding: 20rpx;
- border: 1rpx solid #e8f3ee;
- background: #f8fcfa;
- border-radius: 20rpx;
- font-size: 26rpx;
- line-height: 1.6;
- color: #33423c;
- }
- .detail-media-list {
- padding-bottom: 8rpx;
- }
- .detail-media-item {
- border-top: 1rpx solid #edf5f1;
- }
- .detail-media-img {
- width: 100%;
- display: block;
- }
- .detail-media-video {
- width: 100%;
- height: 420rpx;
- display: block;
- background: #000;
- }
- .detail-media-desc {
- display: block;
- padding: 16rpx 24rpx 24rpx;
- font-size: 24rpx;
- color: var(--muted);
- line-height: 1.5;
- }
- }
- .params-section {
- .params-list {
- padding: 0 24rpx 24rpx;
- }
- .param-item {
- display: grid;
- grid-template-columns: 180rpx 1fr;
- gap: 12rpx;
- padding: 16rpx 0;
- border-bottom: 1rpx solid #edf5f1;
- &:last-child {
- border-bottom: none;
- }
- }
- .param-name {
- width: 180rpx;
- font-size: 26rpx;
- color: var(--muted);
- }
- .param-value {
- flex: 1;
- font-size: 26rpx;
- color: var(--text);
- }
- }
- .bottom-bar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 9999;
- background: var(--card);
- border-top: 1rpx solid var(--border);
- box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.04);
- padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
- box-sizing: border-box;
- }
- .bottom-bar--readonly {
- background: var(--card);
- }
- .bottom-bar-inner {
- width: 100%;
- max-width: 750rpx;
- margin: 0 auto;
- }
- /* 与询价单列表「报价」行视觉一致 */
- .bottom-price-row {
- display: flex;
- flex-direction: row;
- align-items: center;
- width: 100%;
- gap: 10rpx;
- box-sizing: border-box;
- padding: 0;
- background: transparent;
- border: none;
- border-radius: 0;
- }
- .bottom-price-row--readonly {
- flex-wrap: wrap;
- justify-content: center;
- align-items: center;
- gap: 8rpx 12rpx;
- background: transparent;
- padding: 10rpx 0;
- text-align: center;
- }
- .bottom-price-row--readonly .price-label {
- margin: 0;
- }
- .bottom-price-row--readonly .price-unit {
- max-width: 100%;
- white-space: normal;
- text-align: center;
- }
- .price-label {
- flex-shrink: 0;
- font-size: 27rpx;
- color: var(--muted);
- }
- .price-readonly-value {
- font-size: 40rpx;
- font-weight: 600;
- color: var(--danger);
- }
- .price-input {
- flex: 1;
- min-width: 0;
- height: 72rpx;
- border-radius: 16rpx;
- background: #f8fcfa;
- border: 1rpx solid #e8f3ee;
- padding: 0 16rpx;
- font-size: 28rpx;
- color: var(--text);
- box-sizing: border-box;
- }
- .price-unit {
- flex-shrink: 0;
- max-width: 46%;
- font-size: 24rpx;
- color: var(--muted);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .save-btn {
- flex-shrink: 0;
- width: 200rpx;
- height: 72rpx;
- line-height: 72rpx;
- margin: 0;
- padding: 0;
- border-radius: 36rpx;
- background: linear-gradient(145deg, #0a9f6d, #11b37a);
- color: #fff;
- font-size: 26rpx;
- font-weight: 600;
- border: none;
- box-shadow: 0 8rpx 18rpx rgba(7, 193, 96, 0.28);
- &::after {
- border: none;
- }
- }
- </style>
|