shop-web/src/pages/cabinet/components/StorageCellsSummary.vue

694 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.

<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>