shop-wx/src/components/storage-cells-summary/index.vue

639 lines
16 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { availableStorageCells, storeItemApi, openByPassword, resetByPassword } from '@/api/cabinet/index'
import type { AvailableStorageCellDTO } from '@/api/cabinet/types'
import { ref, computed, onMounted } from 'vue'
import { usePopupState } from './usePopupState'
// 格口类型映射
const CELL_TYPE_MAP = {
1: '小格',
2: '中格',
3: '大格',
4: '超大格'
} as const
// 组件 Props
interface Props {
/** 店铺ID用于获取可用格口列表 */
shopId: number
/** 是否自动加载数据 */
autoLoad?: boolean
/** 是否显示操作按钮 */
showButtons?: boolean
}
const props = withDefaults(defineProps<Props>(), {
autoLoad: true,
showButtons: true
})
// 组件 Emits
const emit = defineEmits<{
(e: 'deposit'): void
(e: 'retrieve'): void
(e: 'refresh'): void
(e: 'error', error: Error): void
(e: 'backToAddressSelect'): void
}>()
// 状态变量
const loading = ref(false)
const error = ref<Error | null>(null)
const cellsData = ref<AvailableStorageCellDTO[]>([])
const selectedCellType = ref<number>(1)
const depositLoading = ref(false)
const retrieveLoading = ref(false)
const generatedPassword = ref('')
// 弹窗状态管理
const {
currentState,
popupInputValue,
passwordLength,
popupVisible,
keyboardVisible,
popupTitle,
popupMessage,
popupConfirmText,
showCancelButton,
transitionTo,
handlePasswordInputChange,
handlePopupCancel,
handleKeyboardInput,
handleKeyboardDelete,
handleKeyboardClose
} = usePopupState()
// 统计计算属性
const availableCells = computed(() =>
cellsData.value.filter(cell => !cell.hasPassword)
)
const cellTypeStats = computed(() => {
const stats = new Map<number, number>()
availableCells.value.forEach(cell => {
const type = cell.cellType
stats.set(type, (stats.get(type) || 0) + 1)
})
// 确保所有四种类型都包含即使数量为0
const allTypes = [1, 2, 3, 4] as const
return allTypes.map(type => ({
type,
name: CELL_TYPE_MAP[type as keyof typeof CELL_TYPE_MAP] || '未知',
count: stats.get(type) || 0
}))
})
const hasAvailableCells = computed(() =>
availableCells.value.length > 0
)
// 数据获取逻辑
async function refresh() {
loading.value = true
error.value = null
try {
const response = await availableStorageCells(props.shopId)
cellsData.value = response.data || []
emit('refresh')
} catch (err) {
error.value = err as Error
emit('error', err as Error)
uni.showToast({
title: '数据加载失败',
icon: 'error'
})
} finally {
loading.value = false
}
}
// 事件处理函数
function handleDeposit() {
// 保持事件发射以向后兼容
emit('deposit')
// 调用新的存入流程函数
handleDepositFlow()
}
function handleRetrieve() {
// 保持事件发射以向后兼容
emit('retrieve')
// 调用新的取出流程函数
handleRetrieveFlow()
}
function handleBackToAddressSelect() {
emit('backToAddressSelect')
}
// 存入流程处理函数
async function handleDepositFlow() {
try {
depositLoading.value = true
// 1. 分配格口
const response = await storeItemApi({
shopId: props.shopId,
cellType: selectedCellType.value
})
const password = response.data?.password || ''
if (!password) {
throw new Error('格口分配失败,未获取到密码')
}
// 保存生成的密码以向后兼容
generatedPassword.value = password
// 2. 显示密码(状态转换)
transitionTo({ type: 'password-show', password })
// 注意:原流程在这里等待用户点击"已记住"
// 现在改为在弹窗确认事件中处理
} catch (error) {
// 错误处理
console.error('存入流程失败:', error)
uni.showToast({
title: (error as any)?.message || '操作失败',
icon: 'error',
duration: 3000
})
transitionTo({ type: 'idle' })
} finally {
depositLoading.value = false
}
}
// 弹窗相关事件处理
async function handlePopupConfirm() {
const state = currentState.value
switch (state.type) {
case 'password-show':
// 点击"已记住",进入密码验证
transitionTo({ type: 'password-verify', correctPassword: state.password })
break
case 'password-verify':
// 验证密码
if (popupInputValue.value !== state.correctPassword) {
uni.showToast({ title: '密码不正确', icon: 'error' })
return
}
// 打开格口
await performOpenByPassword(popupInputValue.value, 'deposit')
break
case 'retrieve-input':
// 打开格口
await performOpenByPassword(popupInputValue.value, 'retrieve')
break
}
}
async function performOpenByPassword(password: string, action: 'deposit' | 'retrieve') {
transitionTo({ type: 'processing', action })
try {
await openByPassword({
shopId: props.shopId,
password: String(password)
})
uni.showToast({
title: '格口已打开',
icon: 'success'
})
// 刷新数据
await refresh()
} catch (error) {
console.error('打开格口失败:', error)
uni.showToast({
title: (error as any)?.message || '操作失败',
icon: 'error',
duration: 3000
})
} finally {
transitionTo({ type: 'idle' })
}
}
// 取出流程处理函数
async function handleRetrieveFlow() {
// 直接进入密码输入状态
transitionTo({ type: 'retrieve-input' })
}
// 格口类型图标映射(需验证图标名称可用性)
const CELL_TYPE_ICON_MAP = {
1: 'box', // 小格
2: 'archive', // 中格
3: 'package', // 大格
4: 'cube' // 超大格
} as const
function getCellImg(type: number): string {
switch (type) {
case 1:
return '/static/svg/small-cell.svg'
case 2:
return '/static/svg/medium-cell.svg'
case 3:
return '/static/svg/large-cell.svg'
case 4:
return '/static/svg/extra-large-cell.svg'
default:
return '/static/svg/small-cell.svg'
}
}
// 处理格口类型选择
function handleCellTypeSelect(stat: { type: number; count: number }) {
if (stat.count === 0) {
uni.showToast({
title: '该类型格口暂不可用',
icon: 'none',
duration: 1500
})
return
}
selectedCellType.value = stat.type
}
// 生命周期钩子
onMounted(() => {
if (props.autoLoad !== false) {
refresh()
}
})
</script>
<template>
<view class="storage-cells-summary">
<!-- 标题区域 -->
<view class="summary-header">
<text class="header-title">本区域空余暂存柜子</text>
<view class="header-actions">
<view class="back-btn" @click="handleBackToAddressSelect">
<wd-icon name="arrow-left" size="12px"></wd-icon>
<text style="margin-left: 4px">重选地址</text>
</view>
<wd-button v-if="!loading" size="small" type="primary" plain @click="refresh">
刷新
</wd-button>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<wd-loading type="ring" />
<text class="loading-text">正在加载格口数据...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error-state">
<wd-icon name="warning" size="48px" color="#F56C6C" />
<text class="error-text">数据加载失败</text>
<wd-button size="small" type="primary" @click="refresh">重试</wd-button>
</view>
<!-- 空状态 -->
<view v-else-if="!hasAvailableCells" class="empty-state">
<wd-icon name="box" size="48px" color="#C0C4CC" />
<text class="empty-text">暂无可用格口</text>
</view>
<!-- 格口类型选择区域 -->
<view v-else class="cell-type-selection">
<view class="cell-type-grid">
<view
v-for="stat in cellTypeStats"
:key="stat.type"
class="cell-type-card"
:class="{
'cell-type-card--selected': selectedCellType === stat.type,
'cell-type-card--disabled': stat.count === 0
}"
@click="handleCellTypeSelect(stat)"
>
<view class="cell-type-icon">
<image :src="getCellImg(stat.type)" class="product-image">
</image>
</view>
<text class="cell-type-name">{{ stat.name }}</text>
<text class="cell-type-count">剩余{{ stat.count }}个可用</text>
<!-- <view v-if="selectedCellType === stat.type" class="cell-type-selected-indicator">
<wd-icon name="check" size="16px" color="#fff" />
</view> -->
</view>
</view>
</view>
<!-- 操作按钮区域 -->
<view v-if="props.showButtons && hasAvailableCells && !loading" class="action-buttons">
<wd-row :gutter="16">
<wd-col :span="12">
<wd-button type="primary" block @click="handleDeposit" :loading="depositLoading" :disabled="depositLoading || retrieveLoading">物品暂存</wd-button>
</wd-col>
<wd-col :span="12">
<wd-button type="success" block @click="handleRetrieve" :loading="retrieveLoading" :disabled="depositLoading || retrieveLoading">物品取出</wd-button>
</wd-col>
</wd-row>
</view>
<!-- wd-popup 弹窗 -->
<wd-popup
:model-value="popupVisible"
position="bottom"
:safe-area-inset-bottom="true"
custom-style="border-top-left-radius: 24rpx; border-top-right-radius: 24rpx; padding: 32rpx; background: #fff; max-height: 80vh;"
@update:model-value="handlePopupCancel">
<view class="popup-content">
<!-- 标题 -->
<text class="popup-title">{{ popupTitle }}</text>
<!-- 消息内容支持换行 -->
<text class="popup-message">{{ popupMessage }}</text>
<!-- 密码输入框格子模式 -->
<wd-password-input
v-if="currentState.type === 'password-verify' || currentState.type === 'retrieve-input'"
v-model="popupInputValue"
:length="passwordLength"
:gutter="8"
:mask="true"
:focused="keyboardVisible"
class="popup-password-input"
@focus="keyboardVisible = true"
@change="handlePasswordInputChange" />
<!-- 处理中状态提示 -->
<view v-if="currentState.type === 'processing'" class="processing-state">
<wd-loading type="ring" />
<text class="processing-text">
{{ currentState.action === 'deposit' ? '正在存入...' : '正在取出...' }}
</text>
</view>
<view class="popup-actions">
<!-- 取消按钮 -->
<wd-button
v-if="showCancelButton"
type="default"
block
@click="handlePopupCancel">
取消
</wd-button>
<!-- 确认按钮 -->
<wd-button
type="primary"
block
@click="handlePopupConfirm"
:disabled="(currentState.type === 'password-verify' || currentState.type === 'retrieve-input') && popupInputValue.length !== passwordLength">
{{ popupConfirmText }}
</wd-button>
</view>
<!-- 数字键盘显示时增加底部间距 -->
<wd-gap v-if="keyboardVisible" safe-area-bottom :height="'300rpx'" />
<!-- 数字键盘 -->
<wd-keyboard
v-model="popupInputValue"
v-model:visible="keyboardVisible"
:maxlength="passwordLength"
:modal="false"
:hide-on-click-outside="false"
:safe-area-inset-bottom="true"
close-text="完成"
/>
</view>
</wd-popup>
</view>
</template>
<style scoped lang="scss">
.storage-cells-summary {
padding: 32rpx;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08);
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.header-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 16rpx;
.back-btn {
display: flex;
align-items: center;
padding: 8rpx 12rpx;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 16rpx;
font-size: 24rpx;
color: #666;
white-space: nowrap;
&:active {
background: #f5f5f5;
}
}
}
}
.cell-type-selection {
margin-bottom: 48rpx;
.cell-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
.cell-type-card {
position: relative;
padding: 32rpx 24rpx;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 16rpx;
text-align: center;
transition: all 0.3s ease;
&:active:not(.cell-type-card--disabled) {
transform: translateY(2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
&--selected {
border-color: #2979ff;
background: rgba(41, 121, 255, 0.04);
box-shadow: 0 4rpx 16rpx rgba(41, 121, 255, 0.15);
.cell-type-name {
color: #2979ff;
font-weight: 500;
}
.cell-type-count {
color: #2979ff;
}
}
&--disabled {
background: #f9f9f9;
border-color: #f0f0f0;
opacity: 0.6;
.cell-type-name,
.cell-type-count {
color: #ccc;
}
}
.cell-type-icon {
margin-bottom: 16rpx;
}
.cell-type-name {
display: block;
font-size: 28rpx;
font-weight: 400;
color: #333;
margin-bottom: 8rpx;
}
.cell-type-count {
display: block;
font-size: 24rpx;
color: #666;
}
.cell-type-selected-indicator {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 32rpx;
height: 32rpx;
background: #2979ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(41, 121, 255, 0.3);
}
}
}
}
.action-buttons {
margin-top: 32rpx;
}
// 状态样式
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64rpx 0;
text-align: center;
.loading-text,
.error-text,
.empty-text {
margin-top: 24rpx;
font-size: 28rpx;
color: #666;
}
}
.error-state {
.error-text {
color: #F56C6C;
margin-bottom: 24rpx;
}
}
.custom-message-box {
/* 保留空白,自动换行 */
white-space: pre-wrap;
}
.product-image {
width: 80rpx;
height: 80rpx;
border-radius: 4px;
}
// 弹窗样式
.popup-content {
display: flex;
flex-direction: column;
gap: 32rpx;
.popup-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
text-align: center;
}
.popup-message {
font-size: 28rpx;
color: #666;
line-height: 1.5;
white-space: pre-line;
text-align: center;
}
.popup-password-input {
margin: 32rpx 0;
}
.processing-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
padding: 48rpx 0;
.processing-text {
font-size: 28rpx;
color: #666;
}
}
.popup-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
}
}
</style>