feat: 添加商品详情页及微信参数解析功能

- 新增商品详情页面,支持从首页跳转查看商品详情
- 创建currentProduct store管理当前查看的商品数据
- 实现微信小程序scene参数解析功能,创建wx-params store
- 修改首页商品点击逻辑,从弹窗改为跳转详情页
- 在个人中心添加微信参数查看功能
- 优化商品详情页的借还动态加载和显示逻辑
This commit is contained in:
dzq 2025-12-03 09:10:00 +08:00
parent f543a7e858
commit 918bc53059
8 changed files with 379 additions and 86 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow, } from '@dcloudio/uni-app'
import { onHide, onLaunch, onShow, onLoad} from '@dcloudio/uni-app'
import { navigateToInterceptor } from '@/router/interceptor'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
import {onMounted, ref} from 'vue'

View File

@ -76,6 +76,13 @@
"navigationBarTitleText": "结算购物车"
}
},
{
"path": "pages/index/detail",
"type": "page",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "pages/login/login",
"type": "page",

View File

@ -2,8 +2,8 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useProductStore } from '@/pinia/stores/product'
import { useCartStore } from '@/pinia/stores/cart'
import { useCurrentProductStore } from '@/pinia/stores/currentProduct'
import { storeToRefs } from 'pinia'
import Detail from './detail.vue'
import Cart from './cart.vue'
import { toHttpsUrl } from '@/utils'
@ -24,18 +24,19 @@ const productStore = useProductStore()
const { labels, categories } = storeToRefs(productStore)
const cartStore = useCartStore()
const { cartItems, totalPrice, totalQuantity } = storeToRefs(cartStore)
const currentProductStore = useCurrentProductStore()
// props
const activeCategory = ref<number>(0)
//
const showDetailPopup = ref<boolean>(false)
// const showDetailPopup = ref<boolean>(false)
// ID
const currentCellId = ref<number>()
//
const currentProduct = computed(() =>
categories.value.find(p => p.cellId === currentCellId.value)
)
// const currentProduct = computed(() =>
// categories.value.find(p => p.cellId === currentCellId.value)
// )
//
const showCartPopup = ref<boolean>(false)
@ -55,7 +56,16 @@ function handleCategoryClick(index: number) {
*/
function showProductDetail(cellId: number) {
currentCellId.value = cellId
showDetailPopup.value = true
//
const product = categories.value.find(p => p.cellId === cellId)
if (product) {
// store
currentProductStore.setCurrentProduct(product)
//
uni.navigateTo({
url: '/pages/index/detail'
})
}
}
/**
@ -205,17 +215,6 @@ function handleCheckout() {
</view>
</view>
<!-- 商品详情弹窗 -->
<wd-popup
v-model="showDetailPopup"
position="bottom"
:z-index="999"
>
<view class="detail-container" v-if="currentProduct">
<Detail :product="currentProduct" @detail-close="showDetailPopup = false" />
</view>
</wd-popup>
<!-- 购物车弹窗 -->
<wd-popup v-model="showCartPopup" position="bottom" :z-index="999">
<view class="detail-container">

View File

@ -1,26 +1,27 @@
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import type { Product } from '@/pinia/stores/product'
import { useCartStore } from '@/pinia/stores/cart'
import { useCurrentProductStore } from '@/pinia/stores/currentProduct'
import { storeToRefs } from 'pinia'
import { toHttpsUrl } from '@/utils'
// import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
import { getBorrowReturnDynamicApi, type BorrowReturnDynamicDTO, type SearchBorrowReturnDynamicQuery } from '@/api/order'
import { TabsInstance } from 'wot-design-uni/components/wd-tabs/types'
//
const props = defineProps<{
product: Product
}>()
//
const emit = defineEmits<{
(e: 'detailClose'): void
}>()
definePage({
style: {
navigationBarTitleText: '商品详情',
},
})
//
const cartStore = useCartStore()
const { cartItems } = storeToRefs(cartStore);
const currentProductStore = useCurrentProductStore();
//
const product = computed(() => currentProductStore.currentProduct)
const tabsRef = ref<TabsInstance>();
@ -43,35 +44,33 @@ const loadmoreState = ref<'loading' | 'finished' | 'error'>('loading')
// scroll-view
const scrollHeight = computed(() => {
// - - -
// - -
const windowHeight = uni.getSystemInfoSync().windowHeight
const headerHeight = 60 //
const actionBarHeight = 64 //
const tabHeight = 44 //
return `${windowHeight - headerHeight - actionBarHeight - tabHeight}px`
return `${windowHeight - actionBarHeight - tabHeight}px`
})
const maxQuantity = computed(() => {
const existingItem = cartItems.value.find(item => item.product.id === props.product.id)
if (!product.value) return true
const existingItem = cartItems.value.find(item => item.product.id === product.value.id)
if (existingItem) {
return props.product.stock !== -1 && existingItem.quantity + quantity.value >= props.product.stock
return product.value.stock !== -1 && existingItem.quantity + quantity.value >= product.value.stock
} else {
return props.product.stock !== -1 && quantity.value >= props.product.stock
return product.value.stock !== -1 && quantity.value >= product.value.stock
}
})
//
function handleClose() {
emit('detailClose')
}
function handleAddToCart(): boolean {
if (!product.value) return false
if (maxQuantity.value) {
// TODO
return false
}
const existingItem = cartItems.value.find(item => item.product.id === props.product.id);
if (props.product.stock !== -1 && existingItem && existingItem.quantity + quantity.value >= props.product.stock) {
const existingItem = cartItems.value.find(item => item.product.id === product.value.id);
if (product.value.stock !== -1 && existingItem && existingItem.quantity + quantity.value >= product.value.stock) {
// TODO
return false
}
@ -86,16 +85,20 @@ function handleRemoveFromCart() {
}
function confirmAddCart() {
cartStore.addToCart(props.product, quantity.value)
if (product.value) {
cartStore.addToCart(product.value, quantity.value)
}
//
quantity.value = 1
showAddCart.value = false
emit('detailClose')
//
uni.navigateBack()
}
function doShowAddCart(): boolean {
const existingItem = cartItems.value.find(item => item.product.id === props.product.id)
if (props.product.stock !== -1 && existingItem && existingItem.quantity >= props.product.stock) {
if (!product.value) return false
const existingItem = cartItems.value.find(item => item.product.id === product.value.id)
if (product.value.stock !== -1 && existingItem && existingItem.quantity >= product.value.stock) {
// TODO
return false
}
@ -105,14 +108,14 @@ function doShowAddCart(): boolean {
//
async function loadDynamicList(page: number = 1) {
if (!props.product?.id) return
if (!product.value?.id) return
loading.value = true
loadmoreState.value = 'loading'
try {
const query: SearchBorrowReturnDynamicQuery = {
goodsId: props.product.id,
cellId: props.product.cellId,
goodsId: product.value.id,
cellId: product.value.cellId,
pageNum: page,
pageSize: pageSize.value
}
@ -171,10 +174,12 @@ watch(activeTab, (newVal) => {
const refreshDynamicList = () => {
dynamicList.value = [];
pageNum.value = 1;
loadDynamicList(1);
if (product.value) {
loadDynamicList(1);
}
}
watch(() => props.product, () => {
watch(product, () => {
//
// activeTab.value = 0;
wx.pageScrollTo({
@ -186,17 +191,17 @@ watch(() => props.product, () => {
showAddCart.value = false;
refreshDynamicList();
}, { deep: true, immediate: true })
//
onUnmounted(() => {
currentProductStore.clearCurrentProduct()
})
</script>
<template>
<!-- 商品详情容器 -->
<view class="detail-container">
<!-- 顶部操作栏 -->
<view class="header-bar">
<view class="close-icon" @click="handleClose">
<wd-icon name="close" size="20px" color="#969799"></wd-icon>
</view>
</view>
<!-- 商品内容区域可滚动 -->
<view class="content-area">
@ -239,7 +244,7 @@ watch(() => props.product, () => {
</wd-tab>
<!-- 标签2借还动态 -->
<wd-tab title="借还动态">
<wd-tab title="商品动态">
<!-- 动态列表 -->
<scroll-view class="dynamic-list-scroll" scroll-y :style="{ height: scrollHeight }"
@scrolltolower="handleScrollToLower" :lower-threshold="100">
@ -400,22 +405,7 @@ watch(() => props.product, () => {
background: #f7f8fa;
}
.header-bar {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px 16px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.close-icon {
padding: 4px;
}
.content-area {
flex: 1;

View File

@ -10,6 +10,7 @@ import { generateDynamicCode, getWxUserByOpenid, mpCodeToOpenId } from '@/api/us
import { toHttpsUrl } from '@/utils'
import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { storeToRefs } from 'pinia'
import { useWxParamsStore } from '@/pinia/stores/wx-params'
definePage({
style: {
@ -108,6 +109,16 @@ function handleCheckout() {
url: '/pages/index/checkout'
})
}
onLoad((query) => {
console.log('page index onLoad query: ', query);
// scene 使 decodeURIComponent scene
if (query && query.scene) {
const scene = decodeURIComponent(query.scene);
const wxParamsStore = useWxParamsStore();
wxParamsStore.parseScene(scene);
}
})
</script>
<template>

View File

@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useWxStore } from '@/pinia/stores/wx'
import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { useWxParamsStore } from '@/pinia/stores/wx-params'
import { storeToRefs } from 'pinia'
import { toHttpsUrl } from '@/utils'
import { generateDynamicCode } from '@/api/users'
@ -16,11 +17,12 @@ definePage({
const wxStore = useWxStore()
const ab98UserStore = useAb98UserStore()
const wxParamsStore = useWxParamsStore()
const { balance, useBalance, balanceLimit, name: qyName, ab98User } = storeToRefs(wxStore)
const { name: userName, face_img } = storeToRefs(ab98UserStore);
const { wxUserDTO } = storeToRefs(wxStore);
const { rawScene, params, isParsed, parseError } = storeToRefs(wxParamsStore);
(globalThis as any).ab98UserStore = ab98UserStore;
@ -35,6 +37,9 @@ const dynamicCodeActionSheet = ref<boolean>(false)
const dynamicCodeData = ref<DynamicCodeResponse | null>(null)
const toast = useToast()
// wx
const wxParamsActionSheet = ref<boolean>(false)
const ab98BalanceInYuan = computed(() => {
if (ab98User.value && ab98User.value.ab98Balance !== undefined) {
return (ab98User.value.ab98Balance / 100).toFixed(2)
@ -42,19 +47,9 @@ const ab98BalanceInYuan = computed(() => {
return '0.00'
})
const handleLogout = () => {
uni.showModal({
title: '退出登录',
content: '确定要退出当前账号吗?',
success: (res) => {
if (res.confirm) {
ab98UserStore.clearUserInfo()
uni.switchTab({
url: '/pages/index/index'
})
}
}
})
// wx
const handleShowWxParams = () => {
wxParamsActionSheet.value = true
}
//
@ -121,7 +116,7 @@ const handleGenerateDynamicCode = async () => {
class="avatar"
:src="wxUserDTO?.ab98FaceImg"
mode="aspectFill"
@click="handleLogout"
@click="handleShowWxParams"
/>
</view>
<view class="user-details">
@ -215,6 +210,46 @@ const handleGenerateDynamicCode = async () => {
</view>
</view>
</wd-action-sheet>
<!-- wx参数展示弹窗 -->
<wd-action-sheet
v-model="wxParamsActionSheet"
title="小程序参数信息"
cancel-text="关闭"
:close-on-click-modal="true"
@close="wxParamsActionSheet = false"
>
<view style="padding: 20px;">
<view style="margin-bottom: 20px;">
<view style="font-size: 14px; font-weight: bold; color: #409EFF; margin-bottom: 8px;">原始Scene参数</view>
<view style="font-size: 12px; color: #666; background: #f5f5f5; padding: 8px 12px; border-radius: 4px; word-break: break-all;">
{{ rawScene || '暂无参数' }}
</view>
</view>
<view style="margin-bottom: 20px;">
<view style="font-size: 14px; font-weight: bold; color: #409EFF; margin-bottom: 8px;">解析状态</view>
<view style="font-size: 12px; color: #666;">
{{ isParsed ? '解析完成' : '未解析' }}
<span v-if="parseError" style="color: #f56c6c;"> (错误: {{ parseError }})</span>
</view>
</view>
<view v-if="isParsed && Object.keys(params).length > 0">
<view style="font-size: 14px; font-weight: bold; color: #409EFF; margin-bottom: 8px;">解析结果</view>
<view style="background: #f9f9f9; border-radius: 4px; padding: 12px;">
<view v-for="(value, key) in params" :key="key" style="display: flex; margin-bottom: 6px; font-size: 12px;">
<view style="color: #409EFF; font-weight: 500; min-width: 80px;">{{ key }}:</view>
<view style="color: #666; flex: 1; word-break: break-all;">{{ value }}</view>
</view>
</view>
</view>
<view v-else-if="isParsed" style="font-size: 12px; color: #999; text-align: center; padding: 20px;">
暂无解析参数
</view>
</view>
</wd-action-sheet>
</view>
</template>
@ -467,3 +502,4 @@ const handleGenerateDynamicCode = async () => {
}
}
</style>

View File

@ -0,0 +1,37 @@
import { pinia } from "@/pinia"
import { defineStore } from "pinia"
import type { Product } from './product'
export const useCurrentProductStore = defineStore("currentProduct", () => {
// 当前查看的商品
const currentProduct = ref<Product | null>(null)
/**
*
* @param product -
*/
const setCurrentProduct = (product: Product | null) => {
currentProduct.value = product
}
/**
*
*/
const clearCurrentProduct = () => {
currentProduct.value = null
}
return {
currentProduct,
setCurrentProduct,
clearCurrentProduct
}
})
/**
* @description SPA pinia 使 store
* @description SSR setup 使 store
*/
export function useCurrentProductStoreOutside() {
return useCurrentProductStore(pinia)
}

