detail.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. <template>
  2. <view class="product-detail-page">
  3. <view v-if="loading" class="loading">
  4. <ClientSkeleton variant="detail" :rows="2" />
  5. </view>
  6. <view v-else-if="!product" class="empty">商品不存在</view>
  7. <view v-else class="detail-body">
  8. <view class="detail-header"></view>
  9. <scroll-view class="detail-scroll" scroll-y :enable-back-to-top="true" :show-scrollbar="false">
  10. <view class="detail-scroll-inner" :class="{ 'detail-scroll-inner--view-only': viewOnlyMode }">
  11. <view class="hero-stack">
  12. <view class="banner-card">
  13. <swiper
  14. v-if="bannerList.length > 0"
  15. class="banner-swiper"
  16. :indicator-dots="false"
  17. :autoplay="true"
  18. :interval="3000"
  19. :circular="true"
  20. @change="onBannerChange"
  21. >
  22. <swiper-item v-for="(banner, index) in bannerList" :key="index">
  23. <ClientImageLoader
  24. v-if="banner.mediaType === 'image'"
  25. :src="banner.fileUrl"
  26. mode="aspectFill"
  27. class="banner-image"
  28. radius="20rpx"
  29. />
  30. <video v-else :src="banner.fileUrl" controls class="banner-video" />
  31. </swiper-item>
  32. </swiper>
  33. <view v-if="bannerList.length > 1" class="banner-dots">
  34. <view
  35. v-for="(_, idx) in bannerList"
  36. :key="`dot-${idx}`"
  37. class="banner-dot"
  38. :class="{ 'banner-dot--active': idx === bannerActiveIndex }"
  39. />
  40. </view>
  41. <view v-else class="banner-empty">
  42. <text class="banner-empty-text">暂无轮播图</text>
  43. </view>
  44. </view>
  45. <view class="product-info section-card">
  46. <text class="name">{{ product.name }}</text>
  47. <text class="spec">{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
  48. </view>
  49. </view>
  50. <view class="params-section section-card" v-if="parameterValues.length > 0">
  51. <view class="section-title">商品参数</view>
  52. <view class="params-list">
  53. <view v-for="param in parameterValues" :key="param.parameterId" class="param-item">
  54. <text class="param-name">{{ param.parameterName }}</text>
  55. <text class="param-value">{{ param.paramValue }}</text>
  56. </view>
  57. </view>
  58. </view>
  59. <view class="detail-section section-card" v-if="detailContent || detailMediaList.length > 0">
  60. <view class="section-title">商品详情</view>
  61. <view v-if="detailContent" class="rich-content">
  62. <rich-text :nodes="detailContent"></rich-text>
  63. </view>
  64. <view v-if="detailMediaList.length > 0" class="detail-media-list">
  65. <view v-for="(m, idx) in detailMediaList" :key="m.id || `dm-${idx}`" class="detail-media-item">
  66. <ClientImageLoader
  67. v-if="m.contentType === 'image'"
  68. :src="m.fileUrl"
  69. mode="widthFix"
  70. class="detail-media-img"
  71. radius="20rpx"
  72. />
  73. <video v-else :src="m.fileUrl" :poster="ossImg(m.thumbnailUrl || '')" controls class="detail-media-video" />
  74. <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
  75. </view>
  76. </view>
  77. </view>
  78. </view>
  79. </scroll-view>
  80. </view>
  81. <view v-if="product && !viewOnlyMode" class="bottom-bar" :class="{ 'bottom-bar--readonly': readonlyMode }">
  82. <view class="bottom-bar-inner">
  83. <view v-if="readonlyMode" class="bottom-price-row bottom-price-row--readonly">
  84. <text class="price-label">报价</text>
  85. <text class="price-readonly-value">{{ priceInput || '0.00' }}</text>
  86. <text class="price-unit">元/{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
  87. </view>
  88. <view v-else class="bottom-price-row">
  89. <text class="price-label">报价</text>
  90. <input
  91. class="price-input"
  92. type="text"
  93. :value="priceInput"
  94. placeholder="请输入价格"
  95. @input="onPriceInputEvent"
  96. @blur="onPriceBlur"
  97. />
  98. <text class="price-unit">元/{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
  99. <button class="save-btn" :disabled="savingPrice" :loading="savingPrice" @click="saveSingleProductPrice">
  100. 暂存价格
  101. </button>
  102. </view>
  103. </view>
  104. </view>
  105. </view>
  106. </template>
  107. <script setup lang="ts">
  108. import { ref } from 'vue'
  109. import { onLoad } from '@dcloudio/uni-app'
  110. import { request } from '@/utils/request'
  111. import { useUserStore } from '@/stores/user'
  112. import ClientSkeleton from '@/components/client/client-skeleton.vue'
  113. import ClientImageLoader from '@/components/client/client-image-loader.vue'
  114. import { withOssVersion } from '@/utils/ossCache'
  115. const userStore = useUserStore()
  116. // 进入页面即展示骨架,避免首帧短暂出现空态
  117. const loading = ref(true)
  118. const savingPrice = ref(false)
  119. const product = ref<any>(null)
  120. const bannerList = ref<any[]>([])
  121. const parameterValues = ref<any[]>([])
  122. const detailContent = ref('')
  123. const detailMediaList = ref<any[]>([])
  124. const priceInput = ref('')
  125. const inquiryId = ref('')
  126. const quotationId = ref('')
  127. const readonlyMode = ref(false)
  128. /** 仅浏览商品详情(备货任务等入口),不展示底部报价栏 */
  129. const viewOnlyMode = ref(false)
  130. const inquiryItems = ref<any[]>([])
  131. const currentProductId = ref('')
  132. const bannerActiveIndex = ref(0)
  133. let openerEventChannel: any = null
  134. const TEMP_PRICE_STORAGE_KEY = 'supplier_inquiry_temp_prices'
  135. const ossImg = withOssVersion
  136. function formatMeasureText(measureValue?: number | null, unitName?: string | null) {
  137. const unitText = (unitName || '').trim()
  138. if (measureValue == null) {
  139. return unitText ? `- ${unitText}` : '-'
  140. }
  141. const measureText = String(measureValue).replace(/\.?0+$/, '')
  142. return unitText ? `${measureText} ${unitText}` : measureText || '-'
  143. }
  144. function normalizePriceInput(value: string) {
  145. const cleaned = value.replace(/[^\d.]/g, '')
  146. const parts = cleaned.split('.')
  147. if (parts.length <= 1) return cleaned
  148. return `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}`
  149. }
  150. function formatPriceToFixed2(value: string) {
  151. if (!value) return ''
  152. const num = Number(value)
  153. if (Number.isNaN(num)) return ''
  154. return num.toFixed(2)
  155. }
  156. function onPriceInput(value: string) {
  157. priceInput.value = normalizePriceInput(value)
  158. }
  159. function onPriceInputEvent(event: any) {
  160. const value = event?.detail?.value == null ? '' : String(event.detail.value)
  161. onPriceInput(value)
  162. }
  163. function onPriceBlur() {
  164. priceInput.value = formatPriceToFixed2(priceInput.value)
  165. }
  166. function applyTempPriceForCurrentProduct() {
  167. if (!currentProductId.value) return
  168. const inquiryKey = inquiryId.value || '_default'
  169. const tempPriceMap = uni.getStorageSync(TEMP_PRICE_STORAGE_KEY) || {}
  170. const inquiryTempMap = tempPriceMap[inquiryKey]
  171. if (!inquiryTempMap || typeof inquiryTempMap !== 'object') return
  172. const temp = inquiryTempMap[currentProductId.value]
  173. if (temp == null || temp === '') return
  174. const num = Number(temp)
  175. if (Number.isNaN(num) || num < 0) return
  176. priceInput.value = num.toFixed(2)
  177. }
  178. function onBannerChange(event: any) {
  179. bannerActiveIndex.value = Number(event?.detail?.current || 0)
  180. }
  181. async function loadProductDetail(productId: string) {
  182. loading.value = true
  183. try {
  184. const path = userStore.isDeliverer
  185. ? `/mini/deliverer/products/${productId}`
  186. : `/mini/supplier/products/${productId}`
  187. const res = await request({
  188. url: path,
  189. method: 'GET'
  190. })
  191. const body = res.data as {
  192. code: number
  193. data?: {
  194. product?: any
  195. banners?: any[]
  196. parameterValues?: any[]
  197. detailMedia?: any[]
  198. }
  199. message?: string
  200. }
  201. if (body.code !== 200 || !body.data) {
  202. uni.showToast({ title: body.message || '加载失败', icon: 'none' })
  203. return
  204. }
  205. product.value = body.data.product || null
  206. bannerList.value = Array.isArray(body.data.banners) ? body.data.banners : []
  207. bannerActiveIndex.value = 0
  208. parameterValues.value = Array.isArray(body.data.parameterValues) ? body.data.parameterValues : []
  209. detailContent.value = product.value?.detailContent || ''
  210. detailMediaList.value = Array.isArray(body.data.detailMedia) ? body.data.detailMedia : []
  211. } finally {
  212. loading.value = false
  213. }
  214. }
  215. async function loadInquiryContext() {
  216. if (!inquiryId.value) return
  217. const res = await request({
  218. url: `/mini/supplier/inquiries/${inquiryId.value}`,
  219. method: 'GET'
  220. })
  221. const body = res.data as { code: number; data?: { items?: any[] } }
  222. if (body.code !== 200 || !body.data) return
  223. inquiryItems.value = Array.isArray(body.data.items) ? body.data.items : []
  224. const current = inquiryItems.value.find((row) => row.productId === currentProductId.value)
  225. priceInput.value = current?.lastPrice == null ? '' : Number(current.lastPrice).toFixed(2)
  226. applyTempPriceForCurrentProduct()
  227. }
  228. async function loadQuotationContext() {
  229. if (!quotationId.value) return
  230. const res = await request({
  231. url: `/mini/supplier/quotations/${quotationId.value}`,
  232. method: 'GET'
  233. })
  234. const body = res.data as { code: number; data?: { items?: any[] } }
  235. if (body.code !== 200 || !body.data) return
  236. const list = Array.isArray(body.data.items) ? body.data.items : []
  237. const current = list.find((row) => row.productId === currentProductId.value)
  238. priceInput.value = current?.price == null ? '' : Number(current.price).toFixed(2)
  239. }
  240. async function saveSingleProductPrice() {
  241. if (!currentProductId.value) {
  242. uni.showToast({ title: '缺少商品信息', icon: 'none' })
  243. return
  244. }
  245. if (!priceInput.value) {
  246. uni.showToast({ title: '请填写价格', icon: 'none' })
  247. return
  248. }
  249. const currentPrice = Number(priceInput.value)
  250. if (Number.isNaN(currentPrice) || currentPrice < 0) {
  251. uni.showToast({ title: '价格格式不正确', icon: 'none' })
  252. return
  253. }
  254. savingPrice.value = true
  255. try {
  256. const tempPrice = Number(currentPrice.toFixed(2))
  257. const tempPriceMap = uni.getStorageSync(TEMP_PRICE_STORAGE_KEY) || {}
  258. const inquiryKey = inquiryId.value || '_default'
  259. tempPriceMap[inquiryKey] = {
  260. ...(tempPriceMap[inquiryKey] || {}),
  261. [currentProductId.value]: tempPrice
  262. }
  263. uni.setStorageSync(TEMP_PRICE_STORAGE_KEY, tempPriceMap)
  264. openerEventChannel?.emit('tempPriceSaved', {
  265. inquiryId: inquiryId.value,
  266. productId: currentProductId.value,
  267. price: tempPrice,
  268. message: '暂存成功'
  269. })
  270. const pages = getCurrentPages()
  271. if (pages.length > 1) {
  272. uni.navigateBack({ delta: 1 })
  273. } else {
  274. uni.redirectTo({ url: '/pages/supplier/inquiry/inquiry' })
  275. }
  276. } finally {
  277. savingPrice.value = false
  278. }
  279. }
  280. onLoad((options: any) => {
  281. currentProductId.value = options?.productId || ''
  282. inquiryId.value = options?.inquiryId || ''
  283. quotationId.value = options?.quotationId || ''
  284. viewOnlyMode.value = options?.viewOnly === '1'
  285. readonlyMode.value =
  286. !viewOnlyMode.value && (options?.readonly === '1' || !!quotationId.value)
  287. const pages = getCurrentPages()
  288. const currentPage = pages[pages.length - 1] as any
  289. openerEventChannel = currentPage?.getOpenerEventChannel?.() || null
  290. if (!currentProductId.value) {
  291. uni.showToast({ title: '缺少商品ID', icon: 'none' })
  292. loading.value = false
  293. return
  294. }
  295. loadProductDetail(currentProductId.value)
  296. if (viewOnlyMode.value) {
  297. return
  298. }
  299. if (readonlyMode.value) {
  300. loadQuotationContext()
  301. return
  302. }
  303. loadInquiryContext()
  304. })
  305. </script>
  306. <style lang="scss">
  307. /* 与供应商询价单页背景一致 */
  308. page {
  309. height: 100%;
  310. overflow: hidden;
  311. background-color: #f5f8f7;
  312. }
  313. </style>
  314. <style lang="scss" scoped>
  315. .product-detail-page {
  316. --mint-green: #0a9f6d;
  317. --mint-green-soft: #e9f9f2;
  318. --barley-yellow: #d6a343;
  319. --bg: #f5f8f7;
  320. --card: #ffffff;
  321. --text: #1f2a26;
  322. --muted: #6b7b74;
  323. --border: #dcebe4;
  324. --danger: #d14343;
  325. --shadow: 0 16rpx 40rpx rgba(10, 159, 109, 0.12);
  326. height: 100%;
  327. max-height: 100%;
  328. display: flex;
  329. flex-direction: column;
  330. background: var(--bg);
  331. box-sizing: border-box;
  332. overflow: hidden;
  333. color: var(--text);
  334. }
  335. .detail-body {
  336. flex: 1;
  337. display: flex;
  338. flex-direction: column;
  339. min-height: 0;
  340. overflow: hidden;
  341. }
  342. .detail-header {
  343. flex-shrink: 0;
  344. height: 0;
  345. }
  346. .detail-scroll {
  347. flex: 1;
  348. min-height: 0;
  349. height: 0;
  350. background: var(--bg);
  351. }
  352. .detail-scroll-inner {
  353. padding: 0 32rpx calc(210rpx + env(safe-area-inset-bottom));
  354. }
  355. .detail-scroll-inner--view-only {
  356. padding-bottom: calc(36rpx + env(safe-area-inset-bottom));
  357. }
  358. .hero-stack {
  359. margin-top: 24rpx;
  360. }
  361. .loading, .empty {
  362. padding: 56rpx 40rpx;
  363. text-align: center;
  364. color: var(--muted);
  365. font-size: 26rpx;
  366. }
  367. .banner-card {
  368. background: var(--card);
  369. border: 1rpx solid var(--border);
  370. border-radius: 28rpx;
  371. overflow: hidden;
  372. position: relative;
  373. }
  374. .banner-swiper {
  375. width: 100%;
  376. aspect-ratio: 4 / 3;
  377. height: auto;
  378. background: linear-gradient(145deg, #f4fbf8, #dff2e9);
  379. .banner-image, .banner-video {
  380. width: 100%;
  381. height: 100%;
  382. }
  383. }
  384. .banner-empty {
  385. width: 100%;
  386. aspect-ratio: 4 / 3;
  387. height: auto;
  388. background: linear-gradient(145deg, #f4fbf8, #dff2e9);
  389. display: flex;
  390. align-items: center;
  391. justify-content: center;
  392. }
  393. .banner-dots {
  394. position: absolute;
  395. left: 50%;
  396. transform: translateX(-50%);
  397. bottom: 18rpx;
  398. display: flex;
  399. gap: 10rpx;
  400. z-index: 8;
  401. }
  402. .banner-dot {
  403. width: 18rpx;
  404. height: 8rpx;
  405. border-radius: 999rpx;
  406. background: rgba(255, 255, 255, 0.45);
  407. transition: all 0.2s ease;
  408. }
  409. .banner-dot--active {
  410. width: 30rpx;
  411. background: #ffffff;
  412. }
  413. .banner-empty-text {
  414. font-size: 28rpx;
  415. color: var(--muted);
  416. }
  417. .product-info {
  418. margin-top: 24rpx;
  419. padding: 24rpx;
  420. .name {
  421. display: block;
  422. margin-top: 4rpx;
  423. font-size: 32rpx;
  424. color: var(--text);
  425. font-weight: 600;
  426. line-height: 1.4;
  427. }
  428. .spec {
  429. display: block;
  430. margin-top: 12rpx;
  431. font-size: 24rpx;
  432. color: var(--muted);
  433. }
  434. }
  435. .section-title {
  436. padding: 18rpx 0 20rpx;
  437. margin: 0 24rpx;
  438. font-size: 30rpx;
  439. color: var(--text);
  440. font-weight: 600;
  441. background: transparent;
  442. border-bottom: 1rpx solid #edf5f1;
  443. }
  444. .section-card {
  445. margin-top: 24rpx;
  446. background: var(--card);
  447. border: 1rpx solid var(--border);
  448. border-radius: 28rpx;
  449. overflow: hidden;
  450. }
  451. .detail-section {
  452. .rich-content {
  453. margin: 0 24rpx 24rpx;
  454. padding: 20rpx;
  455. border: 1rpx solid #e8f3ee;
  456. background: #f8fcfa;
  457. border-radius: 20rpx;
  458. font-size: 26rpx;
  459. line-height: 1.6;
  460. color: #33423c;
  461. }
  462. .detail-media-list {
  463. padding-bottom: 8rpx;
  464. }
  465. .detail-media-item {
  466. border-top: 1rpx solid #edf5f1;
  467. }
  468. .detail-media-img {
  469. width: 100%;
  470. display: block;
  471. }
  472. .detail-media-video {
  473. width: 100%;
  474. height: 420rpx;
  475. display: block;
  476. background: #000;
  477. }
  478. .detail-media-desc {
  479. display: block;
  480. padding: 16rpx 24rpx 24rpx;
  481. font-size: 24rpx;
  482. color: var(--muted);
  483. line-height: 1.5;
  484. }
  485. }
  486. .params-section {
  487. .params-list {
  488. padding: 0 24rpx 24rpx;
  489. }
  490. .param-item {
  491. display: grid;
  492. grid-template-columns: 180rpx 1fr;
  493. gap: 12rpx;
  494. padding: 16rpx 0;
  495. border-bottom: 1rpx solid #edf5f1;
  496. &:last-child {
  497. border-bottom: none;
  498. }
  499. }
  500. .param-name {
  501. width: 180rpx;
  502. font-size: 26rpx;
  503. color: var(--muted);
  504. }
  505. .param-value {
  506. flex: 1;
  507. font-size: 26rpx;
  508. color: var(--text);
  509. }
  510. }
  511. .bottom-bar {
  512. position: fixed;
  513. left: 0;
  514. right: 0;
  515. bottom: 0;
  516. z-index: 9999;
  517. background: var(--card);
  518. border-top: 1rpx solid var(--border);
  519. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.04);
  520. padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
  521. box-sizing: border-box;
  522. }
  523. .bottom-bar--readonly {
  524. background: var(--card);
  525. }
  526. .bottom-bar-inner {
  527. width: 100%;
  528. max-width: 750rpx;
  529. margin: 0 auto;
  530. }
  531. /* 与询价单列表「报价」行视觉一致 */
  532. .bottom-price-row {
  533. display: flex;
  534. flex-direction: row;
  535. align-items: center;
  536. width: 100%;
  537. gap: 10rpx;
  538. box-sizing: border-box;
  539. padding: 0;
  540. background: transparent;
  541. border: none;
  542. border-radius: 0;
  543. }
  544. .bottom-price-row--readonly {
  545. flex-wrap: wrap;
  546. justify-content: center;
  547. align-items: center;
  548. gap: 8rpx 12rpx;
  549. background: transparent;
  550. padding: 10rpx 0;
  551. text-align: center;
  552. }
  553. .bottom-price-row--readonly .price-label {
  554. margin: 0;
  555. }
  556. .bottom-price-row--readonly .price-unit {
  557. max-width: 100%;
  558. white-space: normal;
  559. text-align: center;
  560. }
  561. .price-label {
  562. flex-shrink: 0;
  563. font-size: 27rpx;
  564. color: var(--muted);
  565. }
  566. .price-readonly-value {
  567. font-size: 40rpx;
  568. font-weight: 600;
  569. color: var(--danger);
  570. }
  571. .price-input {
  572. flex: 1;
  573. min-width: 0;
  574. height: 72rpx;
  575. border-radius: 16rpx;
  576. background: #f8fcfa;
  577. border: 1rpx solid #e8f3ee;
  578. padding: 0 16rpx;
  579. font-size: 28rpx;
  580. color: var(--text);
  581. box-sizing: border-box;
  582. }
  583. .price-unit {
  584. flex-shrink: 0;
  585. max-width: 46%;
  586. font-size: 24rpx;
  587. color: var(--muted);
  588. overflow: hidden;
  589. text-overflow: ellipsis;
  590. white-space: nowrap;
  591. }
  592. .save-btn {
  593. flex-shrink: 0;
  594. width: 200rpx;
  595. height: 72rpx;
  596. line-height: 72rpx;
  597. margin: 0;
  598. padding: 0;
  599. border-radius: 36rpx;
  600. background: linear-gradient(145deg, #0a9f6d, #11b37a);
  601. color: #fff;
  602. font-size: 26rpx;
  603. font-weight: 600;
  604. border: none;
  605. box-shadow: 0 8rpx 18rpx rgba(7, 193, 96, 0.28);
  606. &::after {
  607. border: none;
  608. }
  609. }
  610. </style>