2025-12-15 11:02:37 +08:00

815 lines
23 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="inventory-grid">
<view v-for="(item, index) in shippedList" :key="index" class="inventory-item">
<!-- 已发货仅展示 -->
<image :src="item.image" mode="aspectFill" class="item-image" @error="onImageError(index, 'shipped')" />
<view class="item-info">
<text class="item-name">{{ item.name || '未命名道具' }}</text>
<text class="item-count">x{{ item.count || 1 }}</text>
<text class="item-status">已申请发货</text>
<text class="item-meta" v-if="item.express_code || item.express_no">快递{{ item.express_code }} {{ item.express_no }}</text>
<text class="item-meta" v-if="item.shipped_at">发货时间{{ formatDate(item.shipped_at) }}</text>
<text class="item-meta" v-if="item.received_at">签收时间{{ formatDate(item.received_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}`
}
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 }
const mapped = list.map(s => ({
image: '/static/logo.png',
name: '发货单',
count: s.count ?? (Array.isArray(s.inventory_ids) ? s.inventory_ids.length : 0),
express_code: s.express_code || '',
express_no: s.express_no || '',
shipped_at: s.shipped_at || '',
received_at: s.received_at || '',
status: s.status
}))
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) {
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 scoped>
.item-status {
font-size: 24rpx;
color: #007AFF;
margin-top: 4rpx;
}
.item-meta { font-size: 22rpx; color: #666; margin-top: 4rpx }
.wrap { padding: 30rpx; }
.tabs {
display: flex;
background: #f5f5f5;
border-radius: 16rpx;
padding: 8rpx;
margin-bottom: 20rpx;
}
.action-bar {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 0 10rpx;
}
.select-all {
display: flex;
align-items: center;
font-size: 28rpx;
color: #333;
}
.select-all .checkbox {
margin-right: 12rpx;
}
.tab-item {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #666;
padding: 20rpx 0;
border-radius: 12rpx;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
gap: 8rpx;
}
.tab-item.active {
background: #fff;
color: #007AFF;
font-weight: bold;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.tab-text {
font-size: 28rpx;
}
.tab-count {
font-size: 24rpx;
opacity: 0.8;
}
.header { font-size: 32rpx; font-weight: bold; margin-bottom: 30rpx; }
.status-text { text-align: center; color: #999; margin-top: 100rpx; }
.inventory-grid {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.inventory-item {
background: #fff;
border-radius: 12rpx;
padding: 24rpx;
display: flex;
flex-direction: row;
align-items: center;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
}
.item-image {
width: 120rpx;
height: 120rpx;
margin-right: 24rpx;
margin-bottom: 0;
border-radius: 8rpx;
background-color: #f5f5f5;
flex-shrink: 0;
}
.item-info {
flex: 1;
text-align: left;
display: flex;
flex-direction: column;
justify-content: center;
}
.item-name {
font-size: 26rpx;
color: #333;
display: block;
margin-bottom: 4rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-count {
font-size: 24rpx;
color: #999;
margin-bottom: 4rpx;
}
.item-price {
font-size: 24rpx;
color: #ff4d4f;
}
.checkbox-area {
padding: 10rpx 20rpx 10rpx 0;
display: flex;
align-items: center;
}
.checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ccc;
border-radius: 50%;
position: relative;
}
.checkbox.checked {
background-color: #007AFF;
border-color: #007AFF;
}
.checkbox.checked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%) rotate(45deg);
width: 10rpx;
height: 20rpx;
border-right: 4rpx solid #fff;
border-bottom: 4rpx solid #fff;
}
.item-actions {
margin-top: 10rpx;
display: flex;
align-items: center;
}
.stepper {
display: flex;
align-items: center;
border: 1px solid #ddd;
border-radius: 8rpx;
height: 48rpx;
}
.step-btn {
width: 48rpx;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f8f8;
font-size: 32rpx;
color: #666;
}
.step-btn.minus { border-right: 1px solid #ddd; }
.step-btn.plus { border-left: 1px solid #ddd; }
.step-num {
width: 60rpx;
text-align: center;
font-size: 26rpx;
color: #333;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30rpx;
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05);
z-index: 100;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.selected-info {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.btn-group {
display: flex;
gap: 20rpx;
}
.action-btn {
margin: 0;
height: 64rpx;
line-height: 64rpx;
font-size: 26rpx;
border-radius: 32rpx;
padding: 0 40rpx;
}
.btn-ship {
background-color: #f0ad4e;
color: #fff;
}
.btn-redeem {
background-color: #dd524d;
color: #fff;
}
.bottom-spacer {
height: 120rpx;
height: calc(120rpx + constant(safe-area-inset-bottom));
height: calc(120rpx + env(safe-area-inset-bottom));
}
</style>