完成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,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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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,