feat: 新增横屏暂存柜管理页面及相关功能

refactor(router): 添加横屏路由配置并优化路由守卫逻辑
feat(api): 新增企业corpid查询接口
feat(components): 实现地址选择面板组件
feat(pinia): 扩展wx store添加corpid设置方法
style(images): 更新柜格图标为更清晰的SVG版本
fix(postcss): 调整移动端适配配置
This commit is contained in:
dzq 2025-12-23 15:25:28 +08:00
parent 47fc5f791e
commit 03e7fc5640
14 changed files with 519 additions and 48 deletions

View File

@ -3,7 +3,9 @@
"allow": [
"Bash(mkdir:*)",
"Bash(tree -L 3 -I 'node_modules|dist')",
"Bash(cat:*)"
"Bash(cat:*)",
"Bash(dir:*)",
"Bash(find:*)"
],
"deny": [],
"ask": []

View File

@ -8,7 +8,7 @@ export default {
// UI 设计稿宽度
viewportWidth: (file: string) => file.includes("vant") ? 375 : 375,
// 限制视图的最大宽度
maxDisplayWidth: 750,
// maxDisplayWidth: 750,
// 页面最外层选择器
appSelector: "#app",
// 是否对「页面最外层选择器」对应的元素进行描边
@ -18,7 +18,8 @@ export default {
// 转换后的单位
mobileUnit: "vw",
// 需要转换的属性
propList: ["*"],
// propList: ["*"],
propList: [],
// 忽略的选择器
selectorBlackList: [".ignore", "keep-px"],
// 忽略的属性

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" rx="10" fill="#E91E63" opacity="0.8"/>
<text x="50" y="55" font-family="Arial" font-size="16" font-weight="bold" fill="white" text-anchor="middle">超大格</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<path fill="currentColor"
d="M200 24H56a12 12 0 0 0-12 12v184a12 12 0 0 0 12 12h144a12 12 0 0 0 12-12V36a12 12 0 0 0-12-12m4 196a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V36a4 4 0 0 1 4-4h144a4 4 0 0 1 4 4v184Zm-64-92a4 4 0 0 1-4 4h-16a4 4 0 0 1 0-8h16a4 4 0 0 1 4 4" />
</svg>

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 367 B

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" rx="10" fill="#FF9800" opacity="0.8"/>
<text x="50" y="55" font-family="Arial" font-size="16" font-weight="bold" fill="white" text-anchor="middle">大格</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<path fill="currentColor"
d="M200 48H56a12 12 0 0 0-12 12v136a12 12 0 0 0 12 12h144a12 12 0 0 0 12-12V60a12 12 0 0 0-12-12m4 148a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V60a4 4 0 0 1 4-4h144a4 4 0 0 1 4 4v136Zm-64-68a4 4 0 0 1-4 4h-16a4 4 0 0 1 0-8h16a4 4 0 0 1 4 4" />
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 367 B

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" rx="10" fill="#2196F3" opacity="0.8"/>
<text x="50" y="55" font-family="Arial" font-size="16" font-weight="bold" fill="white" text-anchor="middle">中格</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<path fill="currentColor"
d="M200 72H56a12 12 0 0 0-12 12v88a12 12 0 0 0 12 12h144a12 12 0 0 0 12-12V84a12 12 0 0 0-12-12m4 100a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V84a4 4 0 0 1 4-4h144a4 4 0 0 1 4 4v88Zm-64-44a4 4 0 0 1-4 4h-16a4 4 0 0 1 0-8h16a4 4 0 0 1 4 4" />
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 365 B

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" rx="10" fill="#4CAF50" opacity="0.8"/>
<text x="50" y="55" font-family="Arial" font-size="16" font-weight="bold" fill="white" text-anchor="middle">小格</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<path fill="currentColor"
d="M200 96H56a12 12 0 0 0-12 12v40a12 12 0 0 0 12 12h144a12 12 0 0 0 12-12v-40a12 12 0 0 0-12-12m4 52a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4v-40a4 4 0 0 1 4-4h144a4 4 0 0 1 4 4v40Zm-64-20a4 4 0 0 1-4 4h-16a4 4 0 0 1 0-8h16a4 4 0 0 1 4 4" />
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 366 B

View File

@ -6,6 +6,7 @@ import { useWxStore } from "@/pinia/stores/wx"
import { tokenLogin } from '@/common/apis/ab98'
import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { useProductStore } from "./pinia/stores/product"
import { getCorpidByIdApi } from "./common/apis/qy"
// const userStore = useUserStore()
const wxStore = useWxStore();
@ -30,14 +31,27 @@ const isLoading = false;
// )
initDark()
onMounted(() => {
onMounted(async () => {
const urlParams = new URLSearchParams(window.location.search);
console.log('urlParams', urlParams);
const code = urlParams.get('code') || undefined;
const state = urlParams.get('state') || undefined;
const corpid = urlParams.get('corpid') || undefined;
let corpid = urlParams.get('corpid') || undefined;
const cid = urlParams.get('cid') || undefined;
let isAdmin = urlParams.get('isAdmin') || undefined;
if (cid && Number(cid)) {
try {
const corpidRes = await getCorpidByIdApi(Number(cid));
if (corpidRes?.code === 0 && corpidRes.data) {
corpid = corpidRes.data;
wxStore.setCorpid(corpid);
}
} catch (error) {
console.log('getCorpidByIdApi error', error);
}
}
if (state && state.indexOf('token') !== -1) {
const token = state.split('token_')[1];
if (token) {

View File

@ -0,0 +1,10 @@
import { request } from "@/http/axios"
/** 根据企业ID查询corpid */
export function getCorpidByIdApi(id: number) {
return request<ApiResponseData<string>>({
url: "qy/getCorpidById",
method: "get",
params: { id }
})
}

View File

@ -0,0 +1,177 @@
<template>
<div v-if="showPanel" class="address-selection-panel">
<!-- 提示区域 -->
<div class="shop-prompt">
<van-cell title="请选择机柜地址:" center />
</div>
<!-- 商店网格 -->
<div class="shop-grid">
<div
v-for="shop in shopList"
:key="shop.shopId"
class="shop-item"
@click="handleShopSelect(shop.shopId)"
>
<van-image
:src="shop.coverImg || `${publicPath}product-image.png`"
class="shop-cover-img"
fit="cover"
/>
<div class="shop-info">
<van-icon name="shop-o" size="16" class="shop-icon" />
<div class="shop-name van-ellipsis">{{ shop.shopName }}</div>
</div>
</div>
<!-- 占位列确保每行4列 -->
<div
v-for="n in (4 - (shopList.length % 4))"
:key="'placeholder-' + n"
v-if="shopList.length % 4 !== 0"
class="shop-item placeholder-col"
></div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<van-loading type="spinner" />
<span class="loading-text">正在加载地址列表...</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { publicPath } from "@/common/utils/path"
// Props
interface Props {
shopList: any[]
loading?: boolean
showPanel?: boolean
}
const props = withDefaults(defineProps<Props>(), {
shopList: () => [],
loading: false,
showPanel: true
})
// Emits
const emit = defineEmits<{
(e: 'select', shopId: number): void
}>()
//
const handleShopSelect = (shopId: number) => {
emit('select', shopId)
}
</script>
<style scoped>
.address-selection-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
overflow: hidden;
background: #f5f5f5;
}
.shop-prompt {
flex-shrink: 0;
margin: 8px 16px;
height: 44px !important;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.placeholder-col {
visibility: hidden;
pointer-events: none;
}
.shop-grid {
flex: 1;
margin: 8px 16px;
display: grid;
grid-template-columns: repeat(4, 1fr);
row-gap: 8px;
column-gap: 6px;
overflow-y: auto;
align-content: start;
}
.shop-item {
min-height: 150px;
padding: 0;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
}
.shop-item:active {
transform: scale(0.98);
}
.shop-cover-img {
width: 100%;
height: 100px;
flex-shrink: 0;
object-fit: cover;
}
.shop-info {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
flex: 1;
}
.shop-icon {
margin-right: 4px;
flex-shrink: 0;
}
.shop-name {
white-space: normal;
word-break: break-word;
font-size: 12px;
color: #333;
font-weight: 500;
margin: 0;
text-align: center;
line-height: 1.3;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 0;
text-align: center;
flex-shrink: 0;
}
.loading-text {
margin-top: 24px;
font-size: 14px;
color: #666;
}
</style>

View File

@ -80,20 +80,27 @@
v-model:show="popupVisible"
position="bottom"
round
:style="{ padding: '32px', background: '#fff', maxHeight: '80vh' }"
:style="popupStyle"
@close="handlePopupCancel">
<div class="popup-content">
<div class="popup-content" :style="popupContentStyle">
<!-- 标题 -->
<div class="popup-title">{{ popupTitle }}</div>
<!-- 消息内容 -->
<div class="popup-message">{{ popupMessage }}</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'"
v-model="popupInputValue"
:value="popupInputValue"
:length="passwordLength"
:gutter="8"
:mask="true"
@ -132,11 +139,12 @@
<!-- 数字键盘 -->
<van-number-keyboard
v-model="popupInputValue"
v-model:show="keyboardVisible"
:show="keyboardVisible"
:maxlength="passwordLength"
theme="custom"
close-button-text="完成"
:safe-area-inset-bottom="true" />
:safe-area-inset-bottom="true"
@blur="handleKeyboardClose" />
</div>
</van-popup>
</div>
@ -152,6 +160,7 @@ import {
} 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 = {
@ -214,6 +223,36 @@ const {
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)
@ -387,7 +426,7 @@ const CELL_TYPE_ICON_MAP = {
function getCellImg(type: number): string {
// public/images/cabinet/
const basePath = '/images/cabinet/'
const basePath = publicPath + 'images/cabinet/'
switch (type) {
case 1: return `${basePath}small-cell.svg`
case 2: return `${basePath}medium-cell.svg`
@ -420,21 +459,21 @@ onMounted(() => {
<style scoped>
.storage-cells-summary {
padding: 32px; /* 原: 32rpx → 16px, 但考虑到适配使用32px */
padding: 12px;
background: #fff;
border-radius: 16px; /* 原: 16rpx → 8px */
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); /* 原: 0 4rpx 24rpx */
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: 32px; /* 原: 32rpx → 16px */
margin-bottom: 12px;
}
.header-title {
font-size: 32px; /* 原: 32rpx → 16px */
font-size: 40px;
font-weight: 600;
color: #333;
}
@ -462,21 +501,21 @@ onMounted(() => {
}
.cell-type-selection {
margin-bottom: 48px; /* 原: 48rpx → 24px */
margin-bottom: 24px;
}
.cell-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px; /* 原: 16rpx → 8px */
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.cell-type-card {
position: relative;
padding: 32px 24px; /* 原: 32rpx 24rpx → 16px 12px */
padding: 40px 32px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 16px; /* 原: 16rpx → 8px */
border-radius: 20px;
text-align: center;
transition: all 0.3s ease;
}
@ -488,8 +527,9 @@ onMounted(() => {
.cell-type-card--selected {
border-color: #2979ff;
border-width: 2px;
background: rgba(41, 121, 255, 0.04);
box-shadow: 0 4px 16px rgba(41, 121, 255, 0.15); /* 原: 0 4rpx 16rpx */
box-shadow: 0 8px 32px rgba(41, 121, 255, 0.25);
}
.cell-type-card--selected .cell-type-name {
@ -513,31 +553,37 @@ onMounted(() => {
}
.cell-type-icon {
margin-bottom: 16px; /* 原: 16rpx → 8px */
margin-bottom: 20px;
}
.cell-type-name {
display: block;
font-size: 28px; /* 原: 28rpx → 14px */
font-size: 32px;
font-weight: 400;
color: #333;
margin-bottom: 8px; /* 原: 8rpx → 4px */
margin-bottom: 12px;
}
.cell-type-count {
display: block;
font-size: 24px; /* 原: 24rpx → 12px */
font-size: 28px;
color: #666;
}
.product-image {
width: 80px; /* 原: 80rpx → 40px */
height: 80px; /* 原: 80rpx → 40px */
width: 96px;
height: 96px;
border-radius: 4px;
}
.action-buttons {
margin-top: 32px; /* 原: 32rpx → 16px */
margin-top: 12px;
.van-button {
height: 52px;
font-size: 20px;
border-radius: 12px;
}
}
/* 状态样式 */
@ -610,6 +656,39 @@ onMounted(() => {
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>

View File

@ -0,0 +1,162 @@
<template>
<div class="storage-horizontal-page">
<!-- 地址选择面板 -->
<div v-if="showAddressPanel" class="panel address-panel horizontal-mode">
<AddressSelectionPanel
:shop-list="shopList"
:loading="loading"
@select="handleShopSelect"
/>
</div>
<!-- 格口信息面板 -->
<div v-else class="panel cells-panel horizontal-mode">
<!-- <div class="panel-header">
<van-button
icon="arrow-left"
type="default"
@click="showAddressPanel = true"
class="panel-switch-btn"
>
切换地址
</van-button>
<div class="panel-title">
<van-icon name="shop-o" />
<span>{{ selectedShop?.shopName }}</span>
</div>
</div> -->
<StorageCellsSummary
:shop-id="shopId"
:show-buttons="true"
@backToAddressSelect="showAddressPanel = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getShopListApi } from '@/common/apis/shop'
import type { ShopEntity } from '@/common/apis/shop/type'
import { useWxStore } from '@/pinia/stores/wx'
import AddressSelectionPanel from './components/AddressSelectionPanel.vue'
import StorageCellsSummary from './components/StorageCellsSummary.vue'
const wxStore = useWxStore()
const appElement = document.getElementById('app')
console.log('appElement', appElement)
if (appElement) {
appElement.style.setProperty('max-width', '100%', 'important')
}
//
const showAddressPanel = ref(true) //
const shopList = ref<ShopEntity[]>([])
const shopId = ref<number>(0)
const selectedShop = ref<ShopEntity | null>(null)
const loading = ref(false)
//
const init = async () => {
await loadShopList()
}
//
const loadShopList = async () => {
loading.value = true
try {
const res = await getShopListApi(wxStore.corpid || 'wpZ1ZrEgAA2QTxIRcB4cMtY7hQbTcPAw', -1)
if (res?.code === 0 && res?.data?.length > 0) {
shopList.value = res.data.filter(shop => shop.mode === 5)
}
} catch (error) {
console.error('获取商店列表失败:', error)
} finally {
loading.value = false
}
}
//
const handleShopSelect = (selectedShopId: number) => {
const shop = shopList.value.find(s => s.shopId === selectedShopId)
if (shop) {
selectedShop.value = shop
shopId.value = selectedShopId
showAddressPanel.value = false
}
}
onMounted(() => {
init()
})
</script>
<style scoped>
.storage-horizontal-page {
width: 100%;
height: 100vh;
background: #f8f8f8;
overflow: hidden;
display: flex;
flex-direction: row;
}
.panel {
width: 100%;
height: 100%;
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
/* 面板切换按钮 */
.panel-switch-btn {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
background: rgba(255, 255, 255, 0.9);
border-radius: 20px;
padding: 8px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:active {
background: rgba(255, 255, 255, 0.8);
}
}
/* 面板标题 */
.panel-title {
text-align: center;
padding: 16px;
font-size: 20px;
font-weight: 500;
color: #333;
.van-icon {
margin-right: 8px;
color: #2979ff;
}
}
/* 地址选择面板专用样式 */
.address-panel {
overflow-y: auto;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
/* 格口信息面板专用样式 */
.cells-panel {
overflow-y: auto;
padding: 0px;
background: #ffffff;
flex: 1;
}
</style>

View File

@ -45,6 +45,10 @@ export const useWxStore = defineStore("wx", () => {
balance.value = amount;
}
const setCorpid = (id: string) => {
corpid.value = id;
}
const setIsCabinetAdmin = (isAdmin: boolean) => {
isCabinetAdmin.value = isAdmin;
}
@ -178,7 +182,7 @@ export const useWxStore = defineStore("wx", () => {
return { code, state, openid, corpid, userid, balance, useBalance,
balanceLimit, isCabinetAdmin, corpidLogin, name, ab98User, qyUserId, isFakeQyLogin,
isHandleWxCallbackComplete, setOpenid, setBalance, handleWxCallback, setIsCabinetAdmin,
isHandleWxCallbackComplete, setOpenid, setBalance, setCorpid, handleWxCallback, setIsCabinetAdmin,
refreshBalance, setAb98User, fakeQyLogin, waitForHandleWxCallbackComplete }
})

View File

@ -29,6 +29,10 @@ export function registerNavigationGuard(router: Router) {
if (isAdmin) {
return true;
}
const cid = urlParams.get('cid') || undefined;
if (cid) {
return true;
}
// useAb98UserStore位置不能放在外面否则会导致路由守卫无法正常工作
const ab98UserStore = useAb98UserStore();

View File

@ -143,6 +143,24 @@ export const routes: RouteRecordRaw[] = [
}
}
},
{
path: "/storage",
component: () => import("@/pages/cabinet/storage-horizontal.vue"),
name: "StorageHorizontal",
meta: {
title: "暂存柜管理(横屏)",
keepAlive: false,
layout: {
navBar: {
showNavBar: false,
showLeftArrow: false
},
tabbar: {
showTabbar: false
}
}
}
},
{
path: "/approval/list",
component: () => import("@/pages/approval/list.vue"),