邹方成 6f7207da2d feat: 优化UI设计并重构样式系统
refactor(components): 重构ElCard、FlipGrid、YifanSelector和PaymentPopup组件样式
refactor(pages): 优化地址管理、商品详情、订单列表、积分记录和活动页面UI
style: 更新uni.scss全局样式变量和设计系统
docs: 添加说明文档记录UI优化进度
2025-12-17 14:32:55 +08:00

1160 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="wrap">
<!-- 顶部 Tab -->
<view class="tabs">
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)">
<text class="tab-text">待处理</text>
<text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text>
</view>
<view class="tab-item" :class="{ active: currentTab === 1 }" @tap="switchTab(1)">
<text class="tab-text">已申请发货</text>
<text class="tab-count" v-if="shippedList.length > 0">({{ shippedList.length }})</text>
</view>
</view>
<!-- Tab 0: 待处理商品 -->
<block v-if="currentTab === 0">
<!-- 全选栏 -->
<view class="action-bar" v-if="aggregatedList.length > 0">
<view class="select-all" @tap="toggleSelectAll">
<view class="checkbox" :class="{ checked: isAllSelected }"></view>
<text>全选</text>
</view>
</view>
<view v-if="loading && aggregatedList.length === 0" class="status-text">加载中...</view>
<view v-else-if="!aggregatedList || aggregatedList.length === 0" class="status-text">背包空空如也</view>
<view v-else class="inventory-grid">
<view v-for="(item, index) in aggregatedList" :key="index" class="inventory-item">
<view class="checkbox-area" @tap.stop="toggleSelect(item)">
<view class="checkbox" :class="{ checked: item.selected }"></view>
</view>
<image :src="item.image" mode="aspectFill" class="item-image" @error="onImageError(index, 'aggregated')" />
<view class="item-info">
<text class="item-name">{{ item.name || '未命名道具' }}</text>
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
<view class="item-actions">
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
<view class="stepper" v-else @tap.stop>
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
<text class="step-num">{{ item.selectedCount }}</text>
<text class="step-btn plus" @tap.stop="changeCount(item, 1)">+</text>
</view>
</view>
</view>
</view>
</view>
<view v-if="loading && aggregatedList.length > 0" class="loading-more">加载更多...</view>
<view v-if="!hasMore && aggregatedList.length > 0" class="no-more">没有更多了</view>
<!-- 底部操作栏 -->
<view class="bottom-bar" v-if="hasSelected">
<view class="selected-info">已选 {{ totalSelectedCount }} 件</view>
<view class="btn-group">
<button class="action-btn btn-ship" @tap="onShip">发货</button>
<button class="action-btn btn-redeem" @tap="onRedeem">兑换</button>
</view>
</view>
<view class="bottom-spacer" v-if="hasSelected"></view>
</block>
<!-- Tab 1: 已申请发货 -->
<block v-if="currentTab === 1">
<view v-if="loading && shippedList.length === 0" class="status-text">加载中...</view>
<view v-else-if="!shippedList || shippedList.length === 0" class="status-text">暂无发货记录</view>
<view v-else class="shipment-list">
<view v-for="(item, index) in shippedList" :key="index" class="shipment-card">
<!-- 头部:批次信息和状态 -->
<view class="shipment-header">
<view class="shipment-batch">
<text class="batch-label">发货单</text>
<text class="batch-no" v-if="item.batch_no">{{ item.batch_no }}</text>
<view class="count-badge">{{ item.count }}件商品</view>
</view>
<view class="shipment-status" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</view>
</view>
<!-- 商品缩略图列表 -->
<view class="product-thumbnails">
<view class="thumb-scroll">
<image
v-for="(img, imgIdx) in item.product_images.slice(0, 4)"
:key="imgIdx"
:src="img"
mode="aspectFill"
class="thumb-img"
@error="onThumbError(index, imgIdx)"
/>
<view class="thumb-more" v-if="item.product_images.length > 4">
+{{ item.product_images.length - 4 }}
</view>
</view>
<!-- 商品名称列表 -->
<view class="product-names">
<text class="product-name-item" v-for="(name, nIdx) in item.product_names.slice(0, 3)" :key="nIdx">
{{ name }}
</text>
<text class="product-name-more" v-if="item.product_names.length > 3">
{{ item.product_names.length }}件商品
</text>
</view>
</view>
<!-- 物流信息 -->
<view class="shipment-express" v-if="item.express_code || item.express_no">
<view class="express-icon">
<text class="iconfont">📦</text>
</view>
<view class="express-info">
<text class="express-company">{{ item.express_code || '待发货' }}</text>
<text class="express-no" v-if="item.express_no">{{ item.express_no }}</text>
</view>
<text class="express-copy" v-if="item.express_no" @tap="copyExpressNo(item.express_no)">复制</text>
</view>
<!-- 时间信息 -->
<view class="shipment-time">
<text class="time-item" v-if="item.created_at">申请时间{{ formatDate(item.created_at) }}</text>
<text class="time-item" v-if="item.shipped_at">发货时间{{ formatDate(item.shipped_at) }}</text>
</view>
</view>
</view>
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
</block>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { getInventory, getProductDetail, redeemInventory, requestShipping, listAddresses, getShipments } from '@/api/appUser'
const currentTab = ref(0)
const aggregatedList = ref([])
const shippedList = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(100)
const hasMore = ref(true)
const totalCount = computed(() => {
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
})
const hasSelected = computed(() => {
return aggregatedList.value.some(item => item.selected)
})
const totalSelectedCount = computed(() => {
return aggregatedList.value.reduce((sum, item) => sum + (item.selected ? item.selectedCount : 0), 0)
})
const isAllSelected = computed(() => {
return aggregatedList.value.length > 0 && aggregatedList.value.every(item => item.selected)
})
onShow(() => {
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
console.log('cabinet onShow token:', token, 'isLogin:', !!token, 'phoneBound:', phoneBound)
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return
}
// 重置并加载第一页
page.value = 1
hasMore.value = true
aggregatedList.value = []
shippedList.value = []
const uid = uni.getStorageSync("user_id")
if (currentTab.value === 1) {
loadShipments(uid)
} else {
loadAllInventory(uid)
}
})
onReachBottom(() => {
if (hasMore.value && !loading.value) {
const uid = uni.getStorageSync("user_id")
if (currentTab.value === 1) {
loadShipments(uid)
} else {
loadInventory(uid)
}
}
})
function switchTab(index) {
currentTab.value = index
// 切换时重新加载数据
page.value = 1
hasMore.value = true
aggregatedList.value = []
shippedList.value = []
const uid = uni.getStorageSync("user_id")
if (currentTab.value === 1) {
loadShipments(uid)
} else {
loadAllInventory(uid)
}
}
function cleanUrl(u) {
if (!u) return '/static/logo.png'
let s = String(u).trim()
// 尝试解析 JSON 数组字符串
if (s.startsWith('[') && s.endsWith(']')) {
try {
const arr = JSON.parse(s)
if (Array.isArray(arr) && arr.length > 0) {
s = arr[0]
}
} catch (e) {
console.warn('JSON parse failed for image:', s)
}
}
// 清理反引号和空格
s = s.replace(/[`'"]/g, '').trim()
// 提取 http 链接
const m = s.match(/https?:\/\/[^\s]+/)
if (m && m[0]) return m[0]
return s || '/static/logo.png'
}
function onImageError(index, type = 'aggregated') {
if (type === 'aggregated' && aggregatedList.value[index]) {
aggregatedList.value[index].image = '/static/logo.png'
} else if (type === 'shipped' && shippedList.value[index]) {
shippedList.value[index].image = '/static/logo.png'
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const da = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${da} ${h}:${min}`
}
// 发货状态辅助函数
function getStatusClass(status) {
const statusMap = {
1: 'status-pending', // 待发货
2: 'status-shipped', // 已发货
3: 'status-delivered', // 已签收
4: 'status-cancelled' // 已取消
}
return statusMap[status] || 'status-pending'
}
function getStatusText(status) {
const statusMap = {
1: '待发货',
2: '运输中',
3: '已签收',
4: '已取消'
}
return statusMap[status] || '待发货'
}
function copyExpressNo(expressNo) {
uni.setClipboardData({
data: expressNo,
success: () => {
uni.showToast({ title: '已复制单号', icon: 'success' })
}
})
}
function onThumbError(shipmentIndex, imgIndex) {
if (shippedList.value[shipmentIndex] && shippedList.value[shipmentIndex].product_images) {
shippedList.value[shipmentIndex].product_images[imgIndex] = '/static/logo.png'
}
}
async function loadShipments(uid) {
if (loading.value) return
loading.value = true
try {
const res = await getShipments(uid, page.value, pageSize.value)
let list = []
let total = 0
if (res && Array.isArray(res.list)) { list = res.list; total = res.total || 0 }
else if (res && Array.isArray(res.data)) { list = res.data; total = res.total || 0 }
else if (Array.isArray(res)) { list = res; total = res.length }
// 处理每个发货单 - 直接使用返回的 products 数组
const mapped = list.map(s => {
const products = s.products || []
let productImages = []
let productNames = []
// 从 products 数组中提取图片和名称
products.forEach(product => {
if (product) {
// image 字段是 JSON 数组字符串,需要解析
const rawImg = product.image || product.images || product.main_image
const img = cleanUrl(rawImg)
productImages.push(img)
productNames.push(product.name || product.title || '商品')
}
})
// 如果没有获取到任何图片,使用默认图片
if (productImages.length === 0) {
productImages = ['/static/logo.png']
productNames = ['未知商品']
}
return {
batch_no: s.batch_no || '',
count: s.count ?? (Array.isArray(s.inventory_ids) ? s.inventory_ids.length : 0),
product_ids: s.product_ids || [],
product_images: productImages,
product_names: productNames,
express_code: s.express_code || '',
express_no: s.express_no || '',
created_at: s.created_at || '',
shipped_at: s.shipped_at || '',
received_at: s.received_at || '',
status: s.status || 1
}
})
const next = page.value === 1 ? mapped : [...shippedList.value, ...mapped]
shippedList.value = next
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) { hasMore.value = false } else { page.value += 1 }
if (list.length === 0) { hasMore.value = false }
} catch (e) {
console.error('Load shipments error:', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function loadInventory(uid) {
if (loading.value) return
loading.value = true
try {
const res = await getInventory(uid, page.value, pageSize.value)
console.log('Inventory loaded:', res)
let list = []
let total = 0
if (res && Array.isArray(res.list)) {
list = res.list
total = res.total || 0
} else if (res && Array.isArray(res.data)) { // 兼容 data: [] 格式
list = res.data
total = res.total || 0
} else if (Array.isArray(res)) { // 兼容直接返回数组
list = res
total = res.length
}
// 过滤 status=1 (正常) 或 status=3 (已使用/已发货/已兑换) 的物品
// status=1: 正常在背包
// status=3: 已处理(可能是已发货或已兑换积分)
const filteredList = list.filter(item => {
const s = Number(item.status)
return s === 1 || s === 3
})
// 调试日志:打印第一条数据以确认字段结构
if (filteredList.length > 0) {
console.log('Debug Inventory Item:', filteredList[0])
}
// 根据当前 Tab 过滤是否发货
const targetItems = filteredList.filter(item => {
// Tab 0: 待处理 (has_shipment 为 false 且 status=1)
// Tab 1: 已申请发货 (has_shipment 为 true)
// 注意API 返回的 has_shipment 可能是布尔值 true/false也可能是数字 1/0或者是字符串 "true"/"false"
// 这里做一个宽容的判断
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
if (currentTab.value === 1) {
// 已申请发货列表:必须是已发货状态
// 注意:有些记录 status=3 且 has_shipment=true 表示已发货
return isShipped
} else {
// 待处理列表:未发货且 status=1 (status=3 且未发货的可能是已兑换积分,不应显示在待处理)
return !isShipped && Number(item.status) === 1
}
})
console.log('Filtered list (status=1, tab=' + currentTab.value + '):', targetItems)
// 处理新数据
const newItems = targetItems.map(item => {
let imageUrl = ''
try {
let rawImg = item.product_images || item.image
if (rawImg && typeof rawImg === 'string') {
imageUrl = cleanUrl(rawImg)
}
} catch (e) {
console.error('Image parse error:', e)
}
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
return {
id: item.product_id || item.id, // 优先使用 product_id
original_ids: [item.id], // 初始化 id 数组
name: (item.product_name || item.name || '').trim(),
image: imageUrl,
count: 1,
selected: false,
selectedCount: 1,
has_shipment: isShipped,
updated_at: item.updated_at // 保留更新时间用于分组
}
})
console.log('Mapped new items:', newItems.length)
// 正确的聚合逻辑:
// 1. 如果是第一页,直接基于 newItems 生成初始列表(带去重聚合)
// 2. 如果是后续页,将 newItems 聚合到现有列表中
// 深拷贝当前列表
let currentList = currentTab.value === 1 ? shippedList : aggregatedList
let next = page.value === 1 ? [] : [...currentList.value]
if (currentTab.value === 1) {
// 已发货列表:按 updated_at 分组展示
// 这里我们实际上不按 ID 聚合,而是直接把新数据追加进去,
// 但为了 UI 展示,我们可以在前端通过 computed 或在这里预处理进行分组
// 为了保持与原有列表结构一致flat list我们这里暂时按照 updated_at + product_id 聚合
// 或者:既然用户要求按 updated_at 分组,可能希望看到的是“一次发货申请”作为一个卡片?
// 这里的实现逻辑是:如果 updated_at 和 product_id 都相同,则聚合数量;否则作为新条目
newItems.forEach(newItem => {
// 查找是否存在 updated_at 和 product_id 都相同的条目
// 注意updated_at 可能是 ISO 字符串,比较前最好截取到秒或直接比较字符串
const existingItem = next.find(i =>
i.id == newItem.id &&
new Date(i.updated_at).getTime() === new Date(newItem.updated_at).getTime()
)
if (existingItem) {
existingItem.count += 1
if (Array.isArray(existingItem.original_ids)) {
existingItem.original_ids.push(...newItem.original_ids)
}
} else {
next.push(newItem)
}
})
} else {
// 待处理列表:按 product_id (id) 聚合
newItems.forEach(newItem => {
if (!newItem.id) {
next.push(newItem)
return
}
const existingItem = next.find(i => i.id == newItem.id)
if (existingItem) {
existingItem.count += 1
if (Array.isArray(existingItem.original_ids)) {
existingItem.original_ids.push(...newItem.original_ids)
} else {
existingItem.original_ids = [...newItem.original_ids]
}
} else {
next.push(newItem)
}
})
}
console.log('Final aggregated list:', JSON.parse(JSON.stringify(next)))
if (currentTab.value === 1) {
shippedList.value = next
} else {
aggregatedList.value = next
}
// 判断是否还有更多
// 注意:这里的 total 是总记录数(未过滤 status=1 之前的),
// 我们的分页是基于原始数据的,所以判断依据是原始数据的分页进度
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) {
hasMore.value = false
} else {
page.value += 1
}
// 如果返回空列表(且 total 为 0也认为没有更多了
if (list.length === 0) {
hasMore.value = false
}
} catch (error) {
console.error('Failed to load inventory:', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function loadAllInventory(uid) {
try {
while (hasMore.value) {
await loadInventory(uid)
}
fetchProductPrices()
} catch (e) {}
}
async function fetchProductPrices() {
const currentList = currentTab.value === 1 ? shippedList : aggregatedList
const list = currentList.value
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item.id && !item.price) {
try {
const res = await getProductDetail(item.id)
if (res && (res.price !== undefined || res.data?.price !== undefined)) {
// 优先取 res.price其次 res.data.price (兼容不同返回结构)
const raw = res.price !== undefined ? res.price : res.data?.price
const num = Number(raw)
item.price = isNaN(num) ? null : (num / 100)
}
} catch (e) {
console.error('Fetch price failed for:', item.id, e)
}
}
}
}
function toggleSelect(item) {
item.selected = !item.selected
if (item.selected) {
// 选中时默认数量为最大值
item.selectedCount = item.count
}
}
function toggleSelectAll() {
const newState = !isAllSelected.value
aggregatedList.value.forEach(item => {
item.selected = newState
if (newState) {
item.selectedCount = item.count
}
})
}
function changeCount(item, delta) {
if (!item.selected) return
const newCount = item.selectedCount + delta
if (newCount >= 1 && newCount <= item.count) {
item.selectedCount = newCount
}
}
async function onRedeem() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
const selectedItems = aggregatedList.value.filter(item => item.selected)
if (selectedItems.length === 0) return
// 收集所有需要兑换的 inventory id
let allIds = []
selectedItems.forEach(item => {
// 确保有足够的 original_ids
if (item.original_ids && item.original_ids.length >= item.selectedCount) {
// 取出前 selectedCount 个 id
const idsToRedeem = item.original_ids.slice(0, item.selectedCount)
allIds.push(...idsToRedeem)
}
})
if (allIds.length === 0) {
uni.showToast({ title: '选择无效', icon: 'none' })
return
}
uni.showModal({
title: '确认兑换',
content: `确定要兑换选中的 ${allIds.length} 件物品吗?此操作不可撤销。`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' })
try {
await redeemInventory(user_id, allIds)
uni.showToast({ title: '兑换成功', icon: 'success' })
// 刷新列表
aggregatedList.value = []
page.value = 1
hasMore.value = true
loadAllInventory(user_id)
} catch (e) {
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
}
async function onShip() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
const selectedItems = aggregatedList.value.filter(item => item.selected)
if (selectedItems.length === 0) return
// 收集所有需要发货的 inventory id
let allIds = []
selectedItems.forEach(item => {
if (item.original_ids && item.original_ids.length >= item.selectedCount) {
const idsToShip = item.original_ids.slice(0, item.selectedCount)
allIds.push(...idsToShip)
}
})
if (allIds.length === 0) {
uni.showToast({ title: '选择无效', icon: 'none' })
return
}
// 2. 确认发货
uni.showModal({
title: '确认发货',
content: `${allIds.length} 件物品,确认申请发货?`,
confirmText: '确认发货',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '提交中...' })
try {
await requestShipping(user_id, allIds)
uni.showToast({ title: '申请成功', icon: 'success' })
// 刷新列表
aggregatedList.value = []
page.value = 1
hasMore.value = true
loadAllInventory(user_id)
} catch (e) {
uni.showToast({ title: e.message || '申请失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
}
</script>
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 货柜页面
采用现代卡片式布局,统一设计语言
============================================ */
.wrap {
padding: 0;
min-height: 100vh;
background: $bg-page;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
/* 顶部 Tab */
.tabs {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 88rpx;
background: rgba($bg-card, 0.9);
backdrop-filter: blur(20rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
box-shadow: $shadow-sm;
padding: 0;
margin: 0;
border-radius: 0;
}
.tab-item {
position: relative;
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: $text-sub;
transition: all 0.3s;
padding: 0;
border-radius: 0;
&.active {
color: $brand-primary;
font-weight: 700;
font-size: 30rpx;
background: transparent;
box-shadow: none;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background: $brand-primary;
border-radius: 4rpx;
}
}
}
.tab-count {
margin-left: 8rpx;
font-size: 20rpx;
background: rgba($brand-primary, 0.1);
padding: 2rpx 10rpx;
border-radius: 20rpx;
color: $brand-primary;
opacity: 1;
}
/* 状态提示 */
.status-text {
padding-top: 200rpx;
text-align: center;
color: $text-tertiary;
font-size: 28rpx;
margin-top: 0;
}
.loading-more, .no-more {
text-align: center;
padding: 30rpx;
color: $text-tertiary;
font-size: 24rpx;
}
/* Tab 0: 待处理列表 */
.action-bar {
margin-top: 108rpx; /* 88rpx tabs + 20rpx spacing */
padding: 0 $spacing-lg;
margin-bottom: 20rpx;
display: flex;
align-items: center;
}
.select-all {
display: flex;
align-items: center;
font-size: 28rpx;
color: $text-main;
}
.inventory-grid {
padding: 0 $spacing-lg;
margin-top: 108rpx; /* default margin if no action bar */
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* Adjust margin if action bar exists */
.action-bar + .inventory-grid {
margin-top: 0;
}
.inventory-item {
background: $bg-card;
border-radius: $radius-lg;
padding: 24rpx;
margin-bottom: 0; /* handled by gap */
display: flex;
flex-direction: row;
align-items: center;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.02);
transition: all 0.2s ease;
animation: fadeInUp 0.4s ease-out backwards;
@for $i from 1 through 10 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
&:active {
transform: scale(0.98);
}
}
.checkbox-area {
padding: 20rpx 20rpx 20rpx 0;
}
.checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid $text-tertiary;
border-radius: 50%;
transition: all 0.2s;
&.checked {
background: $brand-primary;
border-color: $brand-primary;
position: relative;
box-shadow: 0 0 10rpx rgba($brand-primary, 0.3);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 18rpx;
height: 10rpx;
border-left: 3rpx solid #fff;
border-bottom: 3rpx solid #fff;
transform: translate(-50%, -65%) rotate(-45deg);
}
}
}
.item-image {
width: 140rpx;
height: 140rpx;
border-radius: $radius-md;
background: $bg-page;
flex-shrink: 0;
margin-right: 0; /* reset old margin */
}
.item-info {
flex: 1;
margin-left: 24rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 140rpx;
text-align: left; /* reset old text-align */
}
.item-name {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
@include text-ellipsis(2);
margin-bottom: 0; /* reset old margin */
}
.item-price {
font-size: 24rpx;
color: $brand-primary;
margin-top: 8rpx;
font-weight: 600;
}
.item-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.item-count {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
}
.stepper {
display: flex;
align-items: center;
background: $bg-page;
border-radius: $radius-sm;
padding: 4rpx;
height: auto; /* reset old height */
border: none; /* reset old border */
}
.step-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: $text-main;
background: transparent;
&.minus { color: $text-sub; border: none; }
&.plus { color: $brand-primary; border: none; }
&:active { opacity: 0.6; background: transparent !important; transform: scale(0.9); }
}
.step-num {
min-width: 60rpx;
text-align: center;
font-size: 28rpx;
font-weight: 600;
color: $text-main;
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba($bg-card, 0.95);
backdrop-filter: blur(20rpx);
padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
z-index: 99;
height: auto; /* reset old height */
animation: slideUp 0.3s ease-out;
}
.selected-info {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
}
.btn-group {
display: flex;
gap: 20rpx;
}
.action-btn {
height: 72rpx;
padding: 0 40rpx;
border-radius: $radius-round;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
margin: 0;
line-height: 1; /* reset line-height */
&:active { transform: scale(0.96); }
}
.btn-ship {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
box-shadow: none; /* reset shadow */
}
.btn-redeem {
background: $gradient-brand;
color: #fff;
box-shadow: $shadow-warm;
animation: none; /* reset animation */
}
/* Tab 1: 已申请发货 */
.shipment-list {
padding: 0 $spacing-lg;
margin-top: 108rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.shipment-card {
background: $bg-card;
border-radius: $radius-lg;
padding: 30rpx;
margin-bottom: 0; /* handled by gap */
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.02);
animation: fadeInUp 0.4s ease-out backwards;
}
.shipment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid rgba(0,0,0,0.05);
}
.shipment-batch {
display: flex;
align-items: center;
gap: 12rpx;
}
.batch-label {
font-size: 28rpx;
font-weight: 700;
color: $text-main;
}
.batch-no {
font-size: 24rpx;
color: $text-sub;
font-family: monospace;
}
.count-badge {
font-size: 20rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 2rpx 10rpx;
border-radius: 8rpx;
}
.shipment-status {
font-size: 24rpx;
font-weight: 600;
padding: 4rpx 16rpx;
border-radius: 20rpx;
&.status-pending { background: #FFF7E6; color: #FA8C16; }
&.status-shipped { background: #E6F7FF; color: #1890FF; }
&.status-delivered { background: #F6FFED; color: #52C41A; }
&.status-cancelled { background: #F5F5F5; color: #999; }
}
.product-thumbnails {
margin-bottom: 24rpx;
}
.thumb-scroll {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
overflow-x: auto;
}
.thumb-img {
width: 100rpx;
height: 100rpx;
border-radius: $radius-sm;
background: $bg-page;
flex-shrink: 0;
border: 1rpx solid rgba(0,0,0,0.05);
}
.thumb-more {
width: 100rpx;
height: 100rpx;
border-radius: $radius-sm;
background: $bg-page;
display: flex;
align-items: center;
justify-content: center;
color: $text-sub;
font-size: 24rpx;
font-weight: 600;
flex-shrink: 0;
}
.product-names {
font-size: 24rpx;
color: $text-sub;
line-height: 1.4;
display: block; /* reset flex */
}
.product-name-item {
margin-right: 12rpx;
background: transparent; /* reset bg */
padding: 0;
&:not(:last-child)::after {
content: '、';
}
}
.shipment-express {
background: $bg-page;
border-radius: $radius-md;
padding: 16rpx;
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.express-icon {
width: 60rpx;
height: 60rpx;
background: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
margin-right: 16rpx;
}
.express-info {
flex: 1;
display: flex;
flex-direction: column;
}
.express-company {
font-size: 26rpx;
font-weight: 600;
color: $text-main;
}
.express-no {
font-size: 24rpx;
color: $text-sub;
margin-top: 4rpx;
}
.express-copy {
font-size: 22rpx;
color: $brand-primary;
padding: 6rpx 16rpx;
border: 1rpx solid $brand-primary;
border-radius: 20rpx;
background: transparent; /* reset bg */
box-shadow: none; /* reset shadow */
&:active { background: rgba($brand-primary, 0.05); }
}
.shipment-time {
display: flex;
flex-direction: column;
gap: 8rpx;
border-top: 1rpx dashed rgba(0,0,0,0.05);
padding-top: 20rpx;
}
.time-item {
font-size: 22rpx;
color: $text-tertiary;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.bottom-spacer {
height: 120rpx;
}
</style>