feat(柜机管理): 新增格口状态展示及密码清除功能

- 在柜机管理页面添加格口状态可视化展示,包括空闲和占用状态的SVG图标
- 新增resetCellById API用于清除格口密码
- 优化商品列表加载逻辑,避免重复数据
- 完善类型定义,添加格口状态和类型相关字段
- 根据店铺模式调整UI显示,隐藏部分非必要元素
- 添加清除密码按钮及相关处理逻辑
This commit is contained in:
dzq 2025-12-20 17:16:24 +08:00
parent cb20e28890
commit 8866478706
4 changed files with 188 additions and 32 deletions

View File

@ -65,3 +65,10 @@ export const clearGoodsCells = (cellId: number) => {
});
};
export const resetCellById = (cellId: number) => {
return request<ApiResponseData<void>>({
url: `/cabinet/reset/${cellId}`,
method: 'put'
});
};

View File

@ -1,7 +1,11 @@
export interface CabinetDetailDTO {
/** 柜机ID */
cabinetId: number
/** 柜机名称 */
cabinetName: string
/** 锁控编号 */
lockControlNo: number
/** 格口列表 */
cells: CellInfoDTO[]
}
@ -14,11 +18,13 @@ export interface RentingCabinetDetailDTO {
/** 锁控编号 */
lockControlNo: number
/** 柜格列表 */
cells: RetingCellEntity[]
cells: RentingCellEntity[]
}
export interface RetingCellEntity extends CabinetCellEntity {
export interface RentingCellEntity extends CabinetCellEntity {
/** 订单ID */
orderId: number;
/** 订单商品ID */
orderGoodsId: number;
}
@ -48,14 +54,27 @@ export interface CabinetCellEntity {
availableStatus: number
/** 商品ID */
goodsId?: number
/** 密码 */
password?: string
}
export interface CellInfoDTO {
/** 格口唯一ID */
cellId: number
/** 格口号 */
cellNo: number
/** 针脚序号 */
pinNo: number
/** 库存数量 */
stock: number
/** 密码 */
password?: string
/** 商品信息 */
product?: ProductInfoDTO
/** 使用状态1空闲 2已占用 */
usageStatus: number
/** 格口类型1小格 2中格 3大格 4超大格 */
cellType: number
}
export interface ProductInfoDTO {

View File

@ -29,11 +29,108 @@
<template #icon>
<div class="image-container">
<van-image width="80" height="80"
v-if="locker.coverImg"
:src="locker.coverImg ? locker.coverImg : `${publicPath}` + 'img/product-image.svg'"
fit="cover" radius="4" class="product-image"
:style="{ filter: locker.stock === 0 ? 'grayscale(100%)' : 'none' }">
:style="{ filter: locker.stock === 0 && locker.usageStatus === 1 ? 'grayscale(100%)' : 'none' }">
</van-image>
<div v-if="locker.stock >= 0" class="stock-overlay">
<svg v-if="!locker.coverImg && locker.usageStatus === 2" width="80" height="80"
viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"
:class="['cell-image', selectedShop?.mode === 5 ? 'cell-image-full' : '']">
<defs>
<linearGradient id="occupiedGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFF3E0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFE0B2;stop-opacity:1" />
</linearGradient>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="1" dy="1" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.1" />
</filter>
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
<feOffset dx="0" dy="0.5" />
<feGaussianBlur stdDeviation="0.8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="arithmetic" k2="-1" k3="1" />
</filter>
</defs>
<!-- 主卡片背景 -->
<rect x="5" y="5" width="90" height="90" rx="12" fill="url(#occupiedGradient)" stroke="#FF9800"
stroke-width="2.5" filter="url(#shadow)" />
<!-- 内阴影效果 -->
<rect x="5" y="5" width="90" height="90" rx="12" fill="none" stroke="rgba(255,152,0,0.3)"
stroke-width="1" filter="url(#innerShadow)" />
<!-- 锁定图标 -->
<g transform="translate(35, 12)">
<rect x="8" y="12" width="16" height="12" rx="2" fill="#FF6F00" stroke="#E65100"
stroke-width="1" />
<path d="M 12 12 L 12 8 C 12 5.79 13.79 4 16 4 L 16 4 C 18.21 4 20 5.79 20 8 L 20 12" fill="none"
stroke="#FF6F00" stroke-width="2" stroke-linecap="round" />
</g>
<!-- 状态文字 -->
<text x="50" y="54" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#E65100"
text-anchor="middle">占用</text>
<!-- 装饰性分割线 -->
<line x1="20" y1="62" x2="80" y2="62" stroke="#FF9800" stroke-width="1.5" stroke-dasharray="3,2"
opacity="0.7" />
<!-- 格口类型标签 -->
<g transform="translate(50, 78)">
<rect x="-18" y="-8" width="36" height="16" rx="8" fill="#FF9800" opacity="0.9" />
<text x="0" y="3" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white"
text-anchor="middle">{{ switchCellType(locker.cellType) }}</text>
</g>
</svg>
<svg v-if="!locker.coverImg && locker.usageStatus === 1" width="80" height="80"
viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"
:class="['cell-image', selectedShop?.mode === 5 ? 'cell-image-full' : '']">
<defs>
<linearGradient id="freeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E8F5E9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#C8E6C9;stop-opacity:1" />
</linearGradient>
<filter id="shadow2" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="1" dy="1" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.1" />
</filter>
<filter id="innerShadow2" x="-50%" y="-50%" width="200%" height="200%">
<feOffset dx="0" dy="0.5" />
<feGaussianBlur stdDeviation="0.8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="arithmetic" k2="-1" k3="1" />
</filter>
</defs>
<!-- 主卡片背景 -->
<rect x="5" y="5" width="90" height="90" rx="12" fill="url(#freeGradient)" stroke="#4CAF50"
stroke-width="2.5" filter="url(#shadow2)" />
<!-- 内阴影效果 -->
<rect x="5" y="5" width="90" height="90" rx="12" fill="none" stroke="rgba(76,175,80,0.3)"
stroke-width="1" filter="url(#innerShadow2)" />
<!-- 开锁图标 -->
<g transform="translate(35, 12)">
<rect x="8" y="12" width="16" height="12" rx="2" fill="#4CAF50" stroke="#388E3C"
stroke-width="1" />
<path d="M 12 12 L 12 8 C 12 5.79 13.79 4 16 4 L 16 4 C 18.21 4 20 5.79 20 8 L 20 12" fill="none"
stroke="#4CAF50" stroke-width="2" stroke-linecap="round" />
<!-- 打开的锁舌 -->
<circle cx="16" cy="18" r="2" fill="#4CAF50" stroke="#388E3C" stroke-width="1" />
</g>
<!-- 状态文字 -->
<text x="50" y="54" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#2E7D32"
text-anchor="middle">空闲</text>
<!-- 装饰性分割线 -->
<line x1="20" y1="62" x2="80" y2="62" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="3,2"
opacity="0.7" />
<!-- 格口类型标签 -->
<g transform="translate(50, 78)">
<rect x="-18" y="-8" width="36" height="16" rx="8" fill="#4CAF50" opacity="0.9" />
<text x="0" y="3" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white"
text-anchor="middle">{{ switchCellType(locker.cellType) }}</text>
</g>
</svg>
<div v-if="locker.stock >= 0 && selectedShop?.mode !==5" class="stock-overlay">
库存: {{ locker.stock }}
</div>
</div>
@ -43,25 +140,30 @@
<div v-if="locker.goodsName">
<div class="info-row">
<div class="locker-number">格口 {{ locker.cellNo }}</div>
<div class="goods-price">¥{{ (locker.price || 0).toFixed(2) }}</div>
<div v-if="selectedShop?.mode !==5" class="goods-price">¥{{ (locker.price || 0).toFixed(2) }}</div>
</div>
<div class="goods-name">{{ locker.goodsName }}</div>
</div>
<div v-else>
<div class="info-row">
<div class="locker-number">格口 {{ locker.cellNo }}</div>
<div class="goods-price">¥0.00</div>
<div v-if="selectedShop?.mode !==5" class="goods-price">¥0.00</div>
</div>
<div class="goods-name">空闲</div>
<!-- <div class="goods-name">{{ locker.usageStatus === 1 ? '空闲' : '占用' }}</div> -->
</div>
</div>
<div class="button-group">
<van-button size="small" type="primary" class="detail-btn" @click="showBindGoods(locker)">
<van-button v-if="selectedShop?.mode !== 5" size="small" type="primary" class="detail-btn" @click="showBindGoods(locker)">
绑定商品
</van-button>
<van-button v-if="selectedShop?.mode === 5 && locker.password" size="small" type="primary" class="detail-btn" @click="handleClearPassword(locker)">
清除密码
</van-button>
<div v-else-if="selectedShop?.mode === 5 && !locker.password" class="detail-btn-placeholder"></div>
<van-button size="small" plain hairline :loading="openingLockerId === locker.lockerId"
@click="handleOpenLocker(locker)">
@click="handleOpenLocker(locker)"
style="margin-left: auto;">
立即开启
</van-button>
</div>
@ -82,7 +184,7 @@
@success="handleBindSuccess"
@unbind="handleBindSuccess"
@update:currentStock="handleStockUpdate"
/>
/>
</VanPopup>
</div>
</template>
@ -92,7 +194,7 @@ import { throttle } from 'lodash-es';
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { getShopListApi } from '@/common/apis/shop';
import { ShopEntity } from '@/common/apis/shop/type';
import { getCabinetDetailApi, openCabinet, changeGoodsCellsStock, clearGoodsCells } from '@/common/apis/cabinet';
import { getCabinetDetailApi, openCabinet, changeGoodsCellsStock, clearGoodsCells, resetCellById } from '@/common/apis/cabinet';
import type { CabinetDetailDTO } from '@/common/apis/cabinet/type';
import { useWxStore, useWxStoreOutside } from '@/pinia/stores/wx';
import { publicPath } from "@/common/utils/path";
@ -135,6 +237,9 @@ interface LockerItem {
goodsName?: string
price?: number
coverImg?: string
password?: string
usageStatus?: number
cellType?: number
}
//
@ -151,8 +256,10 @@ const loadCabinetDetail = async (selectedShopId?: number) => {
}))
//
if (data.length > 0) {
if (data.length > 0 && data[activeCabinet.value]) {
updateLockerList(data[activeCabinet.value])
} else {
lockerList.value = [];
}
} catch (error) {
console.error('获取柜机详情失败:', error)
@ -170,7 +277,10 @@ const updateLockerList = (cabinet: CabinetDetailDTO) => {
statusClass: cell.product ? 'occupied' : 'available',
goodsName: cell.product?.goodsName,
price: cell.product?.price,
coverImg: cell.product?.coverImg
coverImg: cell.product?.coverImg,
password: cell.password,
usageStatus: cell.usageStatus,
cellType: cell.cellType,
}))
}
@ -182,6 +292,16 @@ const onCabinetChange = (index: number) => {
}
}
function switchCellType(cellType: number | undefined) {
switch (cellType) {
case 1: return '小格';
case 2: return '中格';
case 3: return '大格';
case 4: return '超大格';
default: return '未知';
}
}
const showBindGoods = (locker: LockerItem) => {
currentLocker.value = locker;
showBindGoodsPopup.value = true;
@ -264,6 +384,17 @@ const handleStockUpdate = (newStock: number) => {
}
};
const handleClearPassword = async (locker: LockerItem) => {
try {
await resetCellById(locker.lockerId);
showToast('清除密码成功');
loadCabinetDetail();
} catch (error) {
console.error('清除密码失败:', error);
showToast('清除密码失败');
}
};
onMounted(() => {
init();
scrollListener.push(window.addEventListener('scroll', throttledScroll));
@ -463,6 +594,10 @@ onBeforeUnmount(() => {
border: none;
}
.detail-btn-placeholder {
flex: 1;
}
:deep(.van-button--default) {
color: #e95d5d;
border-color: #e95d5d;

View File

@ -48,17 +48,12 @@ onUnmounted(() => {
debouncedFetchGoodsList.cancel();
});
//
const handleSearch = () => {
debouncedFetchGoodsList.cancel();
fetchGoodsList();
};
//
const onLoad = async () => {
try {
const res = await getGoodsList(searchParams);
goodsList.value.push(...res.data.rows);
const rows = res.data.rows.filter(row => goodsList.value.findIndex(g => g.goodsId === row.goodsId) === -1);
goodsList.value.push(...rows);
loading.value = false;
if (goodsList.value.length >= res.data.total) {
@ -108,7 +103,7 @@ onMounted(fetchGoodsList);
<div class="goods-manage">
<!-- 搜索栏和操作按钮 -->
<div class="search-action-bar">
<van-search v-model="searchParams.goodsName" placeholder="输入商品名称搜索" @search="handleSearch" />
<van-search v-model="searchParams.goodsName" placeholder="输入商品名称搜索" />
<van-button type="primary" @click="navigateToEdit">添加商品</van-button>
</div>