detail.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  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 onBannerChange(event: any) {
  167. bannerActiveIndex.value = Number(event?.detail?.current || 0)
  168. }
  169. async function loadProductDetail(productId: string) {
  170. loading.value = true
  171. try {
  172. const path = userStore.isDeliverer
  173. ? `/mini/deliverer/products/${productId}`
  174. : `/mini/supplier/products/${productId}`
  175. const res = await request({
  176. url: path,
  177. method: 'GET'
  178. })
  179. const body = res.data as {
  180. code: number
  181. data?: {
  182. product?: any
  183. banners?: any[]
  184. parameterValues?: any[]
  185. detailMedia?: any[]
  186. }
  187. message?: string
  188. }
  189. if (body.code !== 200 || !body.data) {
  190. uni.showToast({ title: body.message || '加载失败', icon: 'none' })
  191. return
  192. }
  193. product.value = body.data.product || null
  194. bannerList.value = Array.isArray(body.data.banners) ? body.data.banners : []
  195. bannerActiveIndex.value = 0
  196. parameterValues.value = Array.isArray(body.data.parameterValues) ? body.data.parameterValues : []
  197. detailContent.value = product.value?.detailContent || ''
  198. detailMediaList.value = Array.isArray(body.data.detailMedia) ? body.data.detailMedia : []
  199. } finally {
  200. loading.value = false
  201. }
  202. }
  203. async function loadInquiryContext() {
  204. if (!inquiryId.value) return
  205. const res = await request({
  206. url: `/mini/supplier/inquiries/${inquiryId.value}`,
  207. method: 'GET'
  208. })
  209. const body = res.data as { code: number; data?: { items?: any[] } }
  210. if (body.code !== 200 || !body.data) return
  211. inquiryItems.value = Array.isArray(body.data.items) ? body.data.items : []
  212. const current = inquiryItems.value.find((row) => row.productId === currentProductId.value)
  213. priceInput.value = current?.lastPrice == null ? '' : Number(current.lastPrice).toFixed(2)
  214. }
  215. async function loadQuotationContext() {
  216. if (!quotationId.value) return
  217. const res = await request({
  218. url: `/mini/supplier/quotations/${quotationId.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. const list = Array.isArray(body.data.items) ? body.data.items : []
  224. const current = list.find((row) => row.productId === currentProductId.value)
  225. priceInput.value = current?.price == null ? '' : Number(current.price).toFixed(2)
  226. }
  227. async function saveSingleProductPrice() {
  228. if (!currentProductId.value) {
  229. uni.showToast({ title: '缺少商品信息', icon: 'none' })
  230. return
  231. }
  232. if (!priceInput.value) {
  233. uni.showToast({ title: '请填写价格', icon: 'none' })
  234. return
  235. }
  236. const currentPrice = Number(priceInput.value)
  237. if (Number.isNaN(currentPrice) || currentPrice < 0) {
  238. uni.showToast({ title: '价格格式不正确', icon: 'none' })
  239. return
  240. }
  241. savingPrice.value = true
  242. try {
  243. const tempPrice = Number(currentPrice.toFixed(2))
  244. const tempPriceMap = uni.getStorageSync(TEMP_PRICE_STORAGE_KEY) || {}
  245. const inquiryKey = inquiryId.value || '_default'
  246. tempPriceMap[inquiryKey] = {
  247. ...(tempPriceMap[inquiryKey] || {}),
  248. [currentProductId.value]: tempPrice
  249. }
  250. uni.setStorageSync(TEMP_PRICE_STORAGE_KEY, tempPriceMap)
  251. openerEventChannel?.emit('tempPriceSaved', {
  252. inquiryId: inquiryId.value,
  253. productId: currentProductId.value,
  254. price: tempPrice,
  255. message: '暂存成功'
  256. })
  257. const pages = getCurrentPages()
  258. if (pages.length > 1) {
  259. uni.navigateBack({ delta: 1 })
  260. } else {
  261. uni.redirectTo({ url: '/pages/supplier/inquiry/inquiry' })
  262. }
  263. } finally {
  264. savingPrice.value = false
  265. }
  266. }
  267. onLoad((options: any) => {
  268. currentProductId.value = options?.productId || ''
  269. inquiryId.value = options?.inquiryId || ''
  270. quotationId.value = options?.quotationId || ''
  271. viewOnlyMode.value = options?.viewOnly === '1'
  272. readonlyMode.value =
  273. !viewOnlyMode.value && (options?.readonly === '1' || !!quotationId.value)
  274. const pages = getCurrentPages()
  275. const currentPage = pages[pages.length - 1] as any
  276. openerEventChannel = currentPage?.getOpenerEventChannel?.() || null
  277. if (!currentProductId.value) {
  278. uni.showToast({ title: '缺少商品ID', icon: 'none' })
  279. loading.value = false
  280. return
  281. }
  282. loadProductDetail(currentProductId.value)
  283. if (viewOnlyMode.value) {
  284. return
  285. }
  286. if (readonlyMode.value) {
  287. loadQuotationContext()
  288. return
  289. }
  290. loadInquiryContext()
  291. })
  292. </script>
  293. <style lang="scss">
  294. /* 与供应商询价单页背景一致 */
  295. page {
  296. height: 100%;
  297. overflow: hidden;
  298. background-color: #f5f8f7;
  299. }
  300. </style>
  301. <style lang="scss" scoped>
  302. .product-detail-page {
  303. --mint-green: #0a9f6d;
  304. --mint-green-soft: #e9f9f2;
  305. --barley-yellow: #d6a343;
  306. --bg: #f5f8f7;
  307. --card: #ffffff;
  308. --text: #1f2a26;
  309. --muted: #6b7b74;
  310. --border: #dcebe4;
  311. --danger: #d14343;
  312. --shadow: 0 16rpx 40rpx rgba(10, 159, 109, 0.12);
  313. height: 100%;
  314. max-height: 100%;
  315. display: flex;
  316. flex-direction: column;
  317. background: var(--bg);
  318. box-sizing: border-box;
  319. overflow: hidden;
  320. color: var(--text);
  321. }
  322. .detail-body {
  323. flex: 1;
  324. display: flex;
  325. flex-direction: column;
  326. min-height: 0;
  327. overflow: hidden;
  328. }
  329. .detail-header {
  330. flex-shrink: 0;
  331. height: 0;
  332. }
  333. .detail-scroll {
  334. flex: 1;
  335. min-height: 0;
  336. height: 0;
  337. background: var(--bg);
  338. }
  339. .detail-scroll-inner {
  340. padding: 0 32rpx calc(210rpx + env(safe-area-inset-bottom));
  341. }
  342. .detail-scroll-inner--view-only {
  343. padding-bottom: calc(36rpx + env(safe-area-inset-bottom));
  344. }
  345. .hero-stack {
  346. margin-top: 24rpx;
  347. }
  348. .loading, .empty {
  349. padding: 56rpx 40rpx;
  350. text-align: center;
  351. color: var(--muted);
  352. font-size: 26rpx;
  353. }
  354. .banner-card {
  355. background: var(--card);
  356. border: 1rpx solid var(--border);
  357. border-radius: 28rpx;
  358. overflow: hidden;
  359. position: relative;
  360. }
  361. .banner-swiper {
  362. width: 100%;
  363. aspect-ratio: 4 / 3;
  364. height: auto;
  365. background: linear-gradient(145deg, #f4fbf8, #dff2e9);
  366. .banner-image, .banner-video {
  367. width: 100%;
  368. height: 100%;
  369. }
  370. }
  371. .banner-empty {
  372. width: 100%;
  373. aspect-ratio: 4 / 3;
  374. height: auto;
  375. background: linear-gradient(145deg, #f4fbf8, #dff2e9);
  376. display: flex;
  377. align-items: center;
  378. justify-content: center;
  379. }
  380. .banner-dots {
  381. position: absolute;
  382. left: 50%;
  383. transform: translateX(-50%);
  384. bottom: 18rpx;
  385. display: flex;
  386. gap: 10rpx;
  387. z-index: 8;
  388. }
  389. .banner-dot {
  390. width: 18rpx;
  391. height: 8rpx;
  392. border-radius: 999rpx;
  393. background: rgba(255, 255, 255, 0.45);
  394. transition: all 0.2s ease;
  395. }
  396. .banner-dot--active {
  397. width: 30rpx;
  398. background: #ffffff;
  399. }
  400. .banner-empty-text {
  401. font-size: 28rpx;
  402. color: var(--muted);
  403. }
  404. .product-info {
  405. margin-top: 24rpx;
  406. padding: 24rpx;
  407. .name {
  408. display: block;
  409. margin-top: 4rpx;
  410. font-size: 32rpx;
  411. color: var(--text);
  412. font-weight: 600;
  413. line-height: 1.4;
  414. }
  415. .spec {
  416. display: block;
  417. margin-top: 12rpx;
  418. font-size: 24rpx;
  419. color: var(--muted);
  420. }
  421. }
  422. .section-title {
  423. padding: 18rpx 0 20rpx;
  424. margin: 0 24rpx;
  425. font-size: 30rpx;
  426. color: var(--text);
  427. font-weight: 600;
  428. background: transparent;
  429. border-bottom: 1rpx solid #edf5f1;
  430. }
  431. .section-card {
  432. margin-top: 24rpx;
  433. background: var(--card);
  434. border: 1rpx solid var(--border);
  435. border-radius: 28rpx;
  436. overflow: hidden;
  437. }
  438. .detail-section {
  439. .rich-content {
  440. margin: 0 24rpx 24rpx;
  441. padding: 20rpx;
  442. border: 1rpx solid #e8f3ee;
  443. background: #f8fcfa;
  444. border-radius: 20rpx;
  445. font-size: 26rpx;
  446. line-height: 1.6;
  447. color: #33423c;
  448. }
  449. .detail-media-list {
  450. padding-bottom: 8rpx;
  451. }
  452. .detail-media-item {
  453. border-top: 1rpx solid #edf5f1;
  454. }
  455. .detail-media-img {
  456. width: 100%;
  457. display: block;
  458. }
  459. .detail-media-video {
  460. width: 100%;
  461. height: 420rpx;
  462. display: block;
  463. background: #000;
  464. }
  465. .detail-media-desc {
  466. display: block;
  467. padding: 16rpx 24rpx 24rpx;
  468. font-size: 24rpx;
  469. color: var(--muted);
  470. line-height: 1.5;
  471. }
  472. }
  473. .params-section {
  474. .params-list {
  475. padding: 0 24rpx 24rpx;
  476. }
  477. .param-item {
  478. display: grid;
  479. grid-template-columns: 180rpx 1fr;
  480. gap: 12rpx;
  481. padding: 16rpx 0;
  482. border-bottom: 1rpx solid #edf5f1;
  483. &:last-child {
  484. border-bottom: none;
  485. }
  486. }
  487. .param-name {
  488. width: 180rpx;
  489. font-size: 26rpx;
  490. color: var(--muted);
  491. }
  492. .param-value {
  493. flex: 1;
  494. font-size: 26rpx;
  495. color: var(--text);
  496. }
  497. }
  498. .bottom-bar {
  499. position: fixed;
  500. left: 0;
  501. right: 0;
  502. bottom: 0;
  503. z-index: 9999;
  504. background: var(--card);
  505. border-top: 1rpx solid var(--border);
  506. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.04);
  507. padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
  508. box-sizing: border-box;
  509. }
  510. .bottom-bar--readonly {
  511. background: var(--card);
  512. }
  513. .bottom-bar-inner {
  514. width: 100%;
  515. max-width: 750rpx;
  516. margin: 0 auto;
  517. }
  518. /* 与询价单列表「报价」行视觉一致 */
  519. .bottom-price-row {
  520. display: flex;
  521. flex-direction: row;
  522. align-items: center;
  523. width: 100%;
  524. gap: 10rpx;
  525. box-sizing: border-box;
  526. padding: 0;
  527. background: transparent;
  528. border: none;
  529. border-radius: 0;
  530. }
  531. .bottom-price-row--readonly {
  532. flex-wrap: wrap;
  533. justify-content: center;
  534. align-items: center;
  535. gap: 8rpx 12rpx;
  536. background: transparent;
  537. padding: 10rpx 0;
  538. text-align: center;
  539. }
  540. .bottom-price-row--readonly .price-label {
  541. margin: 0;
  542. }
  543. .bottom-price-row--readonly .price-unit {
  544. max-width: 100%;
  545. white-space: normal;
  546. text-align: center;
  547. }
  548. .price-label {
  549. flex-shrink: 0;
  550. font-size: 27rpx;
  551. color: var(--muted);
  552. }
  553. .price-readonly-value {
  554. font-size: 40rpx;
  555. font-weight: 600;
  556. color: var(--danger);
  557. }
  558. .price-input {
  559. flex: 1;
  560. min-width: 0;
  561. height: 72rpx;
  562. border-radius: 16rpx;
  563. background: #f8fcfa;
  564. border: 1rpx solid #e8f3ee;
  565. padding: 0 16rpx;
  566. font-size: 28rpx;
  567. color: var(--text);
  568. box-sizing: border-box;
  569. }
  570. .price-unit {
  571. flex-shrink: 0;
  572. max-width: 46%;
  573. font-size: 24rpx;
  574. color: var(--muted);
  575. overflow: hidden;
  576. text-overflow: ellipsis;
  577. white-space: nowrap;
  578. }
  579. .save-btn {
  580. flex-shrink: 0;
  581. width: 200rpx;
  582. height: 72rpx;
  583. line-height: 72rpx;
  584. margin: 0;
  585. padding: 0;
  586. border-radius: 36rpx;
  587. background: linear-gradient(145deg, #0a9f6d, #11b37a);
  588. color: #fff;
  589. font-size: 26rpx;
  590. font-weight: 600;
  591. border: none;
  592. box-shadow: 0 8rpx 18rpx rgba(7, 193, 96, 0.28);
  593. &::after {
  594. border: none;
  595. }
  596. }
  597. </style>