View File

@ -0,0 +1,213 @@
import { pinia } from "@/pinia"
import { defineStore } from "pinia"
/**
* store
* scene参数中获取的键值对
*/
export const useWxParamsStore = defineStore("wx-params", () => {
// 原始scene字符串
const rawScene = ref<string>("")
// 解析后的参数对象
const params = ref<Record<string, string>>({})
// 解析是否完成
const isParsed = ref<boolean>(false)
// 解析错误信息
const parseError = ref<string>("")
/**
* scene参数
* @param scene - scene参数
* 1. key1=value1&key2=value2
* 2. JSON字符串格式
* 3.
*/
const parseScene = (scene: string) => {
try {
// 重置状态
rawScene.value = scene
params.value = {}
isParsed.value = false
parseError.value = ""
if (!scene || scene.trim() === "") {
isParsed.value = true
return
}
// 尝试解析为JSON格式
if (scene.trim().startsWith("{") && scene.trim().endsWith("}")) {
try {
const jsonParams = JSON.parse(scene)
if (typeof jsonParams === "object" && jsonParams !== null) {
// 遍历JSON对象将值转换为字符串
Object.keys(jsonParams).forEach(key => {
params.value[key] = String(jsonParams[key])
})
isParsed.value = true
return
}
} catch (jsonError) {
// JSON解析失败继续尝试其他格式
console.warn("Scene参数不是有效的JSON格式尝试其他解析方式:", jsonError)
}
}
// 尝试解析为URL查询字符串格式 (key1=value1&key2=value2)
const urlSearchParams = new URLSearchParams(scene)
if (urlSearchParams.toString() !== "") {
urlSearchParams.forEach((value, key) => {
params.value[key] = value
})
isParsed.value = true
return
}
// 如果以上方法都失败,尝试简单的键值对分割
const parts = scene.split("&")
let hasValidPairs = false
parts.forEach(part => {
const [key, ...valueParts] = part.split("=")
if (key && key.trim() !== "") {
const value = valueParts.join("=") // 处理值中包含等号的情况
params.value[key.trim()] = value.trim()
hasValidPairs = true
}
})
if (hasValidPairs) {
isParsed.value = true
} else {
// 如果没有任何有效的键值对将整个scene作为单个参数
params.value["scene"] = scene
isParsed.value = true
}
} catch (error) {
console.error("解析scene参数失败:", error)
parseError.value = error instanceof Error ? error.message : "未知错误"
isParsed.value = false
}
}
/**
*
* @param key -
* @param defaultValue -
* @returns
*/
const getParam = (key: string, defaultValue: string = ""): string => {
return params.value[key] || defaultValue
}
/**
*
* @param key -
* @param defaultValue -
* @returns
*/
const getNumberParam = (key: string, defaultValue: number = 0): number => {
const value = params.value[key]
if (value === undefined || value === null) {
return defaultValue
}
const num = Number(value)
return isNaN(num) ? defaultValue : num
}
/**
*
* @param key -
* @param defaultValue -
* @returns
*/
const getBooleanParam = (key: string, defaultValue: boolean = false): boolean => {
const value = params.value[key]
if (value === undefined || value === null) {
return defaultValue
}
const lowerValue = value.toLowerCase()
if (lowerValue === "true" || lowerValue === "1" || lowerValue === "yes") {
return true
}
if (lowerValue === "false" || lowerValue === "0" || lowerValue === "no") {
return false
}
return defaultValue
}
/**
*
* @param key -
* @returns
*/
const hasParam = (key: string): boolean => {
return key in params.value
}
/**
*
* @returns
*/
const getAllParams = (): Record<string, string> => {
return { ...params.value }
}
/**
*
*/
const clearParams = () => {
rawScene.value = ""
params.value = {}
isParsed.value = false
parseError.value = ""
}
/**
*
* @param key -
* @param value -
*/
const setParam = (key: string, value: string) => {
params.value[key] = value
}
/**
*
* @param newParams -
*/
const setParams = (newParams: Record<string, string>) => {
Object.keys(newParams).forEach(key => {
params.value[key] = newParams[key]
})
}
return {
// 状态
rawScene,
params,
isParsed,
parseError,
// 方法
parseScene,
getParam,
getNumberParam,
getBooleanParam,
hasParam,
getAllParams,
clearParams,
setParam,
setParams
}
})
/**
* @description setup 使 store
*/
export function useWxParamsStoreOutside() {
return useWxParamsStore(pinia)
}