Sfoglia il codice sorgente

fix: 补货模块完善 — 库存数据显示 + 退出登录 + 页面打磨

【后端】
- getDeviceList 新增 remainingStock/standardStock/inventoryPercent/replenishOrderGenerated
- 标准库存=所有商品 warning_threshold 之和,剩余库存=stock 之和

【补货首页】
- 标题居中,去掉右侧三点
- 搜索🔍→CSS放大镜,卡片>箭头→CSS,去掉emoji
- 字体统一设计系统: stat-label/metric-label→$font-size-xs, header-title→$font-size-xl
- info-tip 14→18rpx, label-tip 16→18rpx
- 去掉日均销量/销容比
- 右上角新增「退出」按钮

【补货操作】
- 商品图片显示(80rpx), 标准库存/剩余库存标签
- 状态颜色修正: normal $primary-color→$success-color

【补货单详情】
- NavBar替代黄色自定义头, 状态枚举对齐后端
- 硬编码颜色→设计token, 移除死功能(蓝牙/导出/撤销)
- 三按钮合并为「按补货单执行补货」
- 溢出修复: .section/.product-row/.head-title overflow hidden

【补货单列表+登录】
- orders: 补全status=2已同步, 加返回按钮
- login: 微信一键登录按钮恢复, 绑定码 whitespace 处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 22 ore fa
parent
commit
406547d64b

+ 38 - 1
haha-admin-mp/src/pages/login/login.vue

@@ -48,6 +48,19 @@
           <text v-else>登录中...</text>
         </button>
         
+        <!-- 分隔线 -->
+        <view class="login-divider">
+          <view class="divider-line"></view>
+          <text class="divider-text">补货员登录</text>
+          <view class="divider-line"></view>
+        </view>
+
+        <!-- 微信登录 -->
+        <button class="wechat-btn" :loading="wechatLoading" :disabled="wechatLoading" @click="handleWechatLogin">
+          <view class="wechat-icon"></view>
+          <text>微信一键登录</text>
+        </button>
+
         <!-- 绑定码入口 -->
         <view class="bind-toggle" v-if="!showBindInput" @click="showBindInput = true">
           <text>已有绑定码?扫码或输入绑定</text>
@@ -82,7 +95,7 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue';
 import { login } from '@/api/auth';
