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

643 lines
16 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.

<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 {
const res = await openByPassword({
shopId: props.shopId,
password: String(password)
})
if (res.code === 0) {
uni.showToast({
title: '格口已打开',
icon: 'success'
})
} else {
console.error('打开格口失败:', res)
uni.showToast({
title: '密码错误',
icon: 'error',
duration: 5000
})
}
// 刷新数据
await refresh()
} catch (error) {
console.error('打开格口失败:', error)
uni.showToast({
title: (error as any)?.message || '操作失败',
icon: 'error',
duration: 5000
})
} 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 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 && !loading" class="action-buttons">
<wd-row :gutter="16">
<wd-col :span="12">
<wd-button type="primary" block @click="handleDeposit" :loading="depositLoading"
:disabled="depositLoading || !hasAvailableCells || 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>