完成consumer端同步

This commit is contained in:
2026-05-14 15:28:09 +08:00
parent 612fb3d360
commit 0ffbc53902
197 changed files with 92657 additions and 7564 deletions

View File

@@ -0,0 +1,479 @@
<template>
<view class="channel-detail-page">
<view class="channel-topbar" :style="topbarStyle">
<view class="channel-topbar-row" :style="topbarRowStyle">
<view class="channel-topbar-back" @tap="goBack">
<text class="channel-topbar-back-text"></text>
</view>
<view class="channel-topbar-title-wrap" :style="titleWrapStyle">
<text class="channel-topbar-title">{{ pageTitle }}</text>
</view>
<view class="channel-topbar-space" :style="topbarSpaceStyle"></view>
</view>
</view>
<scroll-view class="channel-detail-scroll" direction="vertical">
<view v-if="channel != null" class="channel-detail-header" :style="{ backgroundColor: channelBgColor }">
<view class="channel-detail-title-row">
<text class="channel-detail-title">{{ channelTitle }}</text>
<text class="channel-detail-badge" :style="{ color: channelThemeColor, borderColor: channelThemeColor }">{{ channelBadge }}</text>
</view>
<text class="channel-detail-subtitle">{{ channelSubtitle }}</text>
</view>
<view v-if="channel != null" class="channel-section-card">
<text class="channel-section-title">频道精选</text>
<view class="channel-product-grid">
<view
v-for="(product, index) in seedProducts"
:key="buildChannelKey('seed', product.id, index)"
class="channel-product-card"
@tap="handleMockProductClick(product)"
>
<image class="channel-product-cover" :src="getChannelProductImage(product)" mode="aspectFill" />
<text class="channel-product-title">{{ product.name }}</text>
<view class="channel-product-price-row">
<text class="channel-product-tag" :style="{ color: channelThemeColor }">{{ product.tag }}</text>
<text class="channel-product-price" :style="{ color: channelThemeColor }">¥{{ formatChannelPrice(product.price) }}</text>
</view>
<text v-if="product.marketPrice > product.price" class="channel-product-market-price">¥{{ formatChannelPrice(product.marketPrice) }}</text>
</view>
</view>
</view>
<view v-if="channel != null && moreProducts.length > 0" class="channel-section-card">
<text class="channel-section-title">更多好物</text>
<view class="channel-product-grid">
<view
v-for="(product, index) in moreProducts"
:key="buildChannelKey('more', product.id, index)"
class="channel-product-card"
@tap="handleMockProductClick(product)"
>
<image class="channel-product-cover" :src="getChannelProductImage(product)" mode="aspectFill" />
<text class="channel-product-title">{{ product.name }}</text>
<view class="channel-product-price-row">
<text class="channel-product-tag" :style="{ color: channelThemeColor }">{{ product.tag }}</text>
<text class="channel-product-price" :style="{ color: channelThemeColor }">¥{{ formatChannelPrice(product.price) }}</text>
</view>
<text v-if="product.marketPrice > product.price" class="channel-product-market-price">¥{{ formatChannelPrice(product.marketPrice) }}</text>
</view>
</view>
</view>
<view v-if="channel == null" class="channel-empty-state">
<text class="channel-empty-title">频道信息暂不可用</text>
<text class="channel-empty-desc">请返回首页重新进入频道</text>
</view>
<view class="channel-safe-area"></view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getChannelDetailData } from '@/utils/mockChannelData.uts'
import type { MarketingChannel, ChannelProduct } from '@/utils/mockChannelData.uts'
const channel = ref<MarketingChannel | null>(null)
const seedProducts = ref<ChannelProduct[]>([])
const moreProducts = ref<ChannelProduct[]>([])
const pageTitle = ref('频道详情')
const channelTitle = ref('频道详情')
const channelSubtitle = ref('')
const channelBadge = ref('')
const channelThemeColor = ref('#ff6b35')
const channelBgColor = ref('#f5f5f5')
const statusBarHeight = ref(0)
const navBarTop = ref(0)
const navBarHeight = ref(44)
const navRightReserve = ref(0)
const topbarStyle = ref('padding-top:0px;height:88rpx;')
const topbarRowStyle = ref('padding-left:20rpx;padding-right:12px;')
const titleWrapStyle = ref('left:0px;right:0px;')
const topbarSpaceStyle = ref('width:64rpx;height:64rpx;')
function syncTopbarStyle(): void {
const top = navBarTop.value > 0 ? navBarTop.value : statusBarHeight.value
const height = navBarHeight.value > 0 ? navBarHeight.value : 44
const rightReserve = navRightReserve.value > 0 ? navRightReserve.value : 12
topbarStyle.value = 'padding-top:' + top + 'px;height:' + (top + height + 12) + 'px;'
topbarRowStyle.value = 'padding-left:20rpx;padding-right:' + rightReserve + 'px;'
titleWrapStyle.value = 'left:0px;right:0px;'
topbarSpaceStyle.value = 'width:' + rightReserve + 'px;height:64rpx;'
}
function initPageLayout(): void {
try {
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight ?? 0
navBarTop.value = statusBarHeight.value
navBarHeight.value = 44
navRightReserve.value = 12
// #ifdef MP-WEIXIN
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
if (menuBtn != null && menuBtn.top != null && menuBtn.top > 0) {
navBarTop.value = menuBtn.top
navBarHeight.value = menuBtn.height
navRightReserve.value = (windowInfo.windowWidth - menuBtn.left) + 8
}
} catch (e) {
navBarTop.value = statusBarHeight.value
navBarHeight.value = 44
navRightReserve.value = 12
}
// #endif
} catch (e) {
statusBarHeight.value = 0
navBarTop.value = 0
navBarHeight.value = 44
navRightReserve.value = 12
}
syncTopbarStyle()
}
function buildChannelKey(prefix: string, id: string, index: number): string {
const normalizedId = id !== '' ? id : 'empty'
return prefix + '-' + normalizedId + '-' + index.toString()
}
function formatChannelPrice(price: number): string {
const rounded = Math.round(price)
if (Math.abs(price - rounded) < 0.001) {
return rounded.toString()
}
return price.toFixed(1)
}
function getChannelProductImage(product: ChannelProduct): string {
if (product.image != null && product.image !== '') {
return product.image
}
return '/static/images/default.png'
}
function applyChannel(channelData: MarketingChannel | null): void {
channel.value = channelData
if (channelData == null) {
seedProducts.value = []
moreProducts.value = []
pageTitle.value = '频道详情'
channelTitle.value = '频道详情'
channelSubtitle.value = ''
channelBadge.value = ''
channelThemeColor.value = '#ff6b35'
channelBgColor.value = '#f5f5f5'
return
}
pageTitle.value = channelData.title
channelTitle.value = channelData.title
channelSubtitle.value = channelData.subtitle
channelBadge.value = channelData.badge
channelThemeColor.value = channelData.themeColor
channelBgColor.value = channelData.bgColor
seedProducts.value = channelData.products
moreProducts.value = []
setTimeout(() => {
moreProducts.value = channelData.moreProducts
}, 30)
}
function handleMockProductClick(product: ChannelProduct): void {
uni.showToast({
title: product.shortName + ' 为 mock 商品',
icon: 'none'
})
}
function decodeQueryValue(value: string, fallbackValue: string): string {
if (value === '') {
return fallbackValue
}
try {
const decodedValue = decodeURIComponent(value)
if (decodedValue != null && decodedValue !== '') {
return decodedValue
}
return fallbackValue
} catch (e) {
return value !== '' ? value : fallbackValue
}
}
function normalizeLoadOptions(options: UTSJSONObject | null): UTSJSONObject {
if (options == null) {
return new UTSJSONObject()
}
if (options instanceof UTSJSONObject) {
return options
}
try {
return JSON.parse(JSON.stringify(options)) as UTSJSONObject
} catch (e) {
return new UTSJSONObject()
}
}
function resolveQueryValue(options: UTSJSONObject, key: string): string {
const value = options.getString(key) ?? ''
if (value !== '') {
return value
}
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const rawPageOptions = currentPage.options ?? null
const pageOptions = normalizeLoadOptions(rawPageOptions as UTSJSONObject | null)
return pageOptions.getString(key) ?? ''
}
return ''
}
function goBack(): void {
uni.navigateBack({ delta: 1 })
}
onLoad((options) => {
initPageLayout()
let channelId = 'subsidy'
let routeType = 'subsidy'
let categoryId = 'recommend'
try {
const optionObj = normalizeLoadOptions(options as UTSJSONObject | null)
channelId = decodeQueryValue(resolveQueryValue(optionObj, 'channelId'), 'subsidy')
routeType = decodeQueryValue(resolveQueryValue(optionObj, 'routeType'), 'subsidy')
categoryId = decodeQueryValue(resolveQueryValue(optionObj, 'categoryId'), 'recommend')
} catch (e) {
channelId = 'subsidy'
routeType = 'subsidy'
categoryId = 'recommend'
}
applyChannel(getChannelDetailData(channelId, routeType, categoryId))
})
</script>
<style>
.channel-detail-page {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.channel-topbar {
box-sizing: border-box;
background: #f5f5f5;
flex-shrink: 0;
}
.channel-topbar-row {
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
position: relative;
}
.channel-topbar-back {
flex: 0 0 64rpx;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.channel-topbar-back-text {
font-size: 52rpx;
line-height: 1;
color: #202020;
transform: translateY(-3rpx);
}
.channel-topbar-title-wrap {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
pointer-events: none;
}
.channel-topbar-title {
font-size: 28rpx;
font-weight: 700;
color: #202020;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-topbar-space {
flex-shrink: 0;
height: 64rpx;
}
.channel-detail-scroll {
flex: 1;
min-height: 0;
width: 100%;
padding: 20rpx 20rpx 0;
box-sizing: border-box;
}
.channel-detail-header {
border-radius: 28rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.05);
}
.channel-detail-title-row {
display: flex;
flex-direction: row;
align-items: center;
}
.channel-detail-title {
font-size: 36rpx;
font-weight: 800;
color: #202020;
line-height: 1.2;
margin-right: 10rpx;
}
.channel-detail-badge {
font-size: 20rpx;
line-height: 1.2;
border-width: 1rpx;
border-style: solid;
border-radius: 8rpx;
padding: 4rpx 8rpx;
background: rgba(255, 255, 255, 0.72);
}
.channel-detail-subtitle {
font-size: 24rpx;
color: #666666;
line-height: 1.4;
margin-top: 10rpx;
}
.channel-section-card {
background: #ffffff;
border-radius: 24rpx;
padding: 22rpx;
margin-bottom: 20rpx;
box-shadow: 0 3rpx 10rpx rgba(0, 0, 0, 0.04);
}
.channel-section-title {
font-size: 28rpx;
font-weight: 700;
color: #202020;
line-height: 1.2;
margin-bottom: 16rpx;
}
.channel-product-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.channel-product-card {
width: 48.5%;
background: #fafafa;
border-radius: 18rpx;
padding: 14rpx;
box-sizing: border-box;
margin-bottom: 16rpx;
display: flex;
flex-direction: column;
}
.channel-product-cover {
width: 100%;
height: 220rpx;
border-radius: 16rpx;
background: #ffffff;
}
.channel-product-title {
font-size: 24rpx;
line-height: 1.35;
color: #333333;
height: 66rpx;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 10rpx;
}
.channel-product-price-row {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 8rpx;
}
.channel-product-tag {
font-size: 19rpx;
margin-right: 6rpx;
}
.channel-product-price {
font-size: 28rpx;
font-weight: 800;
line-height: 1.2;
}
.channel-product-market-price {
font-size: 20rpx;
color: #999999;
text-decoration: line-through;
margin-top: 6rpx;
}
.channel-empty-state {
background: #ffffff;
border-radius: 24rpx;
padding: 50rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 30rpx;
}
.channel-empty-title {
font-size: 28rpx;
font-weight: 700;
color: #333333;
margin-bottom: 10rpx;
}
.channel-empty-desc {
font-size: 22rpx;
color: #888888;
}
.channel-safe-area {
height: 40rpx;
width: 100%;
}
</style>