home.vue 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253
  1. <template>
  2. <!-- 购物车条勿放在 inquiry-page 内:父级 overflow:hidden 会裁切角标;且 bottom 须与 tabBar 对齐 -->
  3. <view class="home-root client-theme">
  4. <view class="inquiry-page">
  5. <view v-if="isLoading" class="state">
  6. <ClientSkeleton variant="hero-list" :rows="3" />
  7. </view>
  8. <view v-else class="content-wrap">
  9. <!-- 顶栏:仅渐变背景 + 配送行 + 搜索条(与 sanqiang-client-ui-demo.html .top 一致) -->
  10. <view class="filter-top-wrap sq-hero-header">
  11. <view class="hero-top-main">
  12. <view class="hero-top-text">
  13. <text class="hero-brand">叁强鲜配</text>
  14. <text class="hero-region-line">{{ deliveryLineLeft }}</text>
  15. </view>
  16. <ClientImageLoader
  17. class="hero-top-logo"
  18. :src="IMG_LOGO_WATERMARK"
  19. mode="aspectFit"
  20. />
  21. </view>
  22. <view class="search-bar">
  23. <input
  24. v-model="keyword"
  25. class="search-input"
  26. type="text"
  27. placeholder="请输入商品名称搜索"
  28. placeholder-class="search-placeholder"
  29. confirm-type="search"
  30. @confirm="onSearchClick"
  31. />
  32. <text class="search-action" @click="onSearchClick">搜索</text>
  33. </view>
  34. </view>
  35. <!-- 一级分类及以下在灰底区域,独立白卡片(与 demo .section + .first-level 一致) -->
  36. <view class="category-section">
  37. <view class="section-head">
  38. <text class="section-title">商品分类</text>
  39. </view>
  40. <view class="first-category-wrap">
  41. <swiper
  42. class="first-category-swiper"
  43. :current="firstCategorySwiperIndex"
  44. :circular="false"
  45. :indicator-dots="false"
  46. @change="onFirstCategorySwiperChange"
  47. >
  48. <swiper-item v-for="(page, pageIdx) in firstCategoryPages" :key="`category-page-${pageIdx}`">
  49. <view class="first-category-grid">
  50. <view
  51. v-for="cat in page"
  52. :key="cat.id"
  53. class="first-category-card"
  54. :class="{ active: selectedFirstCategoryId === cat.id }"
  55. hover-class="first-category-card--hover"
  56. :hover-stay-time="120"
  57. @click="handleFirstCategoryClick(cat.id)"
  58. >
  59. <ClientImageLoader
  60. class="first-category-image"
  61. :src="cat.imageUrl || DEFAULT_PLACEHOLDER_IMAGE_URL"
  62. mode="aspectFill"
  63. radius="24rpx"
  64. />
  65. <text class="first-category-name">{{ cat.name }}</text>
  66. </view>
  67. </view>
  68. </swiper-item>
  69. </swiper>
  70. </view>
  71. <view v-if="firstCategoryPages.length > 0" class="first-level-indicator">
  72. <view
  73. v-for="(_, idx) in firstCategoryPages"
  74. :key="`dot-${idx}`"
  75. class="indicator-dot"
  76. :class="{ active: firstCategorySwiperIndex === idx }"
  77. @click="goFirstCategorySwiperPage(idx)"
  78. />
  79. </view>
  80. </view>
  81. <view class="product-layout">
  82. <view class="product-body">
  83. <scroll-view
  84. scroll-y
  85. class="category-pane"
  86. :show-scrollbar="true"
  87. :lower-threshold="60"
  88. @scrolltolower="onCategoryScrollToLower"
  89. >
  90. <view class="category-scroll-inner">
  91. <view
  92. v-for="cat in displaySidebarRows"
  93. :key="cat.id"
  94. class="category-item"
  95. :class="{ active: selectedSecondCategoryId === cat.id }"
  96. hover-class="category-item--hover"
  97. :hover-stay-time="120"
  98. @click="selectedSecondCategoryId = cat.id"
  99. >
  100. {{ cat.name }}
  101. </view>
  102. <view v-if="sidebarLoading && sidebarRows.length === 0" class="pane-footer-tip">加载中...</view>
  103. <view v-else-if="sidebarHasMore" class="pane-footer-tip">上拉加载更多分类</view>
  104. </view>
  105. </scroll-view>
  106. <view class="goods-pane">
  107. <scroll-view
  108. scroll-y
  109. class="goods-scroll"
  110. :show-scrollbar="true"
  111. :lower-threshold="80"
  112. @scrolltolower="onGoodsScrollToLower"
  113. >
  114. <view class="goods-scroll-inner">
  115. <view
  116. v-for="product in products"
  117. :key="product.id"
  118. class="goods-card"
  119. hover-class="goods-card--pressed"
  120. :hover-stay-time="120"
  121. @click="goProductView(product.id)"
  122. >
  123. <ClientImageLoader
  124. class="goods-image"
  125. :src="product.imageUrl || IMG_DEFAULT_PRODUCT"
  126. mode="aspectFill"
  127. radius="16rpx"
  128. />
  129. <view class="goods-main">
  130. <text class="goods-name">{{ product.name || '-' }}</text>
  131. <text class="goods-spec">{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
  132. <view class="bottom-row">
  133. <text class="goods-price">¥{{ formatPrice(product.price || 0) }}</text>
  134. <view class="bottom-row-actions">
  135. <view v-if="getCartQuantity(product.id) === 0" class="add-btn" @click.stop="addToCart(product)">
  136. <text class="add-btn-plus">+</text>
  137. </view>
  138. <view v-else class="quantity-control" @click.stop>
  139. <view class="qty-btn" @click.stop="updateQuantity(product.id, -1)">
  140. <text>-</text>
  141. </view>
  142. <text class="qty-num">{{ getCartQuantity(product.id) }}</text>
  143. <view class="qty-btn" @click.stop="updateQuantity(product.id, 1)">
  144. <text>+</text>
  145. </view>
  146. </view>
  147. </view>
  148. </view>
  149. </view>
  150. </view>
  151. <view v-if="products.length === 0 && !productsLoadingMore" class="empty-list-tip">暂无匹配商品</view>
  152. <view v-if="productsLoadingMore" class="pane-footer-tip">加载中...</view>
  153. <view v-else-if="products.length > 0 && !productHasMore" class="pane-footer-tip">没有更多了</view>
  154. </view>
  155. </scroll-view>
  156. </view>
  157. </view>
  158. </view>
  159. </view>
  160. </view>
  161. <view class="cart-bar" @click="goToCart">
  162. <view class="cart-inner">
  163. <view class="cart-left">
  164. <view class="cart-count">
  165. <text>{{ cartStore.totalCount > 99 ? '99+' : cartStore.totalCount }}</text>
  166. </view>
  167. <view class="cart-text-col">
  168. <text class="cart-items-label">已选商品</text>
  169. <text class="cart-total-money">¥{{ formatPrice(cartStore.totalAmount) }}</text>
  170. </view>
  171. </view>
  172. <view class="cart-settle">
  173. <text>去结算</text>
  174. </view>
  175. </view>
  176. </view>
  177. <ProductDetailOverlay
  178. :show="showProductOverlay"
  179. :product-id="overlayProductId"
  180. @close="closeProductOverlay"
  181. />
  182. <!-- #ifdef H5 -->
  183. <ClientTabBar active="home" />
  184. <!-- #endif -->
  185. </view>
  186. </template>
  187. <script setup>
  188. import { ref, computed, onMounted, watch } from 'vue'
  189. import { onShow } from '@dcloudio/uni-app'
  190. import { useUserStore } from '@/stores/user'
  191. import { useCartStore } from '@/stores/cart'
  192. import { TOKEN_KEY } from '@/config/api'
  193. import { request, parseResponseJson } from '@/utils/request'
  194. import { formatYuan } from '@/utils/format'
  195. import ProductDetailOverlay from '@/components/client/product-detail-overlay.vue'
  196. import ClientTabBar from '@/components/client/client-tab-bar.vue'
  197. import ClientSkeleton from '@/components/client/client-skeleton.vue'
  198. import ClientImageLoader from '@/components/client/client-image-loader.vue'
  199. import { DEFAULT_PLACEHOLDER_IMAGE_URL, IMG_DEFAULT_PRODUCT, IMG_LOGO_WATERMARK } from '@/utils/staticAsset'
  200. // Store
  201. const userStore = useUserStore()
  202. const cartStore = useCartStore()
  203. const SIDEBAR_PAGE_SIZE = 30
  204. const ROOT_CATEGORY_PAGE_SIZE = 20
  205. const PRODUCT_PAGE_SIZE = 20
  206. // 状态(一级分类由接口分页拉全;左侧栏由 sidebar 接口分页:顶为「全部」时为全部+一级,顶为某一级时为全部+二级,首行展示名由前端替换为一级名称)
  207. const rootCategoryList = ref([])
  208. const sidebarRows = ref([])
  209. const sidebarLoading = ref(false)
  210. const sidebarHasMore = ref(false)
  211. const nextSidebarPage = ref(1)
  212. const products = ref([])
  213. const selectedFirstCategoryId = ref('all')
  214. const selectedSecondCategoryId = ref('all')
  215. const keyword = ref('')
  216. // 初始为 true:避免先渲染 scroll-view 再被请求置为 loading 时卸载/重挂,
  217. // H5 上会触发 uni scroll-view 内部 _scrollTopChanged 对 null 设 scrollTop 的报错。
  218. const isLoading = ref(true)
  219. const productsLoadingMore = ref(false)
  220. const nextProductPage = ref(1)
  221. const productHasMore = ref(true)
  222. const initialized = ref(false)
  223. // 商品详情覆盖弹窗
  224. const showProductOverlay = ref(false)
  225. const overlayProductId = ref('')
  226. const firstCategoryCards = computed(() => {
  227. const mapped = rootCategoryList.value.map((c) => ({
  228. id: c.id,
  229. name: c.name,
  230. imageUrl: c.imageUrl || DEFAULT_PLACEHOLDER_IMAGE_URL
  231. }))
  232. return [{ id: 'all', name: '全部', imageUrl: DEFAULT_PLACEHOLDER_IMAGE_URL }, ...mapped]
  233. })
  234. /** 与 mobile-ui demo 一致:每页 5 列 × 2 行 = 10 个 */
  235. const FIRST_CATEGORY_PAGE_SIZE = 10
  236. const firstCategoryPages = computed(() => {
  237. const pages = []
  238. for (let i = 0; i < firstCategoryCards.value.length; i += FIRST_CATEGORY_PAGE_SIZE) {
  239. pages.push(firstCategoryCards.value.slice(i, i + FIRST_CATEGORY_PAGE_SIZE))
  240. }
  241. return pages
  242. })
  243. const firstCategorySwiperIndex = ref(0)
  244. const deliveryLineLeft = computed(() => {
  245. const name = userStore.userInfo?.regionName?.trim()
  246. return name ? `配送区域:${name}` : '配送区域:请选择区域'
  247. })
  248. function onFirstCategorySwiperChange(e) {
  249. const cur = e?.detail?.current
  250. if (typeof cur === 'number') firstCategorySwiperIndex.value = cur
  251. }
  252. function goFirstCategorySwiperPage(index) {
  253. firstCategorySwiperIndex.value = index
  254. }
  255. /** 左侧栏展示:顶为「全部」时为接口数据(全部+一级);顶为某一级时首行显示该一级名称,不显示「全部」文案(筛选用 id 仍为 all) */
  256. const displaySidebarRows = computed(() => {
  257. const rows = sidebarRows.value
  258. if (!rows.length) return rows
  259. if (selectedFirstCategoryId.value === 'all') return rows
  260. const firstMeta = firstCategoryCards.value.find((c) => c.id === selectedFirstCategoryId.value)
  261. const firstName = firstMeta?.name || '未分类'
  262. const head = rows[0]
  263. if (head && head.id === 'all') {
  264. return [{ id: 'all', name: firstName }, ...rows.slice(1)]
  265. }
  266. return rows
  267. })
  268. /** 与后端 list_products 的 categoryId / rootCategoryId 约定一致 */
  269. function buildProductRequestData(page) {
  270. const f = selectedFirstCategoryId.value
  271. const s = selectedSecondCategoryId.value
  272. const data = {
  273. page,
  274. pageSize: PRODUCT_PAGE_SIZE
  275. }
  276. const kw = keyword.value.trim()
  277. if (kw) {
  278. data.keyword = kw
  279. }
  280. if (f === 'all') {
  281. if (s !== 'all') {
  282. data.rootCategoryId = s
  283. }
  284. } else if (s === 'all') {
  285. data.rootCategoryId = f
  286. } else {
  287. data.categoryId = s
  288. }
  289. return data
  290. }
  291. async function fetchProductPage(page) {
  292. const res = await request({
  293. url: '/mini/client/products',
  294. method: 'GET',
  295. data: buildProductRequestData(page)
  296. })
  297. const body = parseResponseJson(res)
  298. if (body.code !== 200) {
  299. return { list: [], totalPages: 0 }
  300. }
  301. const payload = body.data || {}
  302. const list = payload.list || []
  303. const totalPages = Number(payload.totalPages) || 0
  304. return { list, totalPages }
  305. }
  306. async function reloadProducts() {
  307. const token = uni.getStorageSync(TOKEN_KEY)
  308. if (!token) {
  309. products.value = []
  310. productHasMore.value = false
  311. return
  312. }
  313. productsLoadingMore.value = true
  314. nextProductPage.value = 1
  315. productHasMore.value = true
  316. products.value = []
  317. try {
  318. const { list, totalPages } = await fetchProductPage(1)
  319. products.value = list
  320. productHasMore.value = totalPages > 1
  321. nextProductPage.value = 2
  322. } catch (error) {
  323. console.error('获取商品失败:', error)
  324. productHasMore.value = false
  325. } finally {
  326. productsLoadingMore.value = false
  327. }
  328. }
  329. async function loadMoreProducts() {
  330. const token = uni.getStorageSync(TOKEN_KEY)
  331. if (!token || productsLoadingMore.value || !productHasMore.value) return
  332. productsLoadingMore.value = true
  333. const page = nextProductPage.value
  334. try {
  335. const { list, totalPages } = await fetchProductPage(page)
  336. if (list.length) {
  337. products.value = [...products.value, ...list]
  338. }
  339. productHasMore.value = page < totalPages
  340. if (productHasMore.value) {
  341. nextProductPage.value = page + 1
  342. }
  343. } catch (error) {
  344. console.error('加载更多商品失败:', error)
  345. } finally {
  346. productsLoadingMore.value = false
  347. }
  348. }
  349. function onGoodsScrollToLower() {
  350. void loadMoreProducts()
  351. }
  352. async function loadAllRootCategories() {
  353. rootCategoryList.value = []
  354. const token = uni.getStorageSync(TOKEN_KEY)
  355. if (!token) return
  356. let page = 1
  357. try {
  358. while (true) {
  359. const res = await request({
  360. url: '/mini/client/product-categories/first-level',
  361. method: 'GET',
  362. data: { page, pageSize: ROOT_CATEGORY_PAGE_SIZE }
  363. })
  364. const body = parseResponseJson(res)
  365. if (body.code !== 200) break
  366. const d = body.data || {}
  367. const list = d.list || []
  368. for (const c of list) {
  369. rootCategoryList.value.push({
  370. id: c.id,
  371. name: c.name,
  372. imageUrl: c.imageUrl || ''
  373. })
  374. }
  375. const totalPages = Number(d.totalPages) || 0
  376. if (page >= totalPages) break
  377. page += 1
  378. }
  379. } catch (error) {
  380. console.error('加载一级分类失败:', error)
  381. }
  382. }
  383. async function reloadSidebar() {
  384. sidebarRows.value = []
  385. nextSidebarPage.value = 1
  386. sidebarHasMore.value = true
  387. await loadSidebarPage(true)
  388. }
  389. async function loadSidebarPage(isFirst) {
  390. if (sidebarLoading.value) return
  391. const token = uni.getStorageSync(TOKEN_KEY)
  392. if (!token) return
  393. const page = isFirst ? 1 : nextSidebarPage.value
  394. if (!isFirst && !sidebarHasMore.value) return
  395. sidebarLoading.value = true
  396. try {
  397. const res = await request({
  398. url: '/mini/client/product-categories/sidebar',
  399. method: 'GET',
  400. data: {
  401. firstCategoryId: selectedFirstCategoryId.value,
  402. page,
  403. pageSize: SIDEBAR_PAGE_SIZE
  404. }
  405. })
  406. const body = parseResponseJson(res)
  407. if (body.code !== 200) return
  408. const d = body.data || {}
  409. const list = d.list || []
  410. if (isFirst) {
  411. sidebarRows.value = list
  412. nextSidebarPage.value = 2
  413. } else {
  414. sidebarRows.value = [...sidebarRows.value, ...list]
  415. nextSidebarPage.value = page + 1
  416. }
  417. const totalPages = Number(d.totalPages) || 0
  418. sidebarHasMore.value = page < totalPages
  419. } finally {
  420. sidebarLoading.value = false
  421. }
  422. }
  423. function onCategoryScrollToLower() {
  424. void loadSidebarPage(false)
  425. }
  426. let keywordDebounce = null
  427. watch(keyword, () => {
  428. if (keywordDebounce) clearTimeout(keywordDebounce)
  429. keywordDebounce = setTimeout(() => {
  430. void reloadProducts()
  431. }, 350)
  432. })
  433. watch([selectedFirstCategoryId, selectedSecondCategoryId], () => {
  434. void reloadProducts()
  435. })
  436. watch(selectedFirstCategoryId, () => {
  437. void reloadSidebar()
  438. })
  439. watch(
  440. () => firstCategoryPages.value.length,
  441. () => {
  442. firstCategorySwiperIndex.value = 0
  443. }
  444. )
  445. function handleFirstCategoryClick(categoryId) {
  446. selectedFirstCategoryId.value = categoryId
  447. selectedSecondCategoryId.value = 'all'
  448. }
  449. // 添加到购物车
  450. async function addToCart(product) {
  451. await cartStore.addToCart(product, 1)
  452. }
  453. // 商品数量变化
  454. async function updateQuantity(productId, delta) {
  455. const cartItem = cartStore.items.find(item => item.productId === productId)
  456. if (cartItem) {
  457. const newQuantity = cartItem.quantity + delta
  458. if (newQuantity <= 0) {
  459. await cartStore.removeItem(cartItem.id)
  460. } else {
  461. await cartStore.updateQuantity(cartItem.id, newQuantity)
  462. }
  463. } else {
  464. const product = products.value.find(p => p.id === productId)
  465. if (product && delta > 0) {
  466. await cartStore.addToCart(product, delta)
  467. }
  468. }
  469. }
  470. // 获取购物车商品数量
  471. function getCartQuantity(productId) {
  472. const item = cartStore.items.find(i => i.productId === productId)
  473. return item?.quantity || 0
  474. }
  475. // 跳转到购物车(tabBar 页须用 switchTab,不能用 navigateTo)
  476. function goToCart() {
  477. uni.switchTab({ url: '/pages/client/cart/cart' })
  478. }
  479. // 跳转到个人中心(Tab 页需 switchTab)
  480. function goToProfile() {
  481. uni.switchTab({ url: '/pages/client/profile/profile' })
  482. }
  483. function onSearchClick() {
  484. uni.hideKeyboard && uni.hideKeyboard()
  485. void reloadProducts()
  486. }
  487. function formatPrice(price) {
  488. return formatYuan(price)
  489. }
  490. function formatMeasureText(measureValue, unitName) {
  491. const unitText = (unitName || '').trim()
  492. if (measureValue == null || measureValue === '') {
  493. return unitText ? `- ${unitText}` : '-'
  494. }
  495. const num = Number(measureValue)
  496. if (!Number.isNaN(num)) {
  497. const measureText = String(num).replace(/\.?0+$/, '')
  498. return unitText ? `${measureText} ${unitText}` : measureText
  499. }
  500. const measureText = String(measureValue).trim()
  501. return unitText ? `${measureText} ${unitText}` : measureText || '-'
  502. }
  503. function goProductView(productId) {
  504. if (!productId) return
  505. overlayProductId.value = productId
  506. showProductOverlay.value = true
  507. }
  508. function closeProductOverlay() {
  509. showProductOverlay.value = false
  510. }
  511. // 初始化
  512. onMounted(async () => {
  513. userStore.checkLoginStatus()
  514. if (!userStore.isLoggedIn) {
  515. isLoading.value = false
  516. uni.reLaunch({ url: '/pages/login/login' })
  517. return
  518. }
  519. if (!userStore.isClient) {
  520. isLoading.value = false
  521. uni.showToast({ title: '请使用客户账号登录', icon: 'none' })
  522. setTimeout(() => userStore.navigateToHome(), 1500)
  523. return
  524. }
  525. isLoading.value = true
  526. try {
  527. await Promise.all([loadAllRootCategories(), cartStore.fetchCart(), reloadSidebar()])
  528. await reloadProducts()
  529. initialized.value = true
  530. } finally {
  531. isLoading.value = false
  532. }
  533. })
  534. onShow(() => {
  535. if (!initialized.value) return
  536. if (!userStore.isLoggedIn || !userStore.isClient) return
  537. void cartStore.fetchCart()
  538. void reloadProducts()
  539. })
  540. </script>
  541. <style lang="scss">
  542. /* 仅影响当前页:高度随 tab 内容区,禁止页面级纵向滚动(不写全局 body overflow,避免污染其它路由) */
  543. page {
  544. height: 100%;
  545. overflow: hidden;
  546. background-color: #f5f8f7;
  547. }
  548. /* input placeholder-class 在部分端需非 scoped 才能命中 */
  549. .search-placeholder {
  550. color: #8aa39a;
  551. }
  552. </style>
  553. <style lang="scss" scoped>
  554. .home-root {
  555. position: relative;
  556. width: 100%;
  557. min-height: 100%;
  558. /* 底部固定购物车条占位:按实际条高+离底间距预留,避免小屏遮挡最后一张商品卡片 */
  559. --home-goods-scroll-pad: calc(360rpx + constant(safe-area-inset-bottom));
  560. --home-goods-scroll-pad: calc(360rpx + env(safe-area-inset-bottom, 0px));
  561. }
  562. /* #ifdef H5 */
  563. .home-root {
  564. /* H5 的 cart-bar 会叠加 --window-bottom 与额外偏移,需要更大的滚动底部留白 */
  565. --home-goods-scroll-pad: calc(420rpx + constant(safe-area-inset-bottom));
  566. --home-goods-scroll-pad: calc(420rpx + env(safe-area-inset-bottom, 0px));
  567. }
  568. /* #endif */
  569. /* #ifdef H5 */
  570. /* 与 cart 等 tab 页一致:底部用 --window-bottom,避免手写 50px 与 tabBar 实际高度不一致产生缝 */
  571. .inquiry-page {
  572. position: fixed;
  573. left: 0;
  574. right: 0;
  575. top: 0;
  576. bottom: var(--window-bottom, calc(50px + env(safe-area-inset-bottom, 0px)));
  577. width: 100%;
  578. z-index: 1;
  579. min-height: 0;
  580. background: var(--c-bg);
  581. overflow: hidden;
  582. display: flex;
  583. flex-direction: column;
  584. box-sizing: border-box;
  585. }
  586. /* #endif */
  587. /* #ifndef H5 */
  588. .inquiry-page {
  589. height: 100%;
  590. max-height: 100%;
  591. min-height: 0;
  592. background: var(--c-bg);
  593. overflow: hidden;
  594. display: flex;
  595. flex-direction: column;
  596. box-sizing: border-box;
  597. }
  598. /* #endif */
  599. .state {
  600. text-align: center;
  601. color: var(--c-muted);
  602. padding: 40rpx;
  603. }
  604. .content-wrap {
  605. flex: 1;
  606. min-height: 0;
  607. /* demo .section 水平 16px → 32rpx */
  608. padding: 0 32rpx;
  609. display: flex;
  610. flex-direction: column;
  611. box-sizing: border-box;
  612. }
  613. /* 对齐 mobile-ui/sanqiang-client-ui-demo.html .top */
  614. .filter-top-wrap {
  615. flex-shrink: 0;
  616. margin: 0 -32rpx 0;
  617. }
  618. .hero-top-main {
  619. display: flex;
  620. align-items: flex-end;
  621. justify-content: space-between;
  622. gap: 20rpx;
  623. margin-bottom: 20rpx;
  624. }
  625. .hero-top-text {
  626. flex: 1;
  627. min-width: 0;
  628. display: flex;
  629. flex-direction: column;
  630. align-items: flex-start;
  631. }
  632. .hero-brand {
  633. color: #ffffff;
  634. font-size: 48rpx;
  635. font-weight: 700;
  636. line-height: 1.25;
  637. letter-spacing: 0.5rpx;
  638. margin-bottom: 12rpx;
  639. }
  640. .hero-region-line {
  641. color: #ffffff;
  642. font-size: 28rpx;
  643. line-height: 1.35;
  644. width: 100%;
  645. overflow: hidden;
  646. text-overflow: ellipsis;
  647. white-space: nowrap;
  648. }
  649. /* 与供应商首页 header-watermark 同源素材,置于顶栏右侧底部对齐 */
  650. .hero-top-logo {
  651. flex-shrink: 0;
  652. width: 168rpx;
  653. height: 168rpx;
  654. opacity: 0.4;
  655. pointer-events: none;
  656. }
  657. /* demo .search:白底圆角条 + 占位灰绿字 */
  658. .search-bar {
  659. display: flex;
  660. align-items: center;
  661. height: 72rpx;
  662. background: #ffffff;
  663. border: 1rpx solid #d9e9e1;
  664. border-radius: 36rpx;
  665. padding: 0 8rpx 0 24rpx;
  666. box-sizing: border-box;
  667. }
  668. .search-input {
  669. flex: 1;
  670. min-width: 0;
  671. height: 72rpx;
  672. font-size: 28rpx;
  673. color: var(--c-text);
  674. background: transparent;
  675. border: none;
  676. padding: 0;
  677. }
  678. .search-placeholder {
  679. color: #8aa39a;
  680. }
  681. .search-action {
  682. flex-shrink: 0;
  683. padding: 0 16rpx 0 8rpx;
  684. font-size: 24rpx;
  685. color: var(--c-brand-green);
  686. font-weight: 600;
  687. }
  688. /* demo .section 内:标题 + 一级分类白卡片 */
  689. .category-section {
  690. flex-shrink: 0;
  691. /* demo .section margin 12px 0 顶 */
  692. margin-top: 24rpx;
  693. }
  694. .section-head {
  695. display: flex;
  696. align-items: center;
  697. justify-content: space-between;
  698. margin-bottom: 20rpx;
  699. }
  700. .section-title {
  701. /* demo h3 17px */
  702. font-size: 34rpx;
  703. font-weight: 600;
  704. color: var(--c-text);
  705. }
  706. .first-category-wrap {
  707. background: #ffffff;
  708. border: 1rpx solid var(--c-border);
  709. border-radius: 28rpx;
  710. padding: 8rpx;
  711. box-sizing: border-box;
  712. overflow: hidden;
  713. }
  714. .first-category-swiper {
  715. /* 收紧一级分类区高度,避免短文案卡片出现大块空白 */
  716. height: 336rpx;
  717. }
  718. .first-category-grid {
  719. display: grid;
  720. grid-template-columns: repeat(5, 1fr);
  721. gap: 16rpx;
  722. padding: 8rpx;
  723. box-sizing: border-box;
  724. width: 100%;
  725. height: 100%;
  726. align-content: start;
  727. }
  728. .first-category-grid--single {
  729. min-height: auto;
  730. }
  731. .first-category-card {
  732. display: flex;
  733. flex-direction: column;
  734. align-items: center;
  735. justify-content: flex-start;
  736. min-height: 152rpx;
  737. padding: 8rpx 4rpx 8rpx;
  738. border-radius: 28rpx;
  739. background: transparent;
  740. border: none;
  741. box-sizing: border-box;
  742. transition: opacity 0.15s ease, transform 0.15s ease;
  743. &.active {
  744. background: linear-gradient(145deg, #e7f8f0, #f1fbf6);
  745. color: var(--c-brand-green);
  746. font-weight: 600;
  747. box-shadow: 0 4rpx 8rpx rgba(10, 159, 109, 0.12);
  748. }
  749. }
  750. .first-category-card--hover {
  751. opacity: 0.88;
  752. }
  753. .first-category-image {
  754. /* demo .first-chip::before 42×42,圆角 12px */
  755. width: 84rpx;
  756. height: 84rpx;
  757. border-radius: 24rpx;
  758. background: linear-gradient(145deg, #dff1e8, #cdebdc);
  759. flex-shrink: 0;
  760. }
  761. .first-category-card.active .first-category-image {
  762. box-shadow: 0 4rpx 14rpx rgba(10, 159, 109, 0.35);
  763. }
  764. .first-category-name {
  765. /* demo .first-chip gap 8px + font 11px */
  766. margin-top: 8rpx;
  767. width: 100%;
  768. font-size: 22rpx;
  769. line-height: 1.2;
  770. color: #4f665d;
  771. text-align: center;
  772. overflow: hidden;
  773. text-overflow: ellipsis;
  774. white-space: nowrap;
  775. }
  776. .first-category-card.active .first-category-name {
  777. color: var(--c-brand-green);
  778. }
  779. .first-level-indicator {
  780. display: flex;
  781. justify-content: center;
  782. align-items: center;
  783. gap: 16rpx;
  784. margin: 16rpx auto 0;
  785. }
  786. .indicator-dot {
  787. width: 36rpx;
  788. height: 8rpx;
  789. border-radius: 999rpx;
  790. background: #dbe9e2;
  791. transition: width 0.2s ease, background 0.2s ease;
  792. }
  793. .indicator-dot.active {
  794. width: 56rpx;
  795. background: linear-gradient(145deg, var(--c-brand-green-2), var(--c-brand-green));
  796. }
  797. .product-layout {
  798. flex: 1;
  799. min-height: 0;
  800. /* demo .category-layout margin-top 10px */
  801. margin-top: 20rpx;
  802. /* 二级分类 + 商品区通栏:抵消 content-wrap 左右 32rpx,商品列更宽 */
  803. margin-left: -32rpx;
  804. margin-right: -32rpx;
  805. width: auto;
  806. align-self: stretch;
  807. }
  808. .product-body {
  809. display: flex;
  810. height: 100%;
  811. min-height: 0;
  812. /* demo .category-layout min-height 440px */
  813. min-height: 880rpx;
  814. border: 1rpx solid var(--c-border);
  815. border-radius: var(--c-radius-lg);
  816. overflow: hidden;
  817. background: var(--c-card);
  818. }
  819. .category-pane {
  820. /* demo grid 92px 侧栏 */
  821. width: 184rpx;
  822. flex-shrink: 0;
  823. height: 100%;
  824. background: #f8fcfa;
  825. border-right: 1rpx solid #edf5f1;
  826. border-top-left-radius: var(--c-radius-lg);
  827. border-bottom-left-radius: var(--c-radius-lg);
  828. }
  829. .category-scroll-inner {
  830. padding-bottom: var(--home-goods-scroll-pad);
  831. box-sizing: border-box;
  832. }
  833. .category-item {
  834. min-height: 84rpx;
  835. display: flex;
  836. align-items: center;
  837. justify-content: center;
  838. padding: 0 16rpx;
  839. font-size: 24rpx;
  840. color: #5f726a;
  841. text-align: center;
  842. border-left: 6rpx solid transparent;
  843. background: transparent;
  844. box-sizing: border-box;
  845. &.active {
  846. color: var(--c-brand-green);
  847. background: #ffffff;
  848. border-left-color: var(--c-brand-green);
  849. font-weight: 600;
  850. }
  851. }
  852. .category-item--hover {
  853. opacity: 0.88;
  854. }
  855. .goods-pane {
  856. flex: 1;
  857. min-width: 0;
  858. background: #ffffff;
  859. /* 对齐设计图 goods-area padding:10px */
  860. padding: 16rpx 0 0;
  861. display: flex;
  862. flex-direction: column;
  863. min-height: 0;
  864. height: 100%;
  865. border-top-right-radius: var(--c-radius-lg);
  866. border-bottom-right-radius: var(--c-radius-lg);
  867. }
  868. .goods-scroll {
  869. flex: 1;
  870. min-height: 0;
  871. height: 100%;
  872. }
  873. /* 内层包裹:各端对 scroll-view 直接 padding 支持不一致,用内层垫高更稳 */
  874. .goods-scroll-inner {
  875. padding-left: 20rpx;
  876. padding-right: 20rpx;
  877. padding-bottom: var(--home-goods-scroll-pad);
  878. box-sizing: border-box;
  879. }
  880. .pane-footer-tip {
  881. text-align: center;
  882. color: #9ba8a2;
  883. font-size: 24rpx;
  884. padding: 24rpx 12rpx 32rpx;
  885. }
  886. .empty-list-tip {
  887. text-align: center;
  888. color: #909399;
  889. font-size: 28rpx;
  890. padding: 40rpx 0;
  891. }
  892. .goods-card {
  893. display: grid;
  894. /* 窄屏:缩略图列随屏宽比例收缩;宽屏仍接近 demo 约 92px/侧 */
  895. grid-template-columns: minmax(112rpx, 36%) minmax(0, 1fr);
  896. gap: min(16rpx, 2.5vw);
  897. align-items: stretch;
  898. background: #fff;
  899. padding: 0;
  900. border: 1rpx solid var(--c-border);
  901. border-radius: var(--c-radius-md);
  902. margin-bottom: 16rpx;
  903. overflow: hidden;
  904. box-sizing: border-box;
  905. }
  906. .goods-card--pressed {
  907. opacity: 0.96;
  908. }
  909. .goods-image {
  910. width: 100%;
  911. align-self: start;
  912. aspect-ratio: 1 / 1;
  913. height: auto;
  914. min-height: 112rpx;
  915. max-height: 208rpx;
  916. border-radius: 16rpx;
  917. background: linear-gradient(145deg, #f4fbf8, #e4f6ef);
  918. flex-shrink: 0;
  919. }
  920. .goods-main {
  921. padding: 10rpx 12rpx 10rpx 0;
  922. min-width: 0;
  923. display: flex;
  924. flex-direction: column;
  925. justify-content: space-between;
  926. }
  927. .goods-name {
  928. display: -webkit-box;
  929. -webkit-box-orient: vertical;
  930. -webkit-line-clamp: 2;
  931. overflow: hidden;
  932. font-size: clamp(22rpx, 3.4vw, 26rpx);
  933. line-height: 1.35;
  934. min-height: 0;
  935. color: var(--c-text);
  936. font-weight: 400;
  937. word-break: break-word;
  938. }
  939. .goods-spec {
  940. display: block;
  941. margin: 6rpx 0 12rpx;
  942. font-size: clamp(20rpx, 3vw, 22rpx);
  943. color: var(--c-muted);
  944. overflow: hidden;
  945. text-overflow: ellipsis;
  946. white-space: nowrap;
  947. }
  948. .bottom-row {
  949. /* 栅格:左侧价格区可收缩省略,右侧操作区独占一列,避免 flex 与 text 组件叠在一起 */
  950. display: grid;
  951. grid-template-columns: minmax(0, 1fr) auto;
  952. align-items: center;
  953. column-gap: 12rpx;
  954. row-gap: 8rpx;
  955. margin-top: auto;
  956. min-width: 0;
  957. }
  958. .goods-price {
  959. min-width: 0;
  960. max-width: 100%;
  961. display: block;
  962. overflow: hidden;
  963. text-overflow: ellipsis;
  964. white-space: nowrap;
  965. font-size: clamp(24rpx, 3.8vw, 32rpx);
  966. color: var(--c-danger);
  967. font-weight: 700;
  968. }
  969. .bottom-row-actions {
  970. display: flex;
  971. align-items: center;
  972. justify-content: flex-end;
  973. flex-shrink: 0;
  974. padding-right: 12rpx;
  975. }
  976. .add-btn {
  977. flex-shrink: 0;
  978. width: clamp(36rpx, 7.5vw, 48rpx);
  979. height: clamp(36rpx, 7.5vw, 48rpx);
  980. background: var(--c-brand-green);
  981. border-radius: 50%;
  982. display: flex;
  983. align-items: center;
  984. justify-content: center;
  985. color: #ffffff;
  986. font-size: clamp(22rpx, 4.5vw, 30rpx);
  987. font-weight: 700;
  988. box-sizing: border-box;
  989. padding: 0;
  990. line-height: 1;
  991. }
  992. /* 「+」在圆内几何居中:抵消 text 默认行高/基线导致的视觉偏移 */
  993. .add-btn-plus {
  994. line-height: 1;
  995. font-size: inherit;
  996. font-weight: inherit;
  997. color: inherit;
  998. text-align: center;
  999. display: block;
  1000. transform: translateY(-2rpx);
  1001. }
  1002. .quantity-control {
  1003. display: flex;
  1004. align-items: center;
  1005. gap: 6rpx;
  1006. flex-shrink: 0;
  1007. }
  1008. .qty-btn {
  1009. width: clamp(32rpx, 6.5vw, 42rpx);
  1010. height: clamp(32rpx, 6.5vw, 42rpx);
  1011. border: 1rpx solid #b9decf;
  1012. border-radius: 50%;
  1013. display: flex;
  1014. align-items: center;
  1015. justify-content: center;
  1016. color: var(--c-brand-green);
  1017. font-size: clamp(18rpx, 3.2vw, 24rpx);
  1018. font-weight: 700;
  1019. background: #fff;
  1020. box-sizing: border-box;
  1021. }
  1022. .qty-num {
  1023. min-width: 40rpx;
  1024. max-width: 64rpx;
  1025. text-align: center;
  1026. font-size: clamp(18rpx, 3vw, 24rpx);
  1027. color: #303133;
  1028. overflow: hidden;
  1029. text-overflow: ellipsis;
  1030. }
  1031. // 底部购物车:对齐 demo .cart-bar / .cart-inner / .settle
  1032. .cart-bar {
  1033. position: fixed;
  1034. left: 50%;
  1035. transform: translateX(-50%);
  1036. z-index: 10010;
  1037. width: 100%;
  1038. max-width: 840rpx;
  1039. padding: 0 24rpx;
  1040. box-sizing: border-box;
  1041. overflow: visible;
  1042. /* demo .cart-bar bottom:72px — 在 tab 之上留出空隙;与 uni --window-bottom 叠加 */
  1043. /* #ifdef H5 */
  1044. bottom: calc(var(--window-bottom, calc(50px + env(safe-area-inset-bottom, 0px))) + 44rpx);
  1045. /* #endif */
  1046. /* #ifndef H5 */
  1047. bottom: calc(100rpx + constant(safe-area-inset-bottom));
  1048. bottom: calc(100rpx + env(safe-area-inset-bottom, 0px));
  1049. /* #endif */
  1050. }
  1051. .cart-inner {
  1052. min-height: 96rpx;
  1053. border-radius: 36rpx;
  1054. background: linear-gradient(145deg, #ffffff, #f5fbf8);
  1055. border: 1rpx solid #d9ebe3;
  1056. color: var(--c-text);
  1057. display: flex;
  1058. align-items: center;
  1059. justify-content: space-between;
  1060. padding: 12rpx;
  1061. box-shadow: 0 16rpx 36rpx rgba(10, 159, 109, 0.12);
  1062. box-sizing: border-box;
  1063. }
  1064. .cart-left {
  1065. display: flex;
  1066. align-items: center;
  1067. gap: 20rpx;
  1068. padding-left: 8rpx;
  1069. flex: 1;
  1070. min-width: 0;
  1071. }
  1072. .cart-count {
  1073. min-width: 52rpx;
  1074. height: 52rpx;
  1075. padding: 0 8rpx;
  1076. border-radius: 26rpx;
  1077. background: linear-gradient(145deg, var(--c-brand-green), var(--c-brand-green-2));
  1078. display: inline-flex;
  1079. align-items: center;
  1080. justify-content: center;
  1081. box-sizing: border-box;
  1082. text {
  1083. font-size: 24rpx;
  1084. font-weight: 700;
  1085. color: #fff;
  1086. }
  1087. }
  1088. .cart-text-col {
  1089. display: flex;
  1090. flex-direction: column;
  1091. gap: 2rpx;
  1092. min-width: 0;
  1093. }
  1094. .cart-items-label {
  1095. font-size: 22rpx;
  1096. color: #6f8179;
  1097. }
  1098. .cart-total-money {
  1099. font-size: 32rpx;
  1100. font-weight: 700;
  1101. letter-spacing: 0.4rpx;
  1102. color: #1f2a26;
  1103. }
  1104. .cart-settle {
  1105. flex-shrink: 0;
  1106. min-height: 72rpx;
  1107. padding: 0 32rpx;
  1108. border-radius: 28rpx;
  1109. background: linear-gradient(145deg, var(--c-barley-yellow), #e4b458);
  1110. color: #fff;
  1111. display: inline-flex;
  1112. align-items: center;
  1113. justify-content: center;
  1114. font-size: 28rpx;
  1115. font-weight: 700;
  1116. box-shadow: inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
  1117. text {
  1118. color: #fff;
  1119. }
  1120. }
  1121. // 加载状态
  1122. .loading-state {
  1123. display: flex;
  1124. align-items: center;
  1125. justify-content: center;
  1126. padding: 40rpx 0;
  1127. color: #909399;
  1128. font-size: 28rpx;
  1129. }
  1130. </style>