完成consumer端同步
This commit is contained in:
22
components/consumer/EmptyState.uvue
Normal file
22
components/consumer/EmptyState.uvue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<view class="empty-state">
|
||||
<image class="empty-img" :src="image" v-if="image" />
|
||||
<text class="empty-text">{{ text }}</text>
|
||||
<slot name="action"></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
text: { type: String, default: '暂无数据' },
|
||||
image: { type: String, default: '/static/icons/empty.png' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:60rpx 20rpx }
|
||||
.empty-img { width:160rpx; height:160rpx; margin-bottom:24rpx }
|
||||
.empty-text { color:#999; font-size:28rpx }
|
||||
</style>
|
||||
58
components/consumer/GoodsCard.uvue
Normal file
58
components/consumer/GoodsCard.uvue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<view class="goods-card" @click="openDetail" :class="{ compact: compact }">
|
||||
<image class="thumb" :src="cover" mode="aspectFill" @error="onImageError" />
|
||||
<view class="meta">
|
||||
<text class="title" :number-of-lines="2">{{ product.name || product.title || product.product_name }}</text>
|
||||
<view class="price-row">
|
||||
<PriceText :price="product.price || product.base_price || product.product_price" :originalPrice="product.original_price || product.market_price || product.originalPrice" />
|
||||
<text class="sales">已售 {{ product.sale_count || product.sales || product.sold || 0 }}</text>
|
||||
</view>
|
||||
<view class="tags-row">
|
||||
<text v-if="product.badge" class="tag">{{ product.badge }}</text>
|
||||
<text v-if="product.manufacturer" class="shop">{{ product.manufacturer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import PriceText from './PriceText.uvue'
|
||||
export default {
|
||||
components: { PriceText },
|
||||
props: {
|
||||
product: { type: Object, default: () => ({}) },
|
||||
goodsType: { type: String, default: 'normal' },
|
||||
compact: { type: Boolean, default: false },
|
||||
},
|
||||
data() { return { cover: '' } },
|
||||
created() {
|
||||
this.cover = this.product.main_image_url || this.product.image || this.product.image_url || '/static/images/default-product.png'
|
||||
},
|
||||
methods: {
|
||||
openDetail() {
|
||||
const id = this.product.id || this.product.product_id || ''
|
||||
try { uni.navigateTo({ url: '/pages/mall/consumer/product-detail?productId=' + id }) } catch (e) {}
|
||||
},
|
||||
onImageError() { this.cover = '/static/images/default-product.png' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.goods-card { display:flex; background:#fff; border-radius:20rpx; overflow:hidden; box-shadow:0 2rpx 8rpx rgba(0,0,0,0.04); padding:12rpx }
|
||||
.thumb { width:320rpx; height:320rpx; border-radius:12rpx }
|
||||
.meta { flex:1; padding-left:16rpx; display:flex; flex-direction:column; justify-content:space-between }
|
||||
.title { font-size:26rpx; color:#262626; line-height:34rpx }
|
||||
.price-row { display:flex; align-items:center; justify-content:space-between; margin-top:10rpx }
|
||||
.sales { font-size:22rpx; color:#999 }
|
||||
.tags-row { margin-top:8rpx; display:flex }
|
||||
.tag { background:#fff3f0; color:#ff4d4f; padding:6rpx 10rpx; border-radius:12rpx; font-size:20rpx }
|
||||
.shop { font-size:22rpx; color:#8c8c8c }
|
||||
|
||||
/* Compact mode for grid (two-column) */
|
||||
.goods-card.compact { flex-direction:column; padding:10rpx; border-radius:12rpx }
|
||||
.goods-card.compact .thumb { width:100%; height:240rpx; border-radius:10rpx }
|
||||
.goods-card.compact .meta { padding-left:0; padding-top:10rpx }
|
||||
.goods-card.compact .title { font-size:24rpx; height:48rpx }
|
||||
.goods-card.compact .price-row { margin-top:6rpx }
|
||||
</style>
|
||||
257
components/consumer/PddWaterfallCard.uvue
Normal file
257
components/consumer/PddWaterfallCard.uvue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<view class="pdd-card" @tap="handleTap">
|
||||
<image
|
||||
class="pdd-card-image"
|
||||
:src="cover"
|
||||
mode="aspectFill"
|
||||
lazy-load="true"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<view class="pdd-card-body">
|
||||
<view class="pdd-title-wrap">
|
||||
<view v-if="promoTags.length > 0" class="pdd-inline-tags">
|
||||
<text
|
||||
v-for="(tag, index) in promoTags"
|
||||
:key="tag.code + '-' + index"
|
||||
:class="['pdd-inline-tag', getTagClass(tag.code)]"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="pdd-title-text">{{ product.title }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pdd-price-row">
|
||||
<text class="pdd-price-symbol">{{ currencySymbol }}</text>
|
||||
<text class="pdd-price-int">{{ priceInteger }}</text>
|
||||
<text class="pdd-price-decimal">.{{ priceDecimal }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pdd-sales-wrap">
|
||||
<text class="pdd-sales-text">{{ product.salesText }}</text>
|
||||
<text class="pdd-sales-text">{{ product.seedText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
type PddProductCardData = {
|
||||
id: string
|
||||
title: string
|
||||
image: string
|
||||
tags: string[]
|
||||
price: number
|
||||
salesText: string
|
||||
seedText: string
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
product: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
id: '',
|
||||
title: '',
|
||||
image: '',
|
||||
tags: [] as string[],
|
||||
price: 0,
|
||||
salesText: '',
|
||||
seedText: ''
|
||||
})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cover: '',
|
||||
currencySymbol: '\u00A5',
|
||||
priceInteger: '0',
|
||||
priceDecimal: '00',
|
||||
promoTags: [] as Array<{ code: string, label: string }>
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncViewData()
|
||||
},
|
||||
watch: {
|
||||
product: {
|
||||
handler() {
|
||||
this.syncViewData()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
syncViewData() {
|
||||
const product = this.product as PddProductCardData
|
||||
const image = product.image != null && product.image !== '' ? product.image : '/static/images/default-product.png'
|
||||
this.cover = image
|
||||
|
||||
const price = product.price != null ? product.price : 0
|
||||
const priceText = Number(price).toFixed(2)
|
||||
const segments = priceText.split('.')
|
||||
this.priceInteger = segments.length > 0 ? segments[0] : '0'
|
||||
this.priceDecimal = segments.length > 1 ? segments[1] : '00'
|
||||
|
||||
const tags = Array.isArray(product.tags) ? product.tags : []
|
||||
const nextTags: Array<{ code: string, label: string }> = []
|
||||
for (let i = 0; i < tags.length && i < 2; i++) {
|
||||
const code = tags[i]
|
||||
nextTags.push({
|
||||
code,
|
||||
label: this.getTagLabel(code)
|
||||
})
|
||||
}
|
||||
this.promoTags = nextTags
|
||||
},
|
||||
getTagLabel(tagCode: string): string {
|
||||
if (tagCode == 'billion_subsidy') return '\u767E\u4EBF\u8865\u8D34'
|
||||
if (tagCode == 'official_subsidy') return '\u5B98\u65B9\u8865\u8D34'
|
||||
if (tagCode == 'pay_later') return '\u5148\u7528\u540E\u4ED8'
|
||||
if (tagCode == 'delivery_48h') return '\u540E\u5929\u8FBE'
|
||||
return '\u70ED\u5356'
|
||||
},
|
||||
getTagClass(tagCode: string): string {
|
||||
if (tagCode == 'billion_subsidy') return 'tag-billion'
|
||||
if (tagCode == 'official_subsidy') return 'tag-official'
|
||||
if (tagCode == 'pay_later') return 'tag-pay-later'
|
||||
if (tagCode == 'delivery_48h') return 'tag-delivery'
|
||||
return 'tag-default'
|
||||
},
|
||||
handleImageError() {
|
||||
this.cover = '/static/images/default-product.png'
|
||||
},
|
||||
handleTap() {
|
||||
this.$emit('select', this.product)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdd-card {
|
||||
background: #ffffff;
|
||||
border-radius: 18rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2rpx 10rpx rgba(237, 42, 69, 0.06);
|
||||
}
|
||||
|
||||
.pdd-card-image {
|
||||
width: 100%;
|
||||
height: 344rpx;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.pdd-card-body {
|
||||
padding: 10rpx 10rpx 12rpx;
|
||||
}
|
||||
|
||||
.pdd-title-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
height: 72rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.pdd-inline-tags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
|
||||
.pdd-inline-tag {
|
||||
height: 28rpx;
|
||||
line-height: 28rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
margin-right: 6rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tag-billion {
|
||||
background: linear-gradient(90deg, #ff2d55 0%, #ff7a00 100%);
|
||||
color: #fff7d1;
|
||||
}
|
||||
|
||||
.tag-official {
|
||||
background: #ff4d00;
|
||||
color: #fff2b3;
|
||||
}
|
||||
|
||||
.tag-pay-later {
|
||||
background: #ffd84d;
|
||||
color: #9c1f00;
|
||||
}
|
||||
|
||||
.tag-delivery {
|
||||
background: #22c55e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tag-default {
|
||||
background: #ffedd5;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.pdd-title-text {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
line-height: 32rpx;
|
||||
color: #222222;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdd-price-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
margin-top: 2rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.pdd-price-symbol {
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
color: #ff1030;
|
||||
font-weight: 700;
|
||||
margin-right: 2rpx;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.pdd-price-int {
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
color: #ff1030;
|
||||
font-weight: 800;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.pdd-price-decimal {
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
color: #ff1030;
|
||||
font-weight: 700;
|
||||
margin-left: 2rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.pdd-sales-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pdd-sales-text {
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
color: #8f8f8f;
|
||||
}
|
||||
</style>
|
||||
144
components/consumer/PddWaterfallFeed.uvue
Normal file
144
components/consumer/PddWaterfallFeed.uvue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<list-view
|
||||
class="pdd-feed"
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="240"
|
||||
@scrolltolower="handleLoadMore"
|
||||
>
|
||||
<list-item v-for="(row, rowIndex) in rows" :key="'row-' + rowIndex" class="pdd-row-item">
|
||||
<view class="pdd-row">
|
||||
<view class="pdd-col">
|
||||
<PddWaterfallCard
|
||||
v-if="row.left != null"
|
||||
:product="row.left"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</view>
|
||||
<view class="pdd-col">
|
||||
<PddWaterfallCard
|
||||
v-if="row.right != null"
|
||||
:product="row.right"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</list-item>
|
||||
|
||||
<list-item class="pdd-footer-item">
|
||||
<view class="pdd-footer">
|
||||
<text class="pdd-footer-text">{{ getFooterText() }}</text>
|
||||
</view>
|
||||
</list-item>
|
||||
</list-view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import PddWaterfallCard from './PddWaterfallCard.uvue'
|
||||
|
||||
type FeedRow = {
|
||||
left: any | null
|
||||
right: any | null
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PddWaterfallCard
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
noMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rows: [] as FeedRow[]
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.buildRows()
|
||||
},
|
||||
watch: {
|
||||
items: {
|
||||
handler() {
|
||||
this.buildRows()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildRows() {
|
||||
const source = this.items as any[]
|
||||
const nextRows: FeedRow[] = []
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
const left = source[index]
|
||||
const right = index + 1 < source.length ? source[index + 1] : null
|
||||
nextRows.push({
|
||||
left,
|
||||
right
|
||||
})
|
||||
index += 2
|
||||
}
|
||||
this.rows = nextRows
|
||||
},
|
||||
handleLoadMore() {
|
||||
this.$emit('loadMore')
|
||||
},
|
||||
getFooterText(): string {
|
||||
if (this.loading) return '\u6B63\u5728\u52A0\u8F7D\u66F4\u591A\u5546\u54C1...'
|
||||
if (this.noMore) return '\u5DF2\u7ECF\u5230\u5E95\u4E86'
|
||||
return '\u7EE7\u7EED\u4E0A\u6ED1\uFF0C\u53D1\u73B0\u66F4\u591A\u4F4E\u4EF7\u597D\u7269'
|
||||
},
|
||||
handleSelect(product: any) {
|
||||
this.$emit('select', product)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdd-feed {
|
||||
flex: 1;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.pdd-row-item {
|
||||
padding-left: 12rpx;
|
||||
padding-right: 12rpx;
|
||||
}
|
||||
|
||||
.pdd-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding-top: 12rpx;
|
||||
}
|
||||
|
||||
.pdd-col {
|
||||
width: 49.1%;
|
||||
}
|
||||
|
||||
.pdd-footer-item {
|
||||
padding: 18rpx 0 26rpx;
|
||||
}
|
||||
|
||||
.pdd-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdd-footer-text {
|
||||
font-size: 22rpx;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
</style>
|
||||
27
components/consumer/PriceText.uvue
Normal file
27
components/consumer/PriceText.uvue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<view class="price-text">
|
||||
<text class="currency">¥</text>
|
||||
<text class="amount">{{ displayPrice }}</text>
|
||||
<text v-if="originalPrice" class="original">{{ formattedOriginal }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
price: { type: Number, default: 0 },
|
||||
originalPrice: { type: Number, default: 0 }
|
||||
},
|
||||
computed: {
|
||||
displayPrice() { return (this.price || 0).toFixed(2) },
|
||||
formattedOriginal() { return '¥' + (this.originalPrice || 0).toFixed(2) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.price-text { display:flex; align-items:baseline }
|
||||
.currency { font-size:20rpx; color:#ff4d4f; margin-right:6rpx }
|
||||
.amount { font-size:34rpx; color:#ff4d4f; font-weight:700 }
|
||||
.original { font-size:22rpx; color:#999; text-decoration:line-through; margin-left:8rpx }
|
||||
</style>
|
||||
88
components/consumer/SearchBar.uvue
Normal file
88
components/consumer/SearchBar.uvue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="search-bar" v-if="mode==='readonly'" :style="{ paddingRight: internalCapsuleRight + 'px' }">
|
||||
<view class="search-box" @click="onClick">
|
||||
<image class="icon" src="/static/icons/search.png" />
|
||||
<text class="placeholder">{{ placeholder }}</text>
|
||||
</view>
|
||||
<view class="right-slot">
|
||||
<slot name="right"></slot>
|
||||
<view v-if="showActionButton" class="action-btn" @click.stop="onActionClick">搜索</view>
|
||||
<image v-if="showCamera" class="camera-icon" src="/static/icons/camera.png" @click.stop="$emit('camera')" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="search-bar" v-else :style="{ paddingRight: internalCapsuleRight + 'px' }">
|
||||
<view class="search-box">
|
||||
<image class="icon" src="/static/icons/search.png" />
|
||||
<input class="search-input" :placeholder="placeholder" v-model="internalValue" @input="onInput" @confirm="onConfirm" />
|
||||
</view>
|
||||
<view class="right-slot">
|
||||
<slot name="right"></slot>
|
||||
<view v-if="showActionButton" class="action-btn" @click.stop="onActionClick">搜索</view>
|
||||
<image v-if="showCamera" class="camera-icon" src="/static/icons/camera.png" @click.stop="$emit('camera')" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { getNavMetrics } from '@/utils/navUtils.uts'
|
||||
export default {
|
||||
props: {
|
||||
placeholder: { type: String, default: '搜索商品、店铺…' },
|
||||
mode: { type: String, default: 'readonly' }, // 'readonly' | 'input'
|
||||
autoNavigate: { type: Boolean, default: true },
|
||||
capsuleRight: { type: [Number, String], default: 0 },
|
||||
showActionButton: { type: Boolean, default: true },
|
||||
showCamera: { type: Boolean, default: false },
|
||||
value: { type: String, default: '' }
|
||||
},
|
||||
created() {
|
||||
// 计算默认的胶囊右侧预留
|
||||
try {
|
||||
const metrics = getNavMetrics()
|
||||
// 如果传入 props capsuleRight 为 0 或空,则使用自动计算值
|
||||
this.internalCapsuleRight = (this.capsuleRight && Number(this.capsuleRight) > 0) ? Number(this.capsuleRight) : (metrics.navRightReserve || 0)
|
||||
} catch (e) {
|
||||
this.internalCapsuleRight = (this.capsuleRight && Number(this.capsuleRight) > 0) ? Number(this.capsuleRight) : 0
|
||||
}
|
||||
},
|
||||
data() { return { internalValue: this.value, internalCapsuleRight: 0 } },
|
||||
watch: {
|
||||
value(newVal) { this.internalValue = newVal }
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('click')
|
||||
if (this.mode === 'readonly' && this.autoNavigate) {
|
||||
try { uni.navigateTo({ url: '/pages/mall/consumer/search' }) } catch (e) {}
|
||||
}
|
||||
},
|
||||
onActionClick() {
|
||||
// 优先触发 action 事件,包含当前输入或占位词
|
||||
const payload = (this.internalValue && this.internalValue.length > 0) ? this.internalValue : this.placeholder
|
||||
this.$emit('action', payload)
|
||||
},
|
||||
onInput(e) {
|
||||
const val = e && e.detail ? e.detail.value : (e || '')
|
||||
this.internalValue = val
|
||||
this.$emit('update:value', val)
|
||||
this.$emit('input', val)
|
||||
},
|
||||
onConfirm(e) {
|
||||
const confirmed = e && e.detail ? e.detail.value : this.internalValue
|
||||
this.$emit('confirm', confirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-bar { display:flex; align-items:center; padding:12rpx 16rpx; background:transparent }
|
||||
.search-box { flex:1; display:flex; align-items:center; background:#ffffff; border-radius:999rpx; padding:10rpx 12rpx; box-shadow:0 2rpx 8rpx rgba(0,0,0,0.04); height:44rpx; border:1rpx solid rgba(0,0,0,0.05) }
|
||||
.icon { width:36rpx; height:36rpx; margin-right:12rpx }
|
||||
.placeholder { color:#b8b8b8; font-size:26rpx; padding-right:6rpx }
|
||||
.search-input { flex:1; height:40rpx; font-size:26rpx; color:#222; padding:0 }
|
||||
.right-slot { margin-left:12rpx; display:flex; align-items:center }
|
||||
.action-btn { background:#ff5000; color:#fff; padding:10rpx 20rpx; border-radius:16rpx; font-size:26rpx; font-weight:500 }
|
||||
.camera-icon { width:28rpx; height:28rpx; margin-left:8rpx }
|
||||
</style>
|
||||
23
components/consumer/SkeletonCard.uvue
Normal file
23
components/consumer/SkeletonCard.uvue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<view class="skeleton-card">
|
||||
<view class="image-skel"></view>
|
||||
<view class="meta-skel">
|
||||
<view class="line short"></view>
|
||||
<view class="line"></view>
|
||||
<view class="line tiny"></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default { }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-card { display:flex; gap:16rpx; padding:16rpx; background:#fff; border-radius:16rpx }
|
||||
.image-skel { width:320rpx; height:320rpx; background:linear-gradient(90deg,#f0f0f0,#e8e8e8); border-radius:12rpx }
|
||||
.meta-skel { flex:1; display:flex; flex-direction:column; justify-content:space-between }
|
||||
.line { height:28rpx; background:linear-gradient(90deg,#f0f0f0,#e8e8e8); border-radius:8rpx; margin-bottom:12rpx }
|
||||
.line.short { width:50% }
|
||||
.line.tiny { width:30%; height:20rpx }
|
||||
</style>
|
||||
47
components/consumer/WaterfallList.uvue
Normal file
47
components/consumer/WaterfallList.uvue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<scroll-view class="waterfall" scroll-y="true" :lower-threshold="100" @scrolltolower="onLoadMore">
|
||||
<view class="columns">
|
||||
<view class="col" v-for="(colItems, idx) in columns" :key="idx">
|
||||
<block v-for="(item, i) in colItems" :key="item.id || i">
|
||||
<slot name="item" :item="item"></slot>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-if="!loading && noMore" class="no-more">没有更多了</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
columnsCount: { type: Number, default: 2 },
|
||||
loading: { type: Boolean, default: false },
|
||||
noMore: { type: Boolean, default: false }
|
||||
},
|
||||
data() { return { columns: [] } },
|
||||
watch: {
|
||||
items: { handler() { this.reflow() }, deep: true }
|
||||
},
|
||||
created() { this.reflow() },
|
||||
methods: {
|
||||
reflow() {
|
||||
const cols = []
|
||||
for (let i = 0; i < this.columnsCount; i++) cols.push([])
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
cols[i % this.columnsCount].push(this.items[i])
|
||||
}
|
||||
this.columns = cols
|
||||
},
|
||||
onLoadMore() { this.$emit('loadMore') }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.waterfall { padding:16rpx; background:transparent }
|
||||
.columns { display:flex; gap:16rpx }
|
||||
.col { flex:1; display:flex; flex-direction:column; gap:16rpx }
|
||||
.loading, .no-more { text-align:center; color:#999; padding:20rpx }
|
||||
</style>
|
||||
83
components/homeService/ServiceActionRow.uvue
Normal file
83
components/homeService/ServiceActionRow.uvue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<view class="action-row" :class="compact ? 'compact-row' : ''">
|
||||
<view v-for="item in actions" :key="item.key" class="action-btn" :class="item.tone" @click="handleClick(item.key)">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
type ServiceActionItem = {
|
||||
key: string
|
||||
label: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
defineProps({
|
||||
actions: {
|
||||
type: Array<ServiceActionItem>,
|
||||
default: [] as Array<ServiceActionItem>
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action', key: string): void
|
||||
}>()
|
||||
|
||||
function handleClick(key: string) {
|
||||
emit('action', key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-row {
|
||||
margin-top: 20rpx;
|
||||
flex-direction: row;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.compact-row {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: #ffffff;
|
||||
color: #16324f;
|
||||
border-width: 2rpx;
|
||||
border-style: solid;
|
||||
border-color: #d7e0ea;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.warn {
|
||||
background: #fff4e5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #e8f7ef;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.teal {
|
||||
background: #e6fffb;
|
||||
color: #0f766e;
|
||||
}
|
||||
</style>
|
||||
99
components/homeService/ServiceInfoCard.uvue
Normal file
99
components/homeService/ServiceInfoCard.uvue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<view class="info-card">
|
||||
<view class="header-row">
|
||||
<view>
|
||||
<text class="title">{{ title }}</text>
|
||||
<text class="code">{{ code }}</text>
|
||||
</view>
|
||||
<ServiceStatusTag v-if="statusText != ''" :text="statusText" :tone="statusTone"></ServiceStatusTag>
|
||||
</view>
|
||||
<view v-for="item in items" :key="item.label" class="info-line">
|
||||
<text class="info-label">{{ item.label }}</text>
|
||||
<text class="info-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
|
||||
|
||||
type ServiceInfoItem = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
code: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
statusText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
statusTone: {
|
||||
type: String,
|
||||
default: 'neutral'
|
||||
},
|
||||
items: {
|
||||
type: Array<ServiceInfoItem>,
|
||||
default: [] as Array<ServiceInfoItem>
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #16324f;
|
||||
}
|
||||
|
||||
.code,
|
||||
.info-label,
|
||||
.info-value {
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 38rpx;
|
||||
}
|
||||
|
||||
.code,
|
||||
.info-label {
|
||||
color: #66788a;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #16324f;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.info-line {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding-top: 14rpx;
|
||||
border-top-width: 1rpx;
|
||||
border-top-style: solid;
|
||||
border-top-color: #e2e8f0;
|
||||
margin-top: 14rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
</style>
|
||||
49
components/homeService/ServiceInfoList.uvue
Normal file
49
components/homeService/ServiceInfoList.uvue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<view class="info-list">
|
||||
<view v-for="item in items" :key="item.label" class="info-item">
|
||||
<text class="info-label">{{ item.label }}</text>
|
||||
<text class="info-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
type ServiceInfoListItem = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array<ServiceInfoListItem>,
|
||||
default: [] as Array<ServiceInfoListItem>
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-list {
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.info-label,
|
||||
.info-value {
|
||||
font-size: 26rpx;
|
||||
line-height: 38rpx;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #66788a;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: #16324f;
|
||||
}
|
||||
</style>
|
||||
58
components/homeService/ServicePanel.uvue
Normal file
58
components/homeService/ServicePanel.uvue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<view class="panel">
|
||||
<view class="panel-header">
|
||||
<view>
|
||||
<text class="panel-title">{{ title }}</text>
|
||||
<text v-if="subtitle != ''" class="panel-subtitle">{{ subtitle }}</text>
|
||||
</view>
|
||||
<slot name="extra"></slot>
|
||||
</view>
|
||||
<view class="panel-body">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #12324a;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #6b7a89;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
</style>
|
||||
63
components/homeService/ServiceStatusTag.uvue
Normal file
63
components/homeService/ServiceStatusTag.uvue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="status-tag" :class="'tag-' + tone">
|
||||
<text class="status-text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
default: 'neutral'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-tag {
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-primary {
|
||||
background-color: #e8f2ff;
|
||||
}
|
||||
|
||||
.tag-primary .status-text {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.tag-warning {
|
||||
background-color: #fff4e5;
|
||||
}
|
||||
|
||||
.tag-warning .status-text {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.tag-success {
|
||||
background-color: #e8f7ef;
|
||||
}
|
||||
|
||||
.tag-success .status-text {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.tag-neutral {
|
||||
background-color: #eef2f6;
|
||||
}
|
||||
|
||||
.tag-neutral .status-text {
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
67
components/homeService/ServiceTimeline.uvue
Normal file
67
components/homeService/ServiceTimeline.uvue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<view>
|
||||
<view v-if="items.length == 0" class="empty-box">
|
||||
<text class="empty-text">暂无过程记录</text>
|
||||
</view>
|
||||
<view v-for="item in items" :key="item.id" class="timeline-item">
|
||||
<view class="dot"></view>
|
||||
<view class="timeline-body">
|
||||
<text class="timeline-title">{{ item.title }}</text>
|
||||
<text class="timeline-time">{{ item.time }}</text>
|
||||
<text class="timeline-desc">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { HomeServiceTimelineItemType } from '@/types/home-service.uts'
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array<HomeServiceTimelineItemType>,
|
||||
default: [] as Array<HomeServiceTimelineItemType>
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-radius: 9rpx;
|
||||
background: #0f766e;
|
||||
margin-top: 10rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #16324f;
|
||||
}
|
||||
|
||||
.timeline-time,
|
||||
.timeline-desc,
|
||||
.empty-text {
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 38rpx;
|
||||
color: #66788a;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
padding: 24rpx 0;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -792,7 +792,12 @@ export class AkSupa {
|
||||
async hydrateSessionFromStorage() : Promise<boolean> {
|
||||
try {
|
||||
const token = AkReq.getToken();
|
||||
if (token == null || token == '') return false;
|
||||
if (token == null || token == '') {
|
||||
console.log('[HydrateSession] no persisted token found');
|
||||
return false;
|
||||
}
|
||||
const tokenPreview = token.length > 12 ? (token.substring(0, 6) + '...' + token.substring(token.length - 6)) : token;
|
||||
console.log('[HydrateSession] found persisted token:', tokenPreview);
|
||||
const res = await AkReq.request({
|
||||
url: this.baseUrl + '/auth/v1/user',
|
||||
method: 'GET',
|
||||
@@ -804,6 +809,7 @@ export class AkSupa {
|
||||
}, false);
|
||||
const status = res.status ?? 0;
|
||||
if (!(status >= 200 && status < 400)) {
|
||||
console.warn('[HydrateSession] /auth/v1/user failed with status:', status);
|
||||
return false;
|
||||
}
|
||||
let user: UTSJSONObject | null = null;
|
||||
@@ -812,7 +818,10 @@ export class AkSupa {
|
||||
} catch (e) {
|
||||
user = null;
|
||||
}
|
||||
if (user == null) return false;
|
||||
if (user == null) {
|
||||
console.warn('[HydrateSession] /auth/v1/user returned empty user payload');
|
||||
return false;
|
||||
}
|
||||
this.user = user;
|
||||
// 仅补齐最小 session 结构,供 getSession / UI 判断登录态使用
|
||||
if (this.session == null) {
|
||||
@@ -826,8 +835,10 @@ export class AkSupa {
|
||||
raw: user
|
||||
} as AkSupaSignInResult;
|
||||
}
|
||||
console.log('[HydrateSession] restored session for user:', user.getString('id') ?? 'unknown');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[HydrateSession] unexpected error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -850,6 +861,9 @@ export class AkSupa {
|
||||
async signOut() {
|
||||
this.session = null
|
||||
this.user = null
|
||||
try {
|
||||
AkReq.clearToken()
|
||||
} catch (e) {}
|
||||
}
|
||||
async signIn(email : string, password : string) : Promise<AkSupaSignInResult> {
|
||||
// 提前检查 apikey 配置是否为占位符,避免发送无效请求导致 401
|
||||
@@ -902,6 +916,13 @@ export class AkSupa {
|
||||
const expires_at = data.getNumber('expires_at') ?? 0;
|
||||
const user = data.getJSON('user');
|
||||
AkReq.setToken(access_token, refresh_token, expires_at);
|
||||
const storedAccessToken = AkReq.getToken() ?? '';
|
||||
const storedRefreshToken = AkReq.getRefreshToken() ?? '';
|
||||
const accessPreview = storedAccessToken.length > 12 ? (storedAccessToken.substring(0, 6) + '...' + storedAccessToken.substring(storedAccessToken.length - 6)) : storedAccessToken;
|
||||
const refreshPreview = storedRefreshToken.length > 12 ? (storedRefreshToken.substring(0, 6) + '...' + storedRefreshToken.substring(storedRefreshToken.length - 6)) : storedRefreshToken;
|
||||
console.log('[SignInPersist] access token stored:', accessPreview != '' ? accessPreview : '(empty)');
|
||||
console.log('[SignInPersist] refresh token stored:', refreshPreview != '' ? refreshPreview : '(empty)');
|
||||
console.log('[SignInPersist] expires_at stored:', AkReq.getExpiresAt() ?? 0);
|
||||
const session : AkSupaSignInResult = {
|
||||
access_token: access_token,
|
||||
refresh_token: refresh_token,
|
||||
|
||||
Reference in New Issue
Block a user