-import { bindWechat } from '@/api/replenish';
+import { bindWechat, loginByWechat } from '@/api/replenish';
 import { isLoggedIn, getHomePath } from '@/utils/auth';
 
 const formData = reactive({
@@ -101,6 +114,7 @@ const navigateHome = () => {
 
 const showPassword = ref(false);
 const loading = ref(false);
+const wechatLoading = ref(false);
 const showBindInput = ref(false);
 const bindingCode = ref('');
 const bindLoading = ref(false);
@@ -135,6 +149,29 @@ const handleLogin = async () => {
   }
 };
 
+const handleWechatLogin = async () => {
+  wechatLoading.value = true;
+  try {
+    const loginRes = await new Promise<any>((resolve, reject) => {
+      uni.login({ provider: 'weixin', success: (res) => resolve(res), fail: (err) => reject(err) });
+    });
+    if (!loginRes.code) { uni.showToast({ title: '微信授权失败', icon: 'none' }); return; }
+
+    await loginByWechat({ code: loginRes.code });
+    uni.showToast({ title: '登录成功', icon: 'success' });
+    setTimeout(() => uni.reLaunch({ url: '/pages/replenish/index' }), 500);
+  } catch (error: any) {
+    if (error.message?.includes('未绑定')) {
+      showBindInput.value = true;
+      uni.showToast({ title: '该微信未绑定账号,请输入绑定码', icon: 'none' });
+    } else {
+      uni.showToast({ title: error.message || '登录失败', icon: 'none' });
+    }
+  } finally {
+    wechatLoading.value = false;
+  }
+};
+
 /**
  * 扫码识别绑定码
  */

+ 36 - 34
haha-admin-mp/src/pages/replenish/index.vue

@@ -5,11 +5,7 @@
       <view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
       <view class="header-content">
         <text class="header-title">补货管理</text>
-        <view class="header-actions">
-          <view class="action-dot"></view>
-          <view class="action-dot"></view>
-          <view class="action-dot"></view>
-        </view>
+        <view class="header-logout" @click="handleLogout"><text>退出</text></view>
       </view>
     </view>
     <view class="header-placeholder" :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
@@ -80,7 +76,7 @@
     <!-- 搜索与筛选 -->
     <view class="search-bar">
       <view class="search-input-wrap">
-        <text class="search-icon">🔍</text>
+        <view class="search-icon"></view>
         <input
           class="search-input"
           v-model="searchKeyword"
@@ -93,7 +89,7 @@
       <view class="search-btn" @click="handleSearch">搜索</view>
       <view class="filter-btn" @click="showFilter = !showFilter">
         <text>筛选</text>
-        <text class="filter-icon">▽</text>
+        <view class="filter-icon"></view>
       </view>
     </view>
 
@@ -134,18 +130,7 @@
               <text class="metric-value">{{ device.remainingStock || 0 }}</text>
               <text class="metric-label">剩余库存</text>
             </view>
-            <view class="metric-item">
-              <text class="metric-value">{{ device.dailySales || 0 }}</text>
-              <text class="metric-label">日均销量</text>
-              <text class="info-tip">i</text>
-            </view>
-            <view class="metric-item">
-              <text class="metric-value">{{ device.salesRatio || 0 }}</text>
-              <text class="metric-label">销容比</text>
-              <text class="info-tip">i</text>
-            </view>
           </view>
-          <text class="card-arrow">></text>
         </view>
 
         <!-- 勾选框 -->
@@ -200,6 +185,7 @@ import { ref, computed, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import CustomTabBar from '@/components/CustomTabBar.vue';
 import { getDeviceList, batchGenerateOrders } from '@/api/replenish';
+import { clearAuth } from '@/utils/auth';
 
 const statusBarHeight = ref(0);
 
@@ -306,6 +292,19 @@ const handleBatchGenerate = async () => {
   }
 };
 
+const handleLogout = () => {
+  uni.showModal({
+    title: '退出登录',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) {
+        clearAuth();
+        uni.reLaunch({ url: '/pages/login/login' });
+      }
+    }
+  });
+};
+
 const loadData = async () => {
   loading.value = true;
   try {
@@ -345,12 +344,13 @@ onShow(async () => {
   padding-top: env(safe-area-inset-top);
 }
 .header-content {
-  display: flex; align-items: center; justify-content: space-between;
-  height: 44px; padding: 0 16rpx;
+  display: flex; align-items: center; justify-content: center;
+  height: 44px; padding: 0 16rpx; position: relative;
 }
-.header-title { font-size: 34rpx; font-weight: 700; color: #1e293b; }
-.header-actions { display: flex; gap: 6rpx; padding: 8rpx;
-  .action-dot { width: 6rpx; height: 6rpx; border-radius: 50%; background: #1e293b; }
+.header-title { font-size: $font-size-xl; font-weight: 700; color: $text-color-primary; }
+.header-logout { position: absolute; right: 24rpx; padding: 8rpx 16rpx;
+  text { font-size: 24rpx; color: $text-color-primary; }
+  &:active { opacity: 0.7; }
 }
 .header-placeholder { width: 100%; }
 
@@ -368,7 +368,7 @@ onShow(async () => {
   .label-icon {
     width: 32rpx; height: 32rpx; border-radius: 50%;
     display: flex; align-items: center; justify-content: center;
-    font-size: 18rpx; font-weight: 700; color: #fff;
+    font-size: $font-size-xs; font-weight: 700; color: $text-color-primary;
   }
   .warning-icon { background: $warning-color; }
   .threshold-icon { background: $primary-color; font-size: 16rpx; }
@@ -377,7 +377,7 @@ onShow(async () => {
   .label-tip {
     width: 28rpx; height: 28rpx; border-radius: 50%;
     background: $warning-color-bg; color: $warning-color;
-    font-size: 16rpx; font-weight: 700; font-style: italic;
+    font-size: 18rpx; font-weight: 700; font-style: italic;
     display: flex; align-items: center; justify-content: center;
   }
 }
@@ -393,7 +393,7 @@ onShow(async () => {
 }
 .threshold-row { border-top: 1rpx solid $border-color-light;
   .threshold-value { display: flex; align-items: center; gap: 8rpx; font-size: $font-size-lg; font-weight: 700; color: $text-color-primary;
-    .edit-icon { font-size: 24rpx; color: $primary-color; }
+    .edit-icon { font-size: 24rpx; color: $primary-color; display: inline-block; transform: scaleX(-1); }
   }
 }
 .toggle-switch {
@@ -416,7 +416,7 @@ onShow(async () => {
     .stat-num { display: block; font-size: 40rpx; font-weight: 800; color: $text-color-primary; line-height: 1.2;
       &.highlight { color: $primary-color-dark; }
     }
-    .stat-label { display: block; font-size: 20rpx; color: $text-color-muted; margin-top: 4rpx; line-height: 1.3;
+    .stat-label { display: block; font-size: $font-size-xs; color: $text-color-muted; margin-top: 4rpx; line-height: 1.3;
       &.highlight { color: $primary-color; font-weight: 600; }
     }
   }
@@ -430,7 +430,10 @@ onShow(async () => {
   flex: 1; display: flex; align-items: center; background: $bg-color-card;
   border-radius: $radius-base; padding: 0 16rpx; height: 72rpx;
   border: 1rpx solid $border-color;
-  .search-icon { font-size: 28rpx; margin-right: 8rpx; }
+  .search-icon { width: 32rpx; height: 32rpx; margin-right: 8rpx; flex-shrink: 0; position: relative;
+    &::before { content: ''; position: absolute; top: 2rpx; left: 2rpx; width: 18rpx; height: 18rpx; border: 2.5rpx solid $text-color-placeholder; border-radius: 50%; }
+    &::after { content: ''; position: absolute; bottom: 2rpx; right: 2rpx; width: 2.5rpx; height: 10rpx; background: $text-color-placeholder; border-radius: 1rpx; transform: rotate(-45deg); transform-origin: top center; }
+  }
   .search-input { flex: 1; font-size: $font-size-base; color: $text-color-primary; }
 }
 .search-placeholder { color: $text-color-placeholder; }
@@ -444,7 +447,7 @@ onShow(async () => {
   display: flex; align-items: center; gap: 4rpx; padding: 0 20rpx; height: 72rpx; line-height: 72rpx;
   background: $bg-color-card; border-radius: $radius-base; border: 1rpx solid $border-color;
   font-size: $font-size-sm; color: $text-color-secondary;
-  .filter-icon { font-size: 18rpx; }
+  .filter-icon { width: 0; height: 0; border-left: 8rpx solid transparent; border-right: 8rpx solid transparent; border-top: 10rpx solid $text-color-secondary; }
   &:active { opacity: 0.7; }
 }
 
@@ -478,8 +481,8 @@ onShow(async () => {
 .card-body { display: flex; align-items: flex-start; gap: 16rpx;
   .inventory-badge {
     width: 100rpx; padding: 12rpx 0; border-radius: $radius-sm; text-align: center; flex-shrink: 0;
-    .badge-label { display: block; font-size: 20rpx; color: #fff; }
-    .badge-value { display: block; font-size: 32rpx; font-weight: 800; color: #fff; }
+    .badge-label { display: block; font-size: $font-size-xs; color: #fff; }
+    .badge-value { display: block; font-size: $font-size-lg; font-weight: 800; color: #fff; }
     &.danger { background: $error-color; }
     &.warning { background: $accent-color; }
     &.normal { background: $success-color; }
@@ -487,11 +490,10 @@ onShow(async () => {
   .metrics-row { flex: 1; display: flex; flex-wrap: wrap;
     .metric-item { width: 50%; margin-bottom: 8rpx;
       .metric-value { font-size: $font-size-base; font-weight: 700; color: $text-color-primary; }
-      .metric-label { font-size: 20rpx; color: $text-color-muted; margin-left: 4rpx; }
-      .info-tip { font-size: 14rpx; color: $warning-color; font-style: italic; margin-left: 2rpx; }
+      .metric-label { font-size: $font-size-xs; color: $text-color-muted; margin-left: 4rpx; }
+      .info-tip { font-size: 18rpx; color: $warning-color; font-style: italic; margin-left: 2rpx; }
     }
   }
-  .card-arrow { position: absolute; right: 72rpx; top: 50%; transform: translateY(-50%); font-size: 28rpx; color: $text-color-placeholder; }
 }
 .card-checkbox {
   position: absolute; right: 24rpx; top: 50%; transform: translateY(-50%);

+ 31 - 50
haha-admin-mp/src/pages/replenish/operation.vue

@@ -43,27 +43,34 @@
           v-for="(item, index) in inventoryList"
           :key="index"
         >
-          <view class="item-header">
-            <text class="item-name">{{ item.product_name || item.productName || '未知商品' }}</text>
-            <text class="item-code" v-if="item.product_code || item.productCode">
-              {{ item.product_code || item.productCode }}
-            </text>
-            <view :class="['stock-status', getStockStatus(item)]">
+          <view class="item-main">
+            <image
+              v-if="item.product_image"
+              :src="item.product_image"
+              class="item-img"
+              mode="aspectFill"
+            />
+            <view v-else class="item-img-placeholder"><text>无图</text></view>
+            <view class="item-info">
+              <text class="item-name">{{ item.product_name || item.productName || '未知商品' }}</text>
+              <text class="item-code" v-if="item.product_code || item.productCode">{{ item.product_code || item.productCode }}</text>
+            </view>
+            <view :class="['stock-badge', getStockStatus(item)]">
               {{ getStockStatusText(item) }}
             </view>
           </view>
 
           <view class="item-stock">
             <view class="stock-row">
-              <text class="stock-label">当前库存</text>
+              <text class="stock-label">标准库存</text>
+              <text class="stock-value muted">{{ item.warning_threshold || item.warningThreshold || '-' }}</text>
+            </view>
+            <view class="stock-row">
+              <text class="stock-label">剩余库存</text>
               <text :class="['stock-value', { 'low-stock': isLowStock(item), 'out-of-stock': isOutOfStock(item) }]">
                 {{ item.stock || 0 }}
               </text>
             </view>
-            <view class="stock-row">
-              <text class="stock-label">预警阈值</text>
-              <text class="stock-value muted">{{ item.warning_threshold || item.warningThreshold || 5 }}</text>
-            </view>
           </view>
 
           <!-- 补货输入 -->
@@ -419,45 +426,19 @@ onMounted(async () => {
   padding: 24rpx;
   margin-bottom: 16rpx;
 
-  .item-header {
-    display: flex;
-    align-items: center;
-    margin-bottom: 16rpx;
-
-    .item-name {
-      font-size: 28rpx;
-      font-weight: 600;
-      color: $text-color-primary;
-      flex: 1;
-    }
-
-    .item-code {
-      font-size: 20rpx;
-      color: $text-color-muted;
-      margin-right: 12rpx;
-    }
-
-    .stock-status {
-      padding: 4rpx 12rpx;
-      border-radius: 6rpx;
-      font-size: 20rpx;
-      font-weight: 500;
-
-      &.normal {
-        background: $success-color-bg;
-        color: $primary-color;
-      }
-
-      &.warning {
-        background: $warning-color-bg;
-        color: $warning-color;
-      }
-
-      &.danger {
-        background: $error-color-bg;
-        color: $error-color;
-      }
-    }
+  .item-main { display: flex; align-items: center; margin-bottom: 16rpx; }
+  .item-img { width: 80rpx; height: 80rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; margin-right: 16rpx; }
+  .item-img-placeholder { width: 80rpx; height: 80rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; margin-right: 16rpx; display: flex; align-items: center; justify-content: center;
+    text { font-size: 20rpx; color: $text-color-muted; }
+  }
+  .item-info { flex: 1; min-width: 0; overflow: hidden; }
+  .item-name { display: block; font-size: $font-size-base; font-weight: 600; color: $text-color-primary; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .item-code { font-size: 22rpx; color: $text-color-muted; }
+
+  .stock-badge { padding: 4rpx 12rpx; border-radius: $radius-sm; font-size: 20rpx; font-weight: 500; flex-shrink: 0; margin-left: 12rpx;
+    &.normal { background: $success-color-bg; color: $success-color; }
+    &.warning { background: $warning-color-bg; color: $warning-color; }
+    &.danger { background: $error-color-bg; color: $error-color; }
   }
 
   .item-stock {

+ 160 - 370
haha-admin-mp/src/pages/replenish/order-detail.vue

@@ -1,280 +1,165 @@
 <template>
   <view class="page">
-    <!-- 黄色导航栏 -->
-    <view class="yellow-header">
-      <view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
-      <view class="header-content">
-        <view class="back-btn" @click="goBack">
-          <view class="back-arrow"></view>
-        </view>
-        <text class="header-title">补货单详情</text>
-        <view class="header-actions">
-          <view class="action-dot"></view>
-          <view class="action-dot"></view>
-        </view>
-      </view>
-    </view>
-    <view class="header-placeholder" :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
+    <NavBar title="补货单详情" :showBack="true" />
 
-    <view v-if="loading" class="loading-container">
-      <view class="loading-spinner"></view>
-      <text class="loading-text">加载中...</text>
+    <view class="load-tip" v-if="loading">
+      <view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view>
     </view>
 
     <template v-else-if="order">
-      <!-- 蓝牙打印机提示 -->
-      <view class="printer-bar">
-        <text class="printer-text">暂未连接蓝牙打印机</text>
-        <text class="printer-link" @click="goPrinterConfig">去配置</text>
-      </view>
-
-      <!-- 订单头部信息 -->
-      <view class="order-header-card">
-        <view class="order-title-row">
-          <text class="order-title">{{ order.deviceName || order.deviceId }}</text>
-          <text class="order-status" :class="statusClass(order.status)">{{ statusLabel(order.status) }}</text>
-        </view>
-        <view class="order-info-row">
-          <text class="info-label">门店名称:</text>
-          <text class="info-value">{{ order.shopName || order.deviceName || '-' }}</text>
-        </view>
-        <view class="order-info-row">
-          <text class="info-label">任务编号:</text>
-          <text class="info-value">{{ order.taskNo || order.orderNo || '-' }}</text>
-        </view>
-        <view class="order-info-row">
-          <text class="info-label">创建时间:</text>
-          <text class="info-value">{{ order.createTime || '-' }}</text>
-        </view>
-        <view class="export-btn" @click="handleExport">
-          <text>导出/打印</text>
-        </view>
-      </view>
-
-      <!-- 待下架商品 -->
-      <view class="section-block">
-        <view class="section-title-row">
-          <text class="section-title">待下架商品</text>
-          <text class="section-count">(共{{ pendingRemovalItems.length }}种商品)</text>
-        </view>
-        <view class="empty-tip" v-if="pendingRemovalItems.length === 0">
-          <text>暂无待下架商品</text>
-        </view>
-        <view
-          class="product-row"
-          v-for="(item, index) in pendingRemovalItems"
-          :key="'remove-' + index"
-        >
-          <image v-if="item.productImage" :src="item.productImage" class="product-img" mode="aspectFill" />
-          <view v-else class="product-img-placeholder">
-            <text class="placeholder-text">无图</text>
+      <scroll-view class="detail-scroll" scroll-y>
+        <!-- 订单头 -->
+        <view class="header-card">
+          <view class="head-row">
+            <text class="head-title">{{ order.deviceName || order.deviceId }}</text>
+            <text class="head-status" :class="statusClass(order.status)">{{ statusLabel(order.status) }}</text>
           </view>
-          <view class="product-info">
-            <text class="product-name">{{ item.productName || '未知商品' }}</text>
+          <view class="head-info">
+            <text class="hi-label">门店</text>
+            <text class="hi-value">{{ order.shopName || '-' }}</text>
           </view>
-          <text class="product-qty">{{ item.plannedQuantity || item.quantity || 0 }}</text>
-        </view>
-      </view>
-
-      <!-- 待补货商品 -->
-      <view class="section-block">
-        <view class="section-title-row">
-          <view class="section-title-left">
-            <text class="section-title">待补货商品</text>
-            <text class="section-count">(共{{ replenishItems.length }}个商品)</text>
+          <view class="head-info">
+            <text class="hi-label">编号</text>
+            <text class="hi-value">{{ order.orderNo || '-' }}</text>
           </view>
-          <view class="show-zero-toggle" @click="showZeroQty = !showZeroQty">
-            <text class="toggle-label">展示待补货数量为0商品</text>
-            <view class="toggle-switch" :class="{ on: showZeroQty }">
-              <view class="toggle-knob"></view>
-            </view>
+          <view class="head-info">
+            <text class="hi-label">创建时间</text>
+            <text class="hi-value">{{ order.createTime || '-' }}</text>
           </view>
         </view>
 
-        <!-- 排序栏 -->
-        <view class="sort-bar">
-          <view class="sort-item" :class="{ active: sortField === 'name' }" @click="sortField = 'name'">
-            <text>商品名称</text>
-            <text class="sort-icon">{{ sortField === 'name' ? (sortAsc ? '↑' : '↓') : '' }}</text>
+        <!-- 待下架商品 -->
+        <view class="section">
+          <view class="section-head">
+            <text class="section-title">待下架商品</text>
+            <text class="section-count">{{ pendingRemovalItems.length }} 种</text>
           </view>
-          <view class="sort-item" :class="{ active: sortField === 'shelfNum' }" @click="sortField = 'shelfNum'">
-            <text>层数</text>
-            <text class="sort-icon">{{ sortField === 'shelfNum' ? (sortAsc ? '↑' : '↓') : '⇅' }}</text>
+          <view class="empty-tip" v-if="pendingRemovalItems.length === 0">
+            <text>暂无待下架商品</text>
           </view>
-          <view class="sort-item" :class="{ active: sortField === 'quantity' }" @click="sortField = 'quantity'">
-            <text>待补货数量</text>
-            <text class="sort-icon">{{ sortField === 'quantity' ? (sortAsc ? '↑' : '↓') : '⇅' }}</text>
+          <view class="product-row" v-for="(item, i) in pendingRemovalItems" :key="'rm-' + i">
+            <image v-if="item.productImage" :src="item.productImage" class="product-img" mode="aspectFill" />
+            <view v-else class="product-img-placeholder"><text>无图</text></view>
+            <view class="product-info">
+              <text class="product-name">{{ item.productName || '未知商品' }}</text>
+            </view>
+            <text class="product-qty">{{ item.plannedQuantity || item.quantity || 0 }}</text>
           </view>
         </view>
 
-        <view
-          class="product-row"
-          v-for="(item, index) in sortedReplenishItems"
-          :key="'replenish-' + index"
-        >
-          <image v-if="item.productImage" :src="item.productImage" class="product-img" mode="aspectFill" />
-          <view v-else class="product-img-placeholder">
-            <text class="placeholder-text">无图</text>
+        <!-- 待补货商品 -->
+        <view class="section">
+          <view class="section-head">
+            <view class="section-head-left">
+              <text class="section-title">待补货商品</text>
+              <text class="section-count">{{ displayReplenishItems.length }} 种</text>
+            </view>
+            <view class="zero-toggle" @click="showZeroQty = !showZeroQty">
+              <text class="toggle-label">含零</text>
+              <view class="toggle-switch" :class="{ on: showZeroQty }"><view class="toggle-knob"></view></view>
+            </view>
+          </view>
+
+          <view class="sort-bar">
+            <view class="sort-item" :class="{ active: sortField === 'name' }" @click="toggleSort('name')">
+              <text>名称</text>
+            </view>
+            <view class="sort-item" :class="{ active: sortField === 'shelfNum' }" @click="toggleSort('shelfNum')">
+              <text>层数</text>
+            </view>
+            <view class="sort-item" :class="{ active: sortField === 'quantity' }" @click="toggleSort('quantity')">
+              <text>数量</text>
+            </view>
           </view>
-          <view class="product-info">
-            <text class="product-name">{{ item.productName || '未知商品' }}</text>
+
+          <view class="product-row" v-for="(item, i) in displayReplenishItems" :key="'rp-' + i">
+            <image v-if="item.productImage" :src="item.productImage" class="product-img" mode="aspectFill" />
+            <view v-else class="product-img-placeholder"><text>无图</text></view>
+            <view class="product-info">
+              <text class="product-name">{{ item.productName || '未知商品' }}</text>
+              <text class="product-shelf" v-if="item.shelfNum">层 {{ item.shelfNum }} · {{ item.position || '' }}</text>
+            </view>
+            <text class="product-qty">{{ item.plannedQuantity || item.quantity || 0 }}</text>
           </view>
-          <text class="product-qty">{{ item.plannedQuantity || item.quantity || 0 }}</text>
         </view>
-      </view>
-    </template>
 
-    <!-- 底部操作栏 -->
-    <view class="bottom-bar" v-if="order && !loading">
-      <view class="bottom-btn secondary" @click="handleRemoteOpen">
-        <text>远程开门补货</text>
-      </view>
-      <view class="bottom-btn secondary" @click="handleCancel">
-        <text>撤销</text>
-      </view>
-      <view class="bottom-btn primary" @click="handleEdit">
-        <text>修改</text>
-      </view>
-    </view>
+        <view class="bottom-spacer" v-if="canExecute(order)"></view>
+      </scroll-view>
 
-    <CustomTabBar />
+      <!-- 底部操作 -->
+      <view class="bottom-bar" v-if="canExecute(order)">
+        <view class="action-btn" @click="goReplenish"><text>按补货单执行补货</text></view>
+      </view>
+    </template>
   </view>
 </template>
 
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue';
-import CustomTabBar from '@/components/CustomTabBar.vue';
+import NavBar from '@/components/NavBar.vue';
 import { getReplenisherOrderDetail } from '@/api/replenish';
 
-const statusBarHeight = ref(0);
 const loading = ref(true);
 const order = ref<any>(null);
 const items = ref<any[]>([]);
 const showZeroQty = ref(false);
-const sortField = ref('name');
+const sortField = ref('shelfNum');
 const sortAsc = ref(true);
 
-const statusMap: Record<number, string> = {
-  0: '草稿', 1: '待补货', 2: '已同步', 3: '已完成', 4: '已取消'
-};
+const statusMap: Record<number, string> = { 0: '草稿', 1: '已提交', 2: '已同步', 3: '已完成', 4: '已取消' };
 
-function statusLabel(status: number): string { return statusMap[status] || '未知'; }
-function statusClass(status: number): string {
-  const map: Record<number, string> = { 0: 'draft', 1: 'pending', 2: 'synced', 3: 'done', 4: 'cancelled' };
-  return map[status] || '';
+function statusLabel(s: number) { return statusMap[s] || '未知'; }
+function statusClass(s: number) {
+  const m: Record<number, string> = { 0: 'draft', 1: 'submitted', 2: 'synced', 3: 'done', 4: 'cancelled' };
+  return m[s] || '';
 }
 
-// 待下架商品(数量为0或标记为下架的商品)
-const pendingRemovalItems = computed(() => {
-  return items.value.filter((item: any) => {
-    const qty = item.plannedQuantity || item.quantity || 0;
-    return item.isRemoval || qty <= 0;
-  });
-});
+function canExecute(o: any) { return o.status === 0 || o.status === 1; }
 
-// 待补货商品
-const replenishItems = computed(() => {
-  let list = items.value.filter((item: any) => {
-    const qty = item.plannedQuantity || item.quantity || 0;
-    return !item.isRemoval && qty > 0;
-  });
-  if (!showZeroQty.value) {
-    list = list.filter((item: any) => (item.plannedQuantity || item.quantity || 0) > 0);
-  }
-  return list;
-});
+const pendingRemovalItems = computed(() =>
+  items.value.filter((item: any) => item.isRemoval || (item.plannedQuantity || item.quantity || 0) <= 0)
+);
+
+const replenishItems = computed(() =>
+  items.value.filter((item: any) => !item.isRemoval && (item.plannedQuantity || item.quantity || 0) > 0)
+);
 
-// 排序
-const sortedReplenishItems = computed(() => {
-  const list = [...replenishItems.value];
+const displayReplenishItems = computed(() => {
+  let list = showZeroQty.value ? [...replenishItems.value] : replenishItems.value.filter((i: any) => (i.plannedQuantity || i.quantity || 0) > 0);
   const dir = sortAsc.value ? 1 : -1;
-  list.sort((a, b) => {
-    if (sortField.value === 'name') {
-      return dir * ((a.productName || '').localeCompare(b.productName || ''));
-    }
-    if (sortField.value === 'shelfNum') {
-      return dir * ((a.shelfNum || 0) - (b.shelfNum || 0));
-    }
-    if (sortField.value === 'quantity') {
-      return dir * ((a.plannedQuantity || a.quantity || 0) - (b.plannedQuantity || b.quantity || 0));
-    }
-    return 0;
+  list.sort((a: any, b: any) => {
+    if (sortField.value === 'name') return dir * (a.productName || '').localeCompare(b.productName || '');
+    if (sortField.value === 'shelfNum') return dir * ((a.shelfNum || 0) - (b.shelfNum || 0));
+    return dir * ((a.plannedQuantity || a.quantity || 0) - (b.plannedQuantity || b.quantity || 0));
   });
   return list;
 });
 
-function goBack() {
-  const pages = getCurrentPages();
-  if (pages.length > 1) {
-    uni.navigateBack();
-  } else {
-    uni.switchTab({ url: '/pages/replenish/index' });
-  }
-}
-
-function goPrinterConfig() {
-  uni.showToast({ title: '蓝牙打印机配置功能开发中', icon: 'none' });
-}
-
-function handleExport() {
-  uni.showToast({ title: '导出/打印功能开发中', icon: 'none' });
-}
-
-function handleRemoteOpen() {
-  if (!order.value) return;
-  uni.navigateTo({
-    url: `/pages/replenish/operation?deviceId=${order.value.deviceId}&orderId=${order.value.id}`
-  });
-}
-
-function handleCancel() {
-  uni.showModal({
-    title: '确认撤销',
-    content: '确定要撤销此补货单吗?',
-    success: (res) => {
-      if (res.confirm) {
-        uni.showToast({ title: '撤销功能开发中', icon: 'none' });
-      }
-    }
-  });
+function toggleSort(field: string) {
+  if (sortField.value === field) { sortAsc.value = !sortAsc.value; } else { sortField.value = field; sortAsc.value = true; }
 }
 
-function handleEdit() {
+function goReplenish() {
   if (!order.value) return;
   const orderData = {
-    orderId: order.value.id,
-    orderNo: order.value.orderNo,
+    orderId: order.value.id, orderNo: order.value.orderNo,
     items: items.value.map((item: any) => ({
-      productCode: item.productCode,
-      productName: item.productName,
+      productCode: item.productCode, productName: item.productName,
       plannedQuantity: item.plannedQuantity || item.quantity || 0,
-      shelfNum: item.shelfNum,
-      position: item.position
+      shelfNum: item.shelfNum, position: item.position
     }))
   };
   uni.setStorageSync('pendingReplenishOrder', JSON.stringify(orderData));
-  uni.navigateTo({
-    url: `/pages/replenish/operation?deviceId=${order.value.deviceId}&orderId=${order.value.id}`
-  });
+  uni.navigateTo({ url: `/pages/replenish/operation?deviceId=${order.value.deviceId}&orderId=${order.value.id}` });
 }
 
 onMounted(async () => {
-  const systemInfo = uni.getSystemInfoSync();
-  statusBarHeight.value = systemInfo.statusBarHeight || 20;
-
   try {
     const pages = getCurrentPages();
-    const currentPage = pages[pages.length - 1] as any;
-    const id = currentPage?.options?.id;
-    if (!id) {
-      uni.showToast({ title: '参数错误', icon: 'none' });
-      loading.value = false;
-      setTimeout(() => uni.navigateBack(), 800);
-      return;
-    }
+    const id = (pages[pages.length - 1] as any)?.options?.id;
+    if (!id) { uni.showToast({ title: '参数错误', icon: 'none' }); loading.value = false; setTimeout(() => uni.navigateBack(), 800); return; }
     const data = await getReplenisherOrderDetail(id);
-    order.value = data?.order;
-    items.value = data?.items || [];
+    order.value = data?.order; items.value = data?.items || [];
   } catch (e: any) {
     uni.showToast({ title: e.message || '加载失败', icon: 'none' });
   } finally {
@@ -284,174 +169,79 @@ onMounted(async () => {
 </script>
 
 <style lang="scss" scoped>
-.page {
-  min-height: 100vh;
-  background: $bg-color-page;
-  padding-bottom: 180rpx;
-}
-
-// ====== 黄色导航栏 ======
-.yellow-header {
-  position: fixed; top: 0; left: 0; right: 0; z-index: 999;
-  background: $primary-color;
-  padding-top: constant(safe-area-inset-top);
-  padding-top: env(safe-area-inset-top);
-}
-.header-content {
-  display: flex; align-items: center; justify-content: space-between;
-  height: 44px; padding: 0 16rpx;
-}
-.back-btn {
-  width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center;
-  &:active { opacity: 0.7; }
-}
-.back-arrow {
-  width: 20rpx; height: 20rpx;
-  border-left: 4rpx solid #1e293b; border-bottom: 4rpx solid #1e293b;
-  transform: rotate(45deg); margin-left: 8rpx;
-}
-.header-title { font-size: 34rpx; font-weight: 700; color: #1e293b; }
-.header-actions { display: flex; gap: 6rpx; padding: 8rpx;
-  .action-dot { width: 6rpx; height: 6rpx; border-radius: 50%; background: #1e293b; }
-}
-.header-placeholder { width: 100%; }
-
-// ====== 加载 ======
-.loading-container {
-  display: flex; flex-direction: column; align-items: center; padding: 100rpx 0;
-  .loading-text { margin-top: 16rpx; font-size: 26rpx; color: $text-color-muted; }
-}
-
-// ====== 蓝牙打印机提示 ======
-.printer-bar {
-  display: flex; align-items: center; justify-content: space-between;
-  padding: 16rpx 24rpx; background: #fffbe6; border-bottom: 1rpx solid $border-color-light;
-  .printer-text { font-size: 24rpx; color: $warning-color; }
-  .printer-link { font-size: 24rpx; color: $primary-color-dark; font-weight: 600; }
-}
-
-// ====== 订单头部卡片 ======
-.order-header-card {
-  margin: 16rpx 24rpx; padding: 24rpx;
-  background: $bg-color-card; border-radius: $radius-md;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
-  position: relative;
-}
-.order-title-row {
-  display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx;
-  .order-title { font-size: 32rpx; font-weight: 700; color: $text-color-primary; }
-  .order-status {
-    padding: 4rpx 16rpx; border-radius: 6rpx; font-size: 22rpx; font-weight: 600;
-    &.draft { background: #f0f0f0; color: #999; }
-    &.pending { background: #fff3e0; color: #ff9800; }
-    &.synced { background: #e3f2fd; color: #2196f3; }
-    &.done { background: #e8f5e9; color: #4caf50; }
-    &.cancelled { background: #ffebee; color: #f44336; }
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; overflow: hidden; }
+.detail-scroll { flex: 1; height: 0; overflow: hidden; }
+
+// ====== Loading ======
+.load-tip { display: flex; justify-content: center; padding: 200rpx 0; }
+.dot-row { display: flex; gap: $spacing-1; }
+.pulse-dot { width: 10rpx; height: 10rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite;
+  &:nth-child(2) { animation-delay: 0.2s; } &:nth-child(3) { animation-delay: 0.4s; }
+}
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+
+// ====== Header card ======
+.header-card { margin: 16rpx 24rpx; padding: $spacing-3; background: $bg-color-card; border-radius: $radius-md; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); }
+.head-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx;
+  .head-title { font-size: $font-size-lg; font-weight: 700; color: $text-color-primary; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .head-status { padding: 4rpx 16rpx; border-radius: $radius-sm; font-size: 22rpx; font-weight: 500;
+    &.draft { background: $bg-color-secondary; color: $text-color-muted; }
+    &.submitted { background: $warning-color-bg; color: $warning-color; }
+    &.synced { background: $info-color-bg; color: $info-color; }
+    &.done { background: $success-color-bg; color: $success-color; }
+    &.cancelled { background: $error-color-bg; color: $error-color; }
   }
 }
-.order-info-row {
-  padding: 6rpx 0;
-  .info-label { font-size: 24rpx; color: $text-color-tertiary; }
-  .info-value { font-size: 24rpx; color: $text-color-primary; font-weight: 500; }
-}
-.export-btn {
-  position: absolute; right: 24rpx; bottom: 24rpx;
-  padding: 10rpx 24rpx; background: $primary-color; border-radius: $radius-base;
-  font-size: 22rpx; font-weight: 600; color: #1e293b;
-  &:active { opacity: 0.8; }
-}
-
-// ====== 区块 ======
-.section-block {
-  padding: 0 24rpx; margin-top: 16rpx;
-}
-.section-title-row {
-  display: flex; align-items: center; justify-content: space-between;
-  padding: 16rpx 0;
-  .section-title { font-size: 28rpx; font-weight: 700; color: $text-color-primary; }
-  .section-count { font-size: 24rpx; color: $text-color-muted; }
+.head-info { padding: 6rpx 0; display: flex;
+  .hi-label { font-size: $font-size-sm; color: $text-color-muted; margin-right: $spacing-1; flex-shrink: 0; }
+  .hi-value { font-size: $font-size-sm; color: $text-color-primary; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 }
-.section-title-left { display: flex; align-items: baseline; gap: 8rpx; }
 
-.empty-tip {
-  text-align: center; padding: 40rpx 0;
-  text { font-size: 26rpx; color: $text-color-muted; }
+// ====== Section ======
+.section { padding: 0 24rpx; margin-top: 16rpx; width: 100%; box-sizing: border-box; overflow: hidden; }
+.section-head { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0;
+  .section-title { font-size: $font-size-base; font-weight: 700; color: $text-color-primary; }
+  .section-count { font-size: $font-size-sm; color: $text-color-muted; }
 }
+.section-head-left { display: flex; align-items: baseline; gap: 8rpx; }
+.empty-tip { text-align: center; padding: 40rpx 0; text { font-size: 26rpx; color: $text-color-muted; } }
 
-// ====== 商品行 ======
-.product-row {
-  display: flex; align-items: center; padding: 16rpx 0;
-  border-bottom: 1rpx solid $border-color-light;
-  &:last-child { border-bottom: none; }
-}
-.product-img {
-  width: 80rpx; height: 80rpx; border-radius: 10rpx;
-  background: $bg-color-page; margin-right: 16rpx; flex-shrink: 0;
-}
-.product-img-placeholder {
-  width: 80rpx; height: 80rpx; border-radius: 10rpx;
-  background: $bg-color-page; margin-right: 16rpx; flex-shrink: 0;
-  display: flex; align-items: center; justify-content: center;
-  .placeholder-text { font-size: 20rpx; color: $text-color-muted; }
-}
-.product-info { flex: 1;
-  .product-name { font-size: 26rpx; color: $text-color-primary; line-height: 1.4; }
+// ====== Toggle ======
+.zero-toggle { display: flex; align-items: center; gap: 8rpx;
+  .toggle-label { font-size: 20rpx; color: $text-color-muted; }
 }
-.product-qty {
-  font-size: 30rpx; font-weight: 700; color: $text-color-primary;
-  margin-left: 16rpx; flex-shrink: 0; min-width: 48rpx; text-align: right;
+.toggle-switch { width: 56rpx; height: 32rpx; border-radius: 16rpx; background: $border-color; position: relative; transition: background 0.2s;
+  .toggle-knob { width: 26rpx; height: 26rpx; border-radius: 50%; background: $bg-color-card; position: absolute; top: 3rpx; left: 3rpx; transition: left 0.2s; box-shadow: 0 1rpx 4rpx rgba(0,0,0,0.1); }
+  &.on { background: $primary-color; .toggle-knob { left: 27rpx; } }
 }
 
-// ====== 排序栏 ======
-.sort-bar {
-  display: flex; background: $bg-color-card; border-radius: $radius-sm;
-  border: 1rpx solid $border-color; overflow: hidden; margin-bottom: 8rpx;
-}
-.sort-item {
-  flex: 1; display: flex; align-items: center; justify-content: center; gap: 4rpx;
-  padding: 14rpx 0; font-size: 22rpx; color: $text-color-tertiary;
-  border-right: 1rpx solid $border-color-light;
+// ====== Sort bar ======
+.sort-bar { display: flex; background: $bg-color-card; border-radius: $radius-sm; border: 1rpx solid $border-color; overflow: hidden; margin-bottom: 8rpx; }
+.sort-item { flex: 1; padding: 12rpx 0; text-align: center; font-size: 22rpx; color: $text-color-muted; border-right: 1rpx solid $border-color-light;
   &:last-child { border-right: none; }
-  &.active { color: $primary-color-dark; font-weight: 600; }
-  .sort-icon { font-size: 18rpx; }
-  &:active { background: $bg-color-page; }
+  &.active { color: $primary-color-dark; font-weight: 600; background: $primary-color-bg; }
+  &:active { opacity: 0.7; }
 }
 
-// ====== 展示0商品开关 ======
-.show-zero-toggle {
-  display: flex; align-items: center; gap: 8rpx;
-  .toggle-label { font-size: 20rpx; color: $text-color-muted; }
+// ====== Product row ======
+.product-row { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid $border-color-light; width: 100%; box-sizing: border-box;
+  &:last-child { border-bottom: none; }
 }
-.toggle-switch {
-  width: 64rpx; height: 36rpx; border-radius: 18rpx;
-  background: $border-color; position: relative; transition: background 0.3s;
-  .toggle-knob {
-    width: 30rpx; height: 30rpx; border-radius: 50%; background: #fff;
-    position: absolute; top: 3rpx; left: 3rpx; transition: left 0.3s;
-    box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.15);
-  }
-  &.on { background: $primary-color;
-    .toggle-knob { left: 31rpx; }
-  }
+.product-img { width: 72rpx; height: 72rpx; border-radius: $radius-sm; background: $bg-color-page; margin-right: 16rpx; flex-shrink: 0; }
+.product-img-placeholder { width: 72rpx; height: 72rpx; border-radius: $radius-sm; background: $bg-color-page; margin-right: 16rpx; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
+  text { font-size: 20rpx; color: $text-color-muted; }
 }
-
-// ====== 底部操作栏 ======
-.bottom-bar {
-  position: fixed; bottom: 0; left: 0; right: 0;
-  display: flex; align-items: center; gap: 16rpx;
-  padding: 16rpx 24rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
-  background: $bg-color-card; border-top: 1rpx solid $border-color-light;
-  z-index: 100;
+.product-info { flex: 1; min-width: 0; overflow: hidden;
+  .product-name { display: block; font-size: 26rpx; color: $text-color-primary; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
+  .product-shelf { font-size: 20rpx; color: $text-color-muted; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 }
-.bottom-btn {
-  flex: 1; height: 80rpx; line-height: 80rpx; text-align: center;
-  border-radius: $radius-base; font-size: $font-size-base; font-weight: 600;
+.product-qty { font-size: 30rpx; font-weight: 700; color: $text-color-primary; margin-left: 16rpx; flex-shrink: 0; min-width: 40rpx; text-align: right; }
+
+// ====== Bottom ======
+.bottom-spacer { height: 120rpx; }
+.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: $spacing-3 $spacing-4; padding-bottom: calc($spacing-3 + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; }
+.action-btn { display: flex; align-items: center; justify-content: center; width: 100%; padding: 26rpx 0; border-radius: $radius-xl; background: $primary-color;
+  text { font-size: $font-size-md; font-weight: 600; color: $text-color-primary; }
   &:active { opacity: 0.8; }
-  &.secondary {
-    background: $bg-color-secondary; color: $text-color-secondary;
-  }
-  &.primary {
-    background: $primary-color; color: #1e293b;
-  }
 }
 </style>

+ 1 - 1
haha-admin-mp/src/pages/replenish/orders.vue

@@ -1,6 +1,6 @@
 <template>
   <view class="page">
-    <NavBar title="补货单" />
+    <NavBar title="补货单" :showBack="true" />
 
     <!-- 状态筛选 -->
     <view class="filter-bar">

+ 7 - 0
haha-admin/src/main/java/com/haha/admin/controller/ReplenisherOperationController.java

@@ -132,12 +132,14 @@ public class ReplenisherOperationController {
             // 查询该设备的库存概览
             List<Map<String, Object>> inventoryList = deviceInventoryService.getInventoryWithProduct(deviceId);
             int totalStock = 0;
+            int standardStock = 0;
             int lowStockCount = 0;
             int zeroStockCount = 0;
             for (Map<String, Object> inv : inventoryList) {
                 Integer stock = inv.get("stock") != null ? ((Number) inv.get("stock")).intValue() : 0;
                 Integer warningThreshold = inv.get("warning_threshold") != null ? ((Number) inv.get("warning_threshold")).intValue() : 0;
                 totalStock += stock;
+                standardStock += warningThreshold;
                 if (stock == 0) {
                     zeroStockCount++;
                 } else if (stock <= warningThreshold) {
@@ -145,6 +147,10 @@ public class ReplenisherOperationController {
                 }
             }
             deviceInfo.put("totalStock", totalStock);
+            deviceInfo.put("remainingStock", totalStock);
+            deviceInfo.put("standardStock", standardStock);
+            int inventoryPercent = standardStock > 0 ? (int) ((double) totalStock / standardStock * 100) : 0;
+            deviceInfo.put("inventoryPercent", inventoryPercent);
             deviceInfo.put("lowStockCount", lowStockCount);
             deviceInfo.put("zeroStockCount", zeroStockCount);
             deviceInfo.put("productCount", inventoryList.size());
@@ -152,6 +158,7 @@ public class ReplenisherOperationController {
             deviceInfo.put("pendingOrderCount", pendingOrderCountMap.getOrDefault(deviceId, 0L));
             deviceInfo.put("submittedOrderCount", submittedOrderCountMap.getOrDefault(deviceId, 0L));
             deviceInfo.put("todayReplenished", todayRestockedMap.containsKey(deviceId));
+            deviceInfo.put("replenishOrderGenerated", pendingOrderCountMap.getOrDefault(deviceId, 0L) > 0);
 
             result.add(deviceInfo);
         }