694 lines
16 KiB
Vue
694 lines
16 KiB
Vue
<template>
|
||
<div class="storage-cells-summary">
|
||
<!-- 标题区域 -->
|
||
<div class="summary-header">
|
||
<span class="header-title">本区域空余暂存柜子</span>
|
||
<div class="header-actions">
|
||
<div class="back-btn" @click="handleBackToAddressSelect">
|
||
<van-icon name="arrow-left" size="12px" />
|
||
<span style="margin-left: 4px">重选地址</span>
|
||
</div>
|
||
<van-button v-if="!loading" size="small" type="primary" plain @click="refresh">
|
||
刷新
|
||
</van-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="loading-state">
|
||
<van-loading type="spinner" />
|
||
<span class="loading-text">正在加载格口数据...</span>
|
||
</div>
|
||
|
||
<!-- 错误状态 -->
|
||
<div v-else-if="error" class="error-state">
|
||
<van-icon name="warning-o" size="48px" color="#F56C6C" />
|
||
<span class="error-text">数据加载失败</span>
|
||
<van-button size="small" type="primary" @click="refresh">重试</van-button>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else-if="!hasAvailableCells" class="empty-state">
|
||
<van-icon name="box" size="48px" color="#C0C4CC" />
|
||
<span class="empty-text">暂无可用格口</span>
|
||
</div>
|
||
|
||
<!-- 格口类型选择区域 -->
|
||
<div v-else class="cell-type-selection">
|
||
<div class="cell-type-grid">
|
||
<div
|
||
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)"
|
||
>
|
||
<div class="cell-type-icon">
|
||
<img :src="getCellImg(stat.type)" class="product-image">
|
||
</div>
|
||
<span class="cell-type-name">{{ stat.name }}</span>
|
||
<span class="cell-type-count">剩余{{ stat.count }}个可用</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮区域 -->
|
||
<div v-if="props.showButtons && hasAvailableCells && !loading" class="action-buttons">
|
||
<van-row :gutter="16">
|
||
<van-col :span="12">
|
||
<van-button type="primary" block @click="handleDeposit"
|
||
:loading="depositLoading"
|
||
:disabled="depositLoading || retrieveLoading">
|
||
物品暂存
|
||
</van-button>
|
||
</van-col>
|
||
<van-col :span="12">
|
||
<van-button type="success" block @click="handleRetrieve"
|
||
:loading="retrieveLoading"
|
||
:disabled="depositLoading || retrieveLoading">
|
||
物品取出
|
||
</van-button>
|
||
</van-col>
|
||
</van-row>
|
||
</div>
|
||
|
||
<!-- 弹窗组件 -->
|
||
<van-popup
|
||
v-model:show="popupVisible"
|
||
position="bottom"
|
||
round
|
||
:style="popupStyle"
|
||
@close="handlePopupCancel">
|
||
|
||
<div class="popup-content" :style="popupContentStyle">
|
||
<!-- 标题 -->
|
||
<div class="popup-title">{{ popupTitle }}</div>
|
||
|
||
<!-- 密码显示区域(仅在密码展示状态显示) -->
|
||
<div v-if="currentState.type === 'password-show'" class="password-display-area">
|
||
<div class="password-display-label">您的暂存密码</div>
|
||
<div class="password-display-value">{{ currentState.password }}</div>
|
||
<div class="password-display-hint">请务必牢记此密码</div>
|
||
</div>
|
||
|
||
<!-- 消息内容(密码展示状态不显示) -->
|
||
<div v-if="currentState.type !== 'password-show'" class="popup-message">{{ popupMessage }}</div>
|
||
|
||
<!-- 密码输入框 -->
|
||
<van-password-input
|
||
v-if="currentState.type === 'password-verify' || currentState.type === 'retrieve-input'"
|
||
:value="popupInputValue"
|
||
:length="passwordLength"
|
||
:gutter="8"
|
||
:mask="true"
|
||
:focused="keyboardVisible"
|
||
class="popup-password-input"
|
||
@focus="keyboardVisible = true" />
|
||
|
||
<!-- 处理中状态 -->
|
||
<div v-if="currentState.type === 'processing'" class="processing-state">
|
||
<van-loading type="spinner" />
|
||
<span class="processing-text">
|
||
{{ currentState.action === 'deposit' ? '正在存入...' : '正在取出...' }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="popup-actions">
|
||
<!-- 取消按钮 -->
|
||
<van-button
|
||
v-if="showCancelButton"
|
||
type="default"
|
||
block
|
||
@click="handlePopupCancel">
|
||
取消
|
||
</van-button>
|
||
|
||
<!-- 确认按钮 -->
|
||
<van-button
|
||
type="primary"
|
||
block
|
||
@click="handlePopupConfirm"
|
||
:disabled="(currentState.type === 'password-verify' || currentState.type === 'retrieve-input') && popupInputValue.length !== passwordLength">
|
||
{{ popupConfirmText }}
|
||
</van-button>
|
||
</div>
|
||
|
||
<!-- 数字键盘 -->
|
||
<van-number-keyboard
|
||
v-model="popupInputValue"
|
||
:show="keyboardVisible"
|
||
:maxlength="passwordLength"
|
||
theme="custom"
|
||
close-button-text="完成"
|
||
:safe-area-inset-bottom="true"
|
||
@blur="handleKeyboardClose" />
|
||
</div>
|
||
</van-popup>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { showToast } from 'vant'
|
||
import {
|
||
availableStorageCells,
|
||
storeItemApi,
|
||
openByPassword
|
||
} from '@/common/apis/cabinet'
|
||
import type { AvailableStorageCellDTO } from '@/common/apis/cabinet/type'
|
||
import { usePopupState } from '@/common/composables/usePopupState'
|
||
import { publicPath } from '@/common/utils/path'
|
||
|
||
// 格口类型映射
|
||
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 popupStyle = computed(() => {
|
||
const baseStyle = {
|
||
padding: '32px',
|
||
background: '#fff'
|
||
}
|
||
|
||
if (keyboardVisible.value) {
|
||
return {
|
||
...baseStyle,
|
||
maxHeight: 'calc(100vh - 360px)',
|
||
overflow: 'auto'
|
||
}
|
||
}
|
||
|
||
return {
|
||
...baseStyle,
|
||
maxHeight: '80vh'
|
||
}
|
||
})
|
||
|
||
const popupContentStyle = computed(() => {
|
||
if (keyboardVisible.value) {
|
||
return {
|
||
paddingBottom: '160px'
|
||
}
|
||
}
|
||
return {}
|
||
})
|
||
|
||
// 统计计算属性
|
||
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)
|
||
showToast({
|
||
message: '数据加载失败',
|
||
type: 'fail'
|
||
})
|
||
} 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 (err) {
|
||
// 错误处理
|
||
console.error('存入流程失败:', err)
|
||
showToast({
|
||
message: (err as any)?.message || '操作失败',
|
||
type: 'fail',
|
||
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) {
|
||
showToast({ message: '密码不正确', type: 'fail' })
|
||
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)
|
||
})
|
||
|
||
showToast({
|
||
message: '格口已打开',
|
||
type: 'success'
|
||
})
|
||
|
||
// 刷新数据
|
||
await refresh()
|
||
} catch (err) {
|
||
console.error('打开格口失败:', err)
|
||
showToast({
|
||
message: (err as any)?.message || '操作失败',
|
||
type: 'fail',
|
||
duration: 3000
|
||
})
|
||
} finally {
|
||
transitionTo({ type: 'idle' })
|
||
}
|
||
}
|
||
|
||
// 取出流程处理函数
|
||
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 {
|
||
// 假设图片放在 public/images/cabinet/ 目录下
|
||
const basePath = publicPath + 'images/cabinet/'
|
||
switch (type) {
|
||
case 1: return `${basePath}small-cell.svg`
|
||
case 2: return `${basePath}medium-cell.svg`
|
||
case 3: return `${basePath}large-cell.svg`
|
||
case 4: return `${basePath}extra-large-cell.svg`
|
||
default: return `${basePath}small-cell.svg`
|
||
}
|
||
}
|
||
|
||
// 处理格口类型选择
|
||
function handleCellTypeSelect(stat: { type: number; count: number }) {
|
||
if (stat.count === 0) {
|
||
showToast({
|
||
message: '该类型格口暂不可用',
|
||
type: 'fail',
|
||
duration: 1500
|
||
})
|
||
return
|
||
}
|
||
selectedCellType.value = stat.type
|
||
}
|
||
|
||
// 生命周期钩子
|
||
onMounted(() => {
|
||
if (props.autoLoad !== false) {
|
||
refresh()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.storage-cells-summary {
|
||
padding: 12px;
|
||
background: #fff;
|
||
border-radius: 24px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.summary-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 40px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px; /* 原: 16rpx → 8px */
|
||
}
|
||
|
||
.back-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 12px; /* 原: 8rpx 12rpx → 4px 6px */
|
||
background: #fff;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 16px; /* 原: 16rpx → 8px */
|
||
font-size: 24px; /* 原: 24rpx → 12px */
|
||
color: #666;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.back-btn:active {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.cell-type-selection {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.cell-type-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 24px;
|
||
}
|
||
|
||
.cell-type-card {
|
||
position: relative;
|
||
padding: 40px 32px;
|
||
background: #fff;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 20px;
|
||
text-align: center;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.cell-type-card:active:not(.cell-type-card--disabled) {
|
||
transform: translateY(2px);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.cell-type-card--selected {
|
||
border-color: #2979ff;
|
||
border-width: 2px;
|
||
background: rgba(41, 121, 255, 0.04);
|
||
box-shadow: 0 8px 32px rgba(41, 121, 255, 0.25);
|
||
}
|
||
|
||
.cell-type-card--selected .cell-type-name {
|
||
color: #2979ff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.cell-type-card--selected .cell-type-count {
|
||
color: #2979ff;
|
||
}
|
||
|
||
.cell-type-card--disabled {
|
||
background: #f9f9f9;
|
||
border-color: #f0f0f0;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.cell-type-card--disabled .cell-type-name,
|
||
.cell-type-card--disabled .cell-type-count {
|
||
color: #ccc;
|
||
}
|
||
|
||
.cell-type-icon {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.cell-type-name {
|
||
display: block;
|
||
font-size: 32px;
|
||
font-weight: 400;
|
||
color: #333;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.cell-type-count {
|
||
display: block;
|
||
font-size: 28px;
|
||
color: #666;
|
||
}
|
||
|
||
.product-image {
|
||
width: 96px;
|
||
height: 96px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.action-buttons {
|
||
margin-top: 12px;
|
||
|
||
.van-button {
|
||
height: 52px;
|
||
font-size: 20px;
|
||
border-radius: 12px;
|
||
}
|
||
}
|
||
|
||
/* 状态样式 */
|
||
.loading-state,
|
||
.error-state,
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 64px 0; /* 原: 64rpx → 32px */
|
||
text-align: center;
|
||
}
|
||
|
||
.loading-text,
|
||
.error-text,
|
||
.empty-text {
|
||
margin-top: 24px; /* 原: 24rpx → 12px */
|
||
font-size: 28px; /* 原: 28rpx → 14px */
|
||
color: #666;
|
||
}
|
||
|
||
.error-state .error-text {
|
||
color: #F56C6C;
|
||
margin-bottom: 24px; /* 原: 24rpx → 12px */
|
||
}
|
||
|
||
/* 弹窗样式 */
|
||
.popup-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 32px; /* 原: 32rpx → 16px */
|
||
}
|
||
|
||
.popup-title {
|
||
font-size: 36px; /* 原: 36rpx → 18px */
|
||
font-weight: 600;
|
||
color: #333;
|
||
text-align: center;
|
||
}
|
||
|
||
.popup-message {
|
||
font-size: 28px; /* 原: 28rpx → 14px */
|
||
color: #666;
|
||
line-height: 1.5;
|
||
white-space: pre-line;
|
||
text-align: center;
|
||
}
|
||
|
||
.popup-password-input {
|
||
margin: 32px 0; /* 原: 32rpx → 16px */
|
||
}
|
||
|
||
.processing-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 24px; /* 原: 24rpx → 12px */
|
||
padding: 48px 0; /* 原: 48rpx → 24px */
|
||
}
|
||
|
||
.processing-text {
|
||
font-size: 28px; /* 原: 28rpx → 14px */
|
||
color: #666;
|
||
}
|
||
|
||
.popup-actions {
|
||
display: flex;
|
||
gap: 16px; /* 原: 16rpx → 8px */
|
||
margin-top: 32px; /* 原: 32rpx → 16px */
|
||
}
|
||
|
||
/* 密码显示区域样式 */
|
||
.password-display-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 48px 32px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 24px;
|
||
margin: 16px 0;
|
||
}
|
||
|
||
.password-display-label {
|
||
font-size: 28px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.password-display-value {
|
||
font-size: 72px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
letter-spacing: 16px;
|
||
line-height: 1.2;
|
||
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.password-display-hint {
|
||
font-size: 24px;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
margin-top: 20px;
|
||
}
|
||
|
||
/* 注意:当前项目使用postcss-px-to-viewport插件,px单位会自动转换为vw */
|
||
/* 因此我们保持px单位,让插件自动处理移动端适配 */
|
||
|
||
</style> |