Browse Source

feat: 补货流程完善 — 按单补货 + 筛选标签 + 退款对齐 + 登录页优化

【补货员流程】
- index: 页面重设计,emoji→CSS图标,状态颜色修正,去除底部TabBar
- index: 新增4筛选标签(全部/有补货单/待完成/今日已补)含数量
- operation: 支持按补货单预填数量,提交关联orderId,数量上限9999
- order-detail: 新增「执行补货」按钮,存储预填数据跳转operation
- orders: 补全status=2(已同步)筛选标签
- CustomTabBar: 补货员身份隐藏底部导航栏

【后端】
- ReplenisherOperationController.getDeviceList: 批量返回pendingOrderCount/submittedOrderCount/todayReplenished

【登录页】
- 移除微信一键登录按钮(非必须)
- 绑定入口独立:账号密码登录补货员同样可见
- bindWechat静态导入替代动态import
- 绑定码校验: /^[A-Z0-9]{24}$/ + replace(/\s/g,'')

【退款】
- api/order: 支持products[]/refundAmount参数(对齐web的handleRefund)
- detail: 退款弹窗: 全额/自定义 双模式+商品选择+原因必填+已退款保护
- 弹窗溢出修复: rp-info加overflow hidden+text-overflow

【Web后台】
- 绑定码展示去formatBindingCode空格(防复制截断)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 22 hours ago
parent
commit
eb26c4f8b2

+ 10 - 3
haha-admin-mp/src/api/order.ts

@@ -49,8 +49,15 @@ export async function getOrderStats(params?: { startDate?: string; endDate?: str
 }
 
 /**
- * 处理退款
+ * 处理退款(支持部分退款、自定义金额、全额退款)
  */
-export async function handleRefund(orderId: string, reason: string): Promise<void> {
-  await post(`/order/${orderId}/refund`, { reason });
+export async function handleRefund(
+  orderId: string,
+  reason: string,
+  data?: { products?: { productId: string; productName: string; quantity: number; price: number }[]; refundAmount?: number }
+): Promise<any> {
+  const body: any = { reason };
+  if (data?.products?.length) body.products = data.products;
+  if (data?.refundAmount) body.refundAmount = data.refundAmount;
+  return post(`/order/${orderId}/refund`, body);
 }

+ 2 - 1
haha-admin-mp/src/api/replenish.ts

@@ -59,10 +59,11 @@ export interface ReplenishItem {
 export interface ReplenishParams {
   deviceId: string;
   items: ReplenishItem[];
+  orderId?: string;
 }
 
 /**
- * 执行补货操作
+ * 执行补货操作(支持关联补货单 orderId)
  */
 export async function replenishStock(params: ReplenishParams): Promise<any> {
   return post('/replenisher/stock/replenish', params);

+ 4 - 1
haha-admin-mp/src/components/CustomTabBar.vue

@@ -1,5 +1,5 @@
 <template>
-  <view class="custom-tabbar">
+  <view class="custom-tabbar" v-if="visible">
     <view class="tab-item" :class="{ active: currentIndex === 0 }" @click="switchTab(0)">
       <view class="tab-icon">
         <view class="icon-work"></view>
@@ -40,6 +40,9 @@
 <script setup lang="ts">
 import { ref, onMounted } from 'vue';
 import { logger } from '@/utils/logger';
+import { isReplenisher } from '@/utils/auth';
+
+const visible = !isReplenisher();
 
 const tabPages = [
   '/pages/index/index',

+ 35 - 83
haha-admin-mp/src/pages/login/login.vue

@@ -48,42 +48,21 @@
           <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 class="bind-toggle" v-if="!showBindInput" @click="showBindInput = true">
+          <text>已有绑定码?扫码或输入绑定</text>
         </view>
-        
-        <!-- 微信登录 -->
-        <button class="wechat-btn" :loading="wechatLoading" :disabled="wechatLoading" @click="handleWechatLogin">
-          <view class="wechat-icon"></view>
-          <text>补货员微信一键登录</text>
-        </button>
-
-        <!-- 绑定码登录 -->
         <view class="bind-section" v-if="showBindInput">
-          <view class="divider-line"></view>
-          <text class="bind-hint">该微信未绑定补货员账号,请扫码或输入管理员提供的一次性绑定码:</text>
           <view class="bind-input-wrapper">
-            <input
-              class="bind-input"
-              type="text"
-              v-model="bindingCode"
-              placeholder="请输入24位绑定码"
-              placeholder-class="input-placeholder"
-              maxlength="24"
-            />
-            <view class="scan-bind-btn" @click="handleScanBindCode">
-              <text class="scan-icon-text">扫码</text>
-            </view>
+            <input class="bind-input" type="text" v-model="bindingCode" placeholder="请输入24位绑定码" placeholder-class="input-placeholder" maxlength="32" />
+            <view class="scan-bind-btn" @click="handleScanBindCode"><text>扫码</text></view>
           </view>
           <button class="bind-btn" :loading="bindLoading" :disabled="bindLoading || !bindingCode" @click="handleBindWechat">
             <text v-if="!bindLoading">绑定微信并登录</text>
             <text v-else>绑定中...</text>
           </button>
+          <text class="bind-cancel" @click="showBindInput = false">取消绑定</text>
         </view>
-        <text v-else class="bind-toggle" @click="showBindInput = true">已有绑定码?点击绑定微信</text>
         
         <view class="login-tips">
           <view class="tips-icon"></view>
@@ -103,23 +82,32 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue';
 import { login } from '@/api/auth';
-import { isLoggedIn } from '@/utils/auth';
+import { bindWechat } from '@/api/replenish';
+import { isLoggedIn, getHomePath } from '@/utils/auth';
 
 const formData = reactive({
   username: '',
   password: ''
 });
 
+const navigateHome = () => {
+  const home = getHomePath();
+  if (home === '/pages/replenish/index') {
+    uni.reLaunch({ url: home });
+  } else {
+    uni.switchTab({ url: home });
+  }
+};
+
 const showPassword = ref(false);
 const loading = ref(false);
-const wechatLoading = ref(false);
 const showBindInput = ref(false);
 const bindingCode = ref('');
 const bindLoading = ref(false);
 
 onMounted(() => {
   if (isLoggedIn()) {
-    uni.switchTab({ url: '/pages/index/index' });
+    navigateHome();
   }
 });
 
@@ -138,7 +126,7 @@ const handleLogin = async () => {
     await login(formData);
     uni.showToast({ title: '登录成功', icon: 'success' });
     setTimeout(() => {
-      uni.switchTab({ url: '/pages/index/index' });
+      navigateHome();
     }, 500);
   } catch (error: any) {
     uni.showToast({ title: error.message || '登录失败', icon: 'none' });
@@ -147,44 +135,6 @@ const handleLogin = async () => {
   }
 };
 
-const handleWechatLogin = async () => {
-  wechatLoading.value = true;
-  try {
-    // 1. 微信授权获取code
-    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;
-    }
-    
-    // 2. 调用后端登录接口
-    const { loginByWechat } = await import('@/api/replenish');
-    await loginByWechat({ code: loginRes.code });
-    
-    uni.showToast({ title: '登录成功', icon: 'success' });
-    setTimeout(() => {
-      uni.reLaunch({ url: '/pages/replenish/index' });
-    }, 500);
-  } catch (error: any) {
-    // 如果是未绑定的账号,显示绑定码输入
-    if (error.message && error.message.includes('未绑定')) {
-      showBindInput.value = true;
-      uni.showToast({ title: '该微信未绑定账号,请输入绑定码', icon: 'none' });
-    } else {
-      uni.showToast({ title: error.message || '登录失败', icon: 'none' });
-    }
-  } finally {
-    wechatLoading.value = false;
-  }
-};
-
 /**
  * 扫码识别绑定码
  */
@@ -212,31 +162,25 @@ const handleScanBindCode = () => {
  * 补货员绑定微信
  */
 const handleBindWechat = async () => {
-  if (!bindingCode.value || bindingCode.value.length < 4) {
-    uni.showToast({ title: '请输入有效的绑定码', icon: 'none' });
+  const code = (bindingCode.value || '').replace(/\s/g, '').toUpperCase();
+  if (!/^[A-Z0-9]{24}$/.test(code)) {
+    uni.showToast({ title: '绑定码格式不正确,应为24位字母数字', icon: 'none' });
     return;
   }
-  
+
   bindLoading.value = true;
   try {
-    // 1. 微信授权获取code
     const loginRes = await new Promise<any>((resolve, reject) => {
-      uni.login({
-        provider: 'weixin',
-        success: (res) => resolve(res),
-        fail: (err) => reject(err)
-      });
+      uni.login({ provider: 'weixin', success: (res) => resolve(res), fail: (err) => reject(err) });
     });
-    
+
     if (!loginRes.code) {
       uni.showToast({ title: '微信授权失败', icon: 'none' });
       return;
     }
-    
-    // 2. 调用绑定接口
-    const { bindWechat } = await import('@/api/replenish');
+
     await bindWechat({
-      bindingCode: bindingCode.value.trim().toUpperCase(),
+      bindingCode: code,
       code: loginRes.code
     });
     
@@ -655,6 +599,14 @@ const handleBindWechat = async () => {
   color: $primary-color;
 }
 
+.bind-cancel {
+  display: block;
+  text-align: center;
+  margin-top: 16rpx;
+  font-size: 24rpx;
+  color: $text-color-muted;
+}
+
 /* 绑定码区域 */
 .bind-section {
   margin-top: 20rpx;

+ 373 - 419
haha-admin-mp/src/pages/orders/detail.vue

@@ -2,16 +2,10 @@
   <view class="page">
     <NavBar title="订单详情" :showBack="true" />
 
-    <!-- 加载 -->
     <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 class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view>
     </view>
 
-    <!-- 错误 -->
     <view class="error-state" v-if="!loading && errorMsg">
       <view class="error-icon"></view>
       <text class="error-text">{{ errorMsg }}</text>
@@ -19,15 +13,17 @@
     </view>
 
     <scroll-view class="detail-scroll" scroll-y v-if="!loading && order">
-      <!-- 状态区域 -->
-      <view class="status-section">
-        <view class="status-row">
-          <view class="status-tag" :class="getPayStatusClass(order.payStatus)">
-            <text>{{ order.payStatusLabel || getPayStatusText(order.payStatus) }}</text>
+      <!-- 状态卡片 -->
+      <view class="card status-card">
+        <view class="status-left">
+          <view class="status-circle" :class="getPayStatusClass(order.payStatus)">
+            <text>{{ getStatusIcon(order.payStatus) }}</text>
+          </view>
+          <view class="status-text-wrap">
+            <text class="status-label">{{ order.payStatusLabel || getPayStatusText(order.payStatus) }}</text>
+            <text class="status-desc">{{ getStatusDesc(order.status, order.payStatus) }}</text>
           </view>
-          <text class="order-no">订单 {{ order.orderNo }}</text>
         </view>
-        <text class="status-desc" :class="getPayStatusClass(order.payStatus)">{{ getStatusDesc(order.status, order.payStatus) }}</text>
       </view>
 
       <!-- 视频 -->
@@ -36,18 +32,19 @@
       </view>
 
       <!-- 订单明细 -->
-      <view class="section">
-        <text class="section-title">订单明细</text>
+      <view class="card">
+        <text class="card-title">订单明细</text>
+
         <view class="product-list" v-if="order.products && order.products.length > 0">
           <view class="product-item" v-for="item in order.products" :key="item.productId">
             <image class="product-img" :src="item.pic || '/static/images/default-product.png'" mode="aspectFill" />
             <view class="product-main">
               <text class="product-name">{{ item.productName }}</text>
-              <text class="product-meta">x{{ item.productNum }}</text>
+              <text class="product-price">¥{{ formatMoney(item.price) }}</text>
             </view>
-            <view class="product-price">
-              <text class="price-unit">¥{{ formatMoney(item.price) }}</text>
-              <text class="price-total">¥{{ formatMoney(item.price * item.productNum) }}</text>
+            <view class="product-right">
+              <text class="product-qty">x{{ item.productNum }}</text>
+              <text class="product-subtotal">¥{{ formatMoney(item.price * item.productNum) }}</text>
             </view>
           </view>
         </view>
@@ -57,70 +54,151 @@
         </view>
 
         <view class="amount-section">
-          <view class="amount-row" v-if="order.discountAmount > 0">
+          <view class="amount-row discount" v-if="order.discountAmount > 0">
             <text class="amount-label">优惠金额</text>
-            <text class="amount-value discount">-¥{{ formatMoney(order.discountAmount) }}</text>
+            <text class="amount-value">-¥{{ formatMoney(order.discountAmount) }}</text>
           </view>
-          <view class="amount-row total">
+          <view class="amount-row paid">
             <text class="amount-label">实付款</text>
             <text class="amount-value">¥{{ formatMoney(order.paidAmount) }}</text>
           </view>
         </view>
       </view>
 
-      <!-- 订单信息 -->
-      <view class="section">
-        <text class="section-title">订单信息</text>
-
-        <view class="info-row">
-          <text class="info-label">订单编号</text>
-          <view class="info-value-row">
-            <text class="info-value mono">{{ order.orderNo }}</text>
-            <view class="copy-btn" @click="copyText(order.orderNo)"><text>复制</text></view>
+      <!-- 退款记录 -->
+      <view class="card" v-if="order.refunds && order.refunds.length > 0">
+        <text class="card-title">退款记录</text>
+        <view class="refund-list">
+          <view class="refund-item" v-for="(r, i) in order.refunds" :key="i">
+            <view class="refund-timeline">
+              <view class="timeline-dot" :class="r.status === 'REFUNDED' ? 'success' : r.status === 'REJECTED' ? 'rejected' : 'pending'"></view>
+              <view class="timeline-line" v-if="i < order.refunds.length - 1"></view>
+            </view>
+            <view class="refund-content">
+              <view class="refund-header">
+                <text class="refund-status" :class="r.status === 'REFUNDED' ? 'success' : r.status === 'REJECTED' ? 'rejected' : 'pending'">
+                  {{ r.status === 'REFUNDED' ? '已退款' : r.status === 'REJECTED' ? '已拒绝' : '审核中' }}
+                </text>
+                <text class="refund-amount">¥{{ formatMoney(r.refundAmount) }}</text>
+              </view>
+              <text class="refund-time">{{ r.createTime }}</text>
+              <text class="refund-reason" v-if="r.reason">原因: {{ r.reason }}</text>
+            </view>
           </view>
         </view>
-        <view class="info-row">
-          <text class="info-label">下单时间</text>
-          <text class="info-value">{{ order.createTime || '-' }}</text>
-        </view>
-        <view class="info-row" v-if="order.payTime">
-          <text class="info-label">支付时间</text>
-          <text class="info-value">{{ order.payTime }}</text>
-        </view>
-        <view class="info-row">
-          <text class="info-label">门店名称</text>
-          <text class="info-value">{{ order.shopName || '-' }}</text>
-        </view>
-        <view class="info-row">
-          <text class="info-label">设备名称</text>
-          <text class="info-value">{{ order.deviceName || '-' }}</text>
-        </view>
-        <view class="info-row">
-          <text class="info-label">设备编号</text>
-          <text class="info-value mono">{{ order.deviceId || '-' }}</text>
-        </view>
-        <view class="info-row" v-if="order.phone">
-          <text class="info-label">消费者</text>
-          <view class="info-value-row">
-            <text class="info-value">{{ formatPhone(order.phone) }}</text>
-            <view class="action-btn call-btn" @click="callPhone(order.phone)"><text>呼叫</text></view>
+      </view>
+
+      <!-- 订单信息 -->
+      <view class="card">
+        <text class="card-title">订单信息</text>
+        <view class="info-list">
+          <view class="info-row">
+            <text class="info-label">订单编号</text>
+            <view class="info-value-row">
+              <text class="info-value">{{ order.orderNo }}</text>
+              <view class="copy-btn" @click="copyText(order.orderNo)"><text>复制</text></view>
+            </view>
+          </view>
+          <view class="info-row">
+            <text class="info-label">下单时间</text>
+            <text class="info-value">{{ order.createTime || '-' }}</text>
+          </view>
+          <view class="info-row" v-if="order.payTime">
+            <text class="info-label">支付时间</text>
+            <text class="info-value">{{ order.payTime }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">门店名称</text>
+            <text class="info-value">{{ order.shopName || '-' }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">设备名称</text>
+            <text class="info-value">{{ order.deviceName || '-' }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">设备编号</text>
+            <text class="info-value mono">{{ order.deviceId || '-' }}</text>
+          </view>
+          <view class="info-row" v-if="order.userName || order.phone">
+            <text class="info-label">消费者</text>
+            <text class="info-value">
+              {{ [order.userName, order.phone ? formatPhone(order.phone) : ''].filter(Boolean).join(' ') }}
+            </text>
           </view>
-        </view>
-        <view class="info-row" v-if="order.userName">
-          <text class="info-label">用户名</text>
-          <text class="info-value">{{ order.userName }}</text>
         </view>
       </view>
-
-      <!-- 底部占位 -->
-      <view class="bottom-spacer" v-if="canRefund(order)"></view>
     </scroll-view>
 
-    <!-- 退款操作 -->
     <view class="bottom-bar" v-if="order && canRefund(order)">
-      <view class="refund-btn" :class="{ disabled: isRefunding }" @click="handleRefund">
-        <view class="loading-spinner" v-if="isRefunding"></view>
-        <text class="refund-btn-text">{{ isRefunding ? '处理中' : '退款' }}</text>
+      <view class="refund-btn" @click="openRefundModal"><text class="refund-btn-text">退款</text></view>
+    </view>
+
+    <!-- 退款弹窗 -->
+    <view class="refund-modal" v-if="showRefundModal" @click="showRefundModal = false">
+      <view class="refund-modal-content" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">退款</text>
+          <view class="modal-close" @click="showRefundModal = false"></view>
+        </view>
+        <scroll-view class="modal-body" scroll-y>
+          <!-- 退款方式 -->
+          <view class="refund-type-row">
+            <view class="refund-type-chip" :class="{ active: refundType === 'full' }" @click="refundType = 'full'; refundCustomAmount = ''; refundProducts = {}"><text>全额退款</text></view>
+            <view class="refund-type-chip" :class="{ active: refundType === 'custom' }" @click="refundType = 'custom'"><text>自定义</text></view>
+          </view>
+
+          <!-- 已退款提示 -->
+          <view class="refunded-tip" v-if="alreadyRefunded() > 0">
+            <text>已退款 ¥{{ formatMoney(alreadyRefunded()) }},实付 ¥{{ formatMoney(order?.paidAmount || 0) }}</text>
+          </view>
+
+          <!-- 自定义金额 -->
+          <view class="form-group" v-if="refundType === 'custom'">
+            <text class="form-label">退款金额 (元)</text>
+            <input class="form-input" v-model="refundCustomAmount" type="digit" placeholder="留空则为全额退款" />
+          </view>
+
+          <!-- 商品选择 (仅全额模式) -->
+          <view class="form-group" v-if="refundType === 'full' && refundableProducts().length > 0">
+            <text class="form-label">退款商品 (不选则为全额)</text>
+            <view class="refund-product-item" v-for="p in refundableProducts()" :key="p._idx">
+              <view class="rp-left" @click="toggleProduct(p._idx)">
+                <view class="rp-checkbox" :class="{ checked: refundProducts[p._idx] }">
+                  <text v-if="refundProducts[p._idx]">✓</text>
+                </view>
+                <image class="rp-img" :src="p.pic || '/static/images/default-product.png'" mode="aspectFill" />
+                <view class="rp-info">
+                  <text class="rp-name">{{ p.productName }}</text>
+                  <text class="rp-price">¥{{ formatMoney(p.price) }}</text>
+                </view>
+              </view>
+              <view class="rp-qty" v-if="refundProducts[p._idx]">
+                <view class="qty-btn" @click="setProductQty(p._idx, (refundProducts[p._idx] || 1) - 1)"><text>-</text></view>
+                <text class="qty-value">{{ refundProducts[p._idx] }}</text>
+                <view class="qty-btn" @click="setProductQty(p._idx, (refundProducts[p._idx] || 1) + 1)"><text>+</text></view>
+              </view>
+            </view>
+          </view>
+
+          <!-- 退款原因 -->
+          <view class="form-group">
+            <text class="form-label">退款原因 *</text>
+            <textarea class="form-textarea" v-model="refundReason" placeholder="请输入退款原因" :maxlength="200" />
+          </view>
+
+          <!-- 退款合计 -->
+          <view class="refund-total">
+            <text class="total-label">退款合计</text>
+            <text class="total-value">¥{{ formatMoney(refundTotalAmount()) }}</text>
+          </view>
+        </scroll-view>
+        <view class="modal-footer">
+          <button class="btn-cancel" @click="showRefundModal = false">取消</button>
+          <button class="btn-confirm" :class="{ disabled: isRefunding }" @click="handleRefund">
+            <view class="loading-spinner" v-if="isRefunding"></view>
+            <text>{{ isRefunding ? '处理中' : '确认退款' }}</text>
+          </button>
+        </view>
       </view>
     </view>
   </view>
@@ -141,6 +219,14 @@ interface OrderProduct {
   pic?: string;
 }
 
+interface RefundRecord {
+  status: string;
+  refundAmount: number;
+  createTime: string;
+  reason?: string;
+  remark?: string;
+}
+
 interface OrderDetail {
   id: string;
   orderNo: string;
@@ -149,6 +235,7 @@ interface OrderDetail {
   payStatusLabel?: string;
   paidAmount: number;
   discountAmount?: number;
+  refundAmount?: number;
   createTime: string;
   payTime?: string;
   shopName?: string;
@@ -158,6 +245,7 @@ interface OrderDetail {
   phone?: string;
   videoUrl?: string;
   products?: OrderProduct[];
+  refunds?: RefundRecord[];
 }
 
 const order = ref<OrderDetail | null>(null);
@@ -166,22 +254,35 @@ const errorMsg = ref('');
 const isRefunding = ref(false);
 let lastId = '';
 
+// Refund modal state
+const showRefundModal = ref(false);
+const refundReason = ref('');
+const refundProducts = ref<Record<number, number>>({});
+const refundType = ref<'full' | 'custom'>('full');
+const refundCustomAmount = ref('');
+
 const formatMoney = formatMoneyUtil;
 const formatPhone = formatPhoneUtil;
 
-const getPayStatusText = (payStatus: string): string => {
+const getStatusIcon = (payStatus: string) => {
+  if (payStatus === 'PAID') return '✓';
+  if (payStatus === 'REFUND') return '↩';
+  return '○';
+};
+
+const getPayStatusText = (payStatus: string) => {
   const map: Record<string, string> = { UNPAID: '未支付', PAID: '已支付', REFUND: '已退款' };
   return map[payStatus] || payStatus || '未知';
 };
 
-const getPayStatusClass = (payStatus: string): 'success' | 'danger' | 'warning' => {
+const getPayStatusClass = (payStatus: string): string => {
   if (payStatus === 'PAID') return 'success';
   if (payStatus === 'REFUND') return 'danger';
   return 'warning';
 };
 
 const getStatusDesc = (status: number, payStatus: string): string => {
-  if (payStatus === 'PAID') return '交易成功';
+  if (payStatus === 'PAID') return '交易成功,款项已到账';
   if (payStatus === 'REFUND') return '已退款,款项将退回原支付账户';
   if (status === 0) return '等待消费者支付';
   if (status === 2) return '订单已取消';
@@ -189,8 +290,61 @@ const getStatusDesc = (status: number, payStatus: string): string => {
   return '';
 };
 
-const canRefund = (o: OrderDetail): boolean => {
-  return o.payStatus === 'PAID' && o.status === 1;
+const alreadyRefunded = () => (order.value?.refundAmount || 0);
+
+const canRefund = (o: OrderDetail) => {
+  if (o.payStatus !== 'PAID' || o.status !== 1) return false;
+  const paid = o.paidAmount || o.totalAmount || 0;
+  return (o.refundAmount || 0) < paid;
+};
+
+const refundableProducts = () => {
+  if (!order.value?.products) return [];
+  return order.value.products.map((p, i) => ({ ...p, _idx: i }));
+};
+
+const refundTotalAmount = (): number => {
+  const orderData = order.value;
+  if (!orderData) return 0;
+  if (refundType.value === 'custom') {
+    const val = parseFloat(refundCustomAmount.value);
+    return isNaN(val) ? 0 : val;
+  }
+  const selected = refundProducts.value;
+  if (Object.keys(selected).length === 0) {
+    const paid = orderData.paidAmount || 0;
+    return paid - alreadyRefunded();
+  }
+  let total = 0;
+  Object.entries(selected).forEach(([idx, qty]) => {
+    const p = orderData.products?.[Number(idx)];
+    if (p && qty > 0) total += p.price * qty;
+  });
+  return total;
+};
+
+const openRefundModal = () => {
+  refundReason.value = '';
+  refundProducts.value = {};
+  refundType.value = 'full';
+  refundCustomAmount.value = '';
+  showRefundModal.value = true;
+};
+
+const toggleProduct = (idx: number) => {
+  const current = refundProducts.value;
+  if (current[idx]) { delete current[idx]; refundProducts.value = { ...current }; }
+  else {
+    const p = order.value?.products?.[idx];
+    refundProducts.value = { ...current, [idx]: p?.productNum || 1 };
+  }
+};
+
+const setProductQty = (idx: number, qty: number) => {
+  const p = order.value?.products?.[idx];
+  if (!p) return;
+  const max = p.productNum || 1;
+  refundProducts.value = { ...refundProducts.value, [idx]: Math.max(1, Math.min(qty, max)) };
 };
 
 const copyText = (text: string) => {
@@ -198,421 +352,221 @@ const copyText = (text: string) => {
   uni.setClipboardData({ data: text, success: () => showToast('已复制', 'success') });
 };
 
-const callPhone = (phone: string) => {
-  uni.makePhoneCall({ phoneNumber: phone });
-};
-
 const loadDetail = async (id?: string) => {
-  if (!id) {
-    errorMsg.value = '缺少订单参数';
-    loading.value = false;
-    return;
-  }
+  if (!id) { errorMsg.value = '缺少订单参数'; loading.value = false; return; }
   lastId = id;
   loading.value = true;
   errorMsg.value = '';
-  try {
-    order.value = await getOrderDetail(id);
-  } catch {
-    errorMsg.value = '加载订单详情失败,请检查网络后重试';
-    order.value = null;
-  } finally {
-    loading.value = false;
-  }
+  try { order.value = await getOrderDetail(id); } catch { errorMsg.value = '加载失败,请检查网络后重试'; order.value = null; }
+  finally { loading.value = false; }
 };
 
 const onRetry = () => loadDetail(lastId);
 
 const handleRefund = async () => {
   if (isRefunding.value || !order.value) return;
-  const confirmed = await showConfirm('确认对该订单进行全额退款?');
-  if (!confirmed) return;
+  if (!refundReason.value.trim()) { showToast('请输入退款原因'); return; }
+
+  const orderId = order.value.id;
+  const reason = refundReason.value.trim();
+  const extra: any = {};
+
+  if (refundType.value === 'custom' && refundCustomAmount.value) {
+    const amt = parseFloat(refundCustomAmount.value);
+    if (!isNaN(amt) && amt > 0) extra.refundAmount = amt;
+  } else if (Object.keys(refundProducts.value).length > 0) {
+    extra.products = Object.entries(refundProducts.value)
+      .filter(([_, qty]) => qty > 0)
+      .map(([idx, qty]) => {
+        const p = order.value!.products![Number(idx)];
+        return { productId: p.productId, productName: p.productName, quantity: qty, price: p.price };
+      });
+  }
 
   isRefunding.value = true;
   try {
-    await refundOrder(order.value.id, '商家申请退款');
+    await refundOrder(orderId, reason, Object.keys(extra).length > 0 ? extra : undefined);
     showToast('退款成功', 'success');
+    showRefundModal.value = false;
     setTimeout(() => loadDetail(lastId), 1200);
-  } catch {
-    showToast('退款失败,请重试');
+  } catch (e: any) {
+    showToast(e?.message || '退款失败,请重试');
   } finally {
     isRefunding.value = false;
   }
 };
 
-onLoad((options?: Record<string, string>) => {
-  loadDetail(options?.id);
-});
+onLoad((options?: Record<string, string>) => loadDetail(options?.id));
 </script>
 
 <style lang="scss" scoped>
 $font-stack: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', system-ui, sans-serif;
 
-.page {
-  height: 100vh;
-  background: $bg-color-page;
-  display: flex;
-  flex-direction: column;
-  font-family: $font-stack;
-  overflow-x: hidden;
-}
-
-.detail-scroll {
-  flex: 1;
-  height: 0;
-}
-
-.bottom-spacer {
-  height: 120rpx;
-}
-
-// ====== Loading ======
-.load-tip {
-  display: flex;
-  justify-content: center;
-  padding: 200rpx 0;
-}
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; font-family: $font-stack; overflow-x: hidden; }
+.detail-scroll { flex: 1; height: 0; padding-bottom: 160rpx; }
 
+// ====== Loading / Error ======
+.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; }
+.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; }
 }
-
-// ====== Error ======
-.error-state {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 200rpx $spacing-4;
-
-  .error-icon {
-    width: 80rpx;
-    height: 80rpx;
-    border-radius: 50%;
-    background: $error-color-bg;
-    margin-bottom: $spacing-3;
-    position: relative;
-
-    &::before {
-      content: '!';
-      position: absolute;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      font-size: 40rpx;
-      font-weight: 700;
-      color: $error-color;
-      line-height: 1;
-    }
-  }
-
-  .error-text {
-    font-size: $font-size-base;
-    color: $text-color-secondary;
-    margin-bottom: $spacing-4;
-    text-align: center;
-  }
-
-  .retry-btn {
-    padding: $spacing-2 $spacing-6;
-    background: $primary-color;
-    border-radius: $radius-full;
-
-    text { font-size: $font-size-base; color: $text-color-primary; font-weight: 500; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.error-state { display: flex; flex-direction: column; align-items: center; padding: 200rpx $spacing-4;
+  .error-icon { width: 80rpx; height: 80rpx; border-radius: 50%; background: $error-color-bg; margin-bottom: $spacing-3; position: relative;
+    &::before { content: '!'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 40rpx; font-weight: 700; color: $error-color; line-height: 1; }
   }
+  .error-text { font-size: $font-size-base; color: $text-color-secondary; margin-bottom: $spacing-4; text-align: center; }
+  .retry-btn { padding: $spacing-2 $spacing-6; background: $primary-color; border-radius: $radius-full; text { font-size: $font-size-base; color: $text-color-primary; font-weight: 500; } }
 }
 
-// ====== Status ======
-.status-section {
-  padding: $spacing-4 $spacing-4 $spacing-3;
-  background: $bg-color-card;
-  margin-bottom: $spacing-2;
-
-  .status-row {
-    display: flex;
-    align-items: center;
-    margin-bottom: 12rpx;
-
-    .status-tag {
-      padding: 4rpx 16rpx;
-      border-radius: $radius-sm;
-      font-size: 24rpx;
-      font-weight: 500;
-      margin-right: 16rpx;
-
-      &.success { background: $success-color-bg; color: $success-color; }
-      &.danger  { background: $error-color-bg; color: $error-color; }
-      &.warning { background: $warning-color-bg; color: $warning-color; }
-    }
-
-    .order-no {
-      font-size: $font-size-md;
-      font-weight: 600;
-      color: $text-color-primary;
-    }
-  }
-
-  .status-desc {
-    font-size: $font-size-sm;
-
-    &.success { color: $success-color; }
-    &.danger  { color: $error-color; }
-    &.warning { color: $warning-color; }
+// ====== Cards ======
+.card { margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; background: $bg-color-card; }
+.card-title { display: block; font-size: $font-size-md; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+
+// ====== Status card ======
+.status-card {
+  .status-left { display: flex; align-items: center; gap: 20rpx; }
+  .status-circle {
+    width: 72rpx; height: 72rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+    text { font-size: 36rpx; font-weight: 700; color: #fff; }
+    &.success { background: $success-color; }
+    &.danger  { background: $error-color; }
+    &.warning { background: $warning-color; }
   }
+  .status-text-wrap { .status-label { display: block; font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; margin-bottom: 4rpx; } .status-desc { font-size: $font-size-sm; color: $text-color-muted; } }
 }
 
 // ====== Video ======
-.video-section {
-  margin-bottom: $spacing-2;
-
-  .order-video {
-    width: 100%;
-    height: 400rpx;
-    background: $bg-color-secondary;
-  }
-}
-
-// ====== Section ======
-.section {
-  margin-bottom: $spacing-2;
-  padding: $spacing-3 $spacing-4;
-  background: $bg-color-card;
-
-  .section-title {
-    display: block;
-    font-size: $font-size-md;
-    font-weight: 600;
-    color: $text-color-primary;
-    margin-bottom: $spacing-3;
-    padding-bottom: $spacing-2;
-    border-bottom: 1rpx solid $border-color-light;
-  }
-}
+.video-section { margin-bottom: $spacing-2; .order-video { width: 100%; height: 400rpx; background: $bg-color-secondary; } }
 
 // ====== Product list ======
 .product-item {
-  display: flex;
-  align-items: flex-start;
-  padding: $spacing-2 0;
-  border-bottom: 1rpx solid $border-color-light;
-
+  display: flex; align-items: center; padding: $spacing-2 0; border-bottom: 1rpx solid $border-color-light;
   &:last-child { border-bottom: none; }
-
-  .product-img {
-    width: 100rpx;
-    height: 100rpx;
-    border-radius: $radius-sm;
-    background: $bg-color-page;
-    flex-shrink: 0;
-    margin-right: 16rpx;
+  .product-img { width: 120rpx; height: 120rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; margin-right: 16rpx; }
+  .product-main { flex: 1;
+    .product-name { display: block; font-size: $font-size-base; color: $text-color-primary; margin-bottom: 6rpx; }
+    .product-price { font-size: $font-size-sm; color: $text-color-muted; }
   }
-
-  .product-main {
-    flex: 1;
-
-    .product-name {
-      display: block;
-      font-size: $font-size-base;
-      color: $text-color-primary;
-      margin-bottom: 4rpx;
-    }
-
-    .product-meta {
-      font-size: $font-size-sm;
-      color: $text-color-muted;
-    }
-  }
-
-  .product-price {
-    text-align: right;
-    flex-shrink: 0;
-
-    .price-unit {
-      display: block;
-      font-size: $font-size-sm;
-      color: $text-color-muted;
-      margin-bottom: 4rpx;
-    }
-
-    .price-total {
-      font-size: $font-size-base;
-      font-weight: 600;
-      color: $text-color-primary;
-    }
+  .product-right { text-align: right; flex-shrink: 0;
+    .product-qty { display: block; font-size: $font-size-sm; color: $text-color-muted; margin-bottom: 6rpx; }
+    .product-subtotal { font-size: $font-size-base; font-weight: 600; color: $text-color-primary; }
   }
 }
-
-.product-empty {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: $spacing-5 0;
-
-  .empty-doc-icon {
-    width: 48rpx;
-    height: 56rpx;
-    border: 2.5rpx solid $text-color-placeholder;
-    border-radius: 4rpx;
-    margin-bottom: $spacing-2;
-    position: relative;
-
-    &::after {
-      content: '';
-      position: absolute;
-      top: 16rpx;
-      left: 10rpx;
-      right: 10rpx;
-      height: 2.5rpx;
-      background: $text-color-placeholder;
-      border-radius: 1rpx;
-      box-shadow: 0 10rpx 0 $text-color-placeholder, 0 20rpx 0 $text-color-placeholder;
-    }
+.product-empty { display: flex; flex-direction: column; align-items: center; padding: $spacing-5 0;
+  .empty-doc-icon { width: 48rpx; height: 56rpx; border: 2.5rpx solid $text-color-placeholder; border-radius: 4rpx; margin-bottom: $spacing-2; position: relative;
+    &::after { content: ''; position: absolute; top: 16rpx; left:10rpx; right:10rpx; height:2.5rpx; background:$text-color-placeholder; border-radius:1rpx; box-shadow:0 10rpx 0 $text-color-placeholder, 0 20rpx 0 $text-color-placeholder; }
   }
-
   text { font-size: $font-size-sm; color: $text-color-placeholder; }
 }
 
 // ====== Amount ======
-.amount-section {
-  margin-top: $spacing-2;
-  padding-top: $spacing-2;
-  border-top: 1rpx solid $border-color-light;
-}
-
-.amount-row {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 8rpx 0;
-
-  .amount-label {
-    font-size: $font-size-base;
-    color: $text-color-secondary;
+.amount-section { margin-top: $spacing-2; padding-top: $spacing-2; border-top: 1rpx solid $border-color-light; }
+.amount-row { display: flex; align-items: center; justify-content: space-between; padding: 8rpx 0;
+  .amount-label { font-size: $font-size-base; color: $text-color-secondary; }
+  .amount-value { font-size: $font-size-base; font-weight: 600; color: $text-color-primary; }
+  &.discount { .amount-label { color: $success-color; } .amount-value { color: $success-color; font-weight: 500; } }
+  &.paid { padding-top: 16rpx;
+    .amount-value { font-size: $font-size-lg; font-weight: 700; }
   }
+}
 
-  .amount-value {
-    font-size: $font-size-base;
-    font-weight: 600;
-    color: $text-color-primary;
-
-    &.discount { color: $error-color; font-weight: 500; }
+// ====== Refund timeline ======
+.refund-item { display: flex; gap: 16rpx; padding: 8rpx 0;
+  .refund-timeline { display: flex; flex-direction: column; align-items: center; flex-shrink: 0;
+    .timeline-dot { width: 16rpx; height: 16rpx; border-radius: 50%; flex-shrink: 0;
+      &.success { background: $success-color; } &.rejected { background: $error-color; } &.pending { background: $warning-color; }
+    }
+    .timeline-line { width: 2rpx; flex: 1; min-height: 24rpx; background: $border-color-light; }
   }
-
-  &.total {
-    padding-top: 16rpx;
-
-    .amount-value { font-size: $font-size-lg; font-weight: 700; }
+  .refund-content { flex: 1; padding-bottom: $spacing-2;
+    .refund-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4rpx;
+      .refund-status { font-size: $font-size-base; font-weight: 500;
+        &.success { color: $success-color; } &.rejected { color: $error-color; } &.pending { color: $warning-color; }
+      }
+      .refund-amount { font-size: $font-size-base; font-weight: 600; color: $text-color-primary; }
+    }
+    .refund-time { font-size: $font-size-sm; color: $text-color-muted; display: block; }
+    .refund-reason { font-size: $font-size-sm; color: $text-color-secondary; margin-top: 4rpx; }
   }
 }
 
 // ====== Info ======
-.info-row {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: $spacing-3 0;
-  border-bottom: 1rpx solid $border-color-light;
-
+.info-list .info-row { display: flex; align-items: center; justify-content: space-between; padding: $spacing-3 0; border-bottom: 1rpx solid $border-color-light;
   &:last-child { border-bottom: none; }
-
-  .info-label {
-    font-size: $font-size-base;
-    color: $text-color-muted;
-    flex-shrink: 0;
-  }
-
-  .info-value {
-    font-size: $font-size-base;
-    color: $text-color-primary;
-    text-align: right;
-
-    &.mono { letter-spacing: 0.02em; }
-  }
-
-  .info-value-row {
-    display: flex;
-    align-items: center;
-    gap: 16rpx;
-  }
+  .info-label { font-size: $font-size-base; color: $text-color-muted; flex-shrink: 0; }
+  .info-value { font-size: $font-size-base; color: $text-color-primary; text-align: right; &.mono { letter-spacing: 0.02em; } }
+  .info-value-row { display: flex; align-items: center; gap: 16rpx; }
 }
-
-// ====== Buttons ======
-.copy-btn, .action-btn {
-  padding: 6rpx 20rpx;
-  border-radius: $radius-sm;
-  flex-shrink: 0;
-  display: flex;
-  align-items: center;
-
-  text { font-size: 22rpx; line-height: 1; }
+.copy-btn { padding: 6rpx 20rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; display: flex; align-items: center;
+  text { font-size: 22rpx; color: $text-color-secondary; line-height: 1; }
   &:active { opacity: 0.7; }
 }
 
-.copy-btn {
-  background: $bg-color-page;
-
-  text { color: $text-color-secondary; }
+// ====== Bottom bar ======
+.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; }
+.refund-btn { display: flex; align-items: center; justify-content: center; gap: $spacing-2; width: 100%; padding: 26rpx 0; border-radius: $radius-xl; background: $primary-color; transition: opacity 0.15s ease;
+  &:active { opacity: 0.8; } &.disabled { opacity: 0.6; pointer-events: none; }
+  .refund-btn-text { font-size: $font-size-md; font-weight: 600; color: $text-color-primary; }
 }
+.loading-spinner { width: 32rpx; height: 32rpx; border: 3rpx solid rgba(30, 41, 59, 0.15); border-top-color: $text-color-primary; border-radius: 50%; animation: spin 0.8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
 
-.call-btn {
-  background: $primary-color-bg;
+// ====== Refund modal ======
+.refund-modal { position: fixed; inset: 0; z-index: 200; display: flex; align-items: flex-end; background: rgba(30, 30, 30, 0.5); }
+.refund-modal-content { width: 100%; max-height: 85vh; background: $bg-color-card; border-radius: $radius-xl $radius-xl 0 0; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; }
+.modal-header { display: flex; align-items: center; justify-content: space-between; padding: $spacing-3 $spacing-4; border-bottom: 1rpx solid $border-color-light;
+  .modal-title { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+  .modal-close { width: 44rpx; height: 44rpx; position: relative;
+    &::before,&::after { content: ''; position: absolute; top: 50%; left: 50%; width: 20rpx; height: 2rpx; background: $text-color-muted; border-radius: 1rpx; }
+    &::before { transform: translate(-50%,-50%) rotate(45deg); } &::after { transform: translate(-50%,-50%) rotate(-45deg); }
+  }
+}
+.modal-body { padding: $spacing-3 $spacing-4; flex: 1; max-height: 55vh; }
 
-  text { color: $primary-color-dark; font-weight: 500; }
+.refund-type-row { display: flex; gap: 16rpx; margin-bottom: $spacing-3; }
+.refund-type-chip { padding: 12rpx 32rpx; border-radius: $radius-full; background: $bg-color-page; border: 1rpx solid transparent;
+  text { font-size: $font-size-base; color: $text-color-secondary; }
+  &.active { background: $primary-color-bg; border-color: $primary-color; text { color: $primary-color-dark; font-weight: 500; } }
 }
 
-// ====== Bottom ======
-.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;
+.refunded-tip { padding: $spacing-2; margin-bottom: $spacing-3; background: $warning-color-bg; border-radius: $radius-sm;
+  text { font-size: $font-size-sm; color: $warning-color; }
 }
 
-.refund-btn {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: $spacing-2;
-  width: 100%;
-  padding: 26rpx 0;
-  border-radius: $radius-xl;
-  background: $primary-color;
-  transition: opacity 0.15s ease;
-
-  &:active { opacity: 0.8; }
-
-  &.disabled { opacity: 0.6; pointer-events: none; }
-
-  .refund-btn-text {
-    font-size: $font-size-md;
-    font-weight: 600;
-    color: $text-color-primary;
+.form-group { margin-bottom: $spacing-3; }
+.form-label { display: block; font-size: $font-size-base; color: $text-color-secondary; margin-bottom: $spacing-1; }
+.form-input { width: 100%; height: 80rpx; padding: 0 $spacing-3; background: $bg-color-page; border-radius: $radius-sm; font-size: $font-size-base; color: $text-color-primary; box-sizing: border-box; }
+.form-textarea { width: 100%; min-height: 120rpx; padding: $spacing-2 $spacing-3; background: $bg-color-page; border-radius: $radius-sm; font-size: $font-size-base; color: $text-color-primary; box-sizing: border-box; }
+
+.refund-product-item { display: flex; align-items: center; padding: 12rpx 0; width: 100%; box-sizing: border-box;
+  .rp-left { display: flex; align-items: center; gap: 12rpx; flex: 1; min-width: 0; overflow: hidden; }
+  .rp-checkbox { width: 36rpx; height: 36rpx; border: 2rpx solid $border-color; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+    text { font-size: 20rpx; color: $primary-color; }
+    &.checked { background: $primary-color; border-color: $primary-color; text { color: #fff; } }
+  }
+  .rp-img { width: 64rpx; height: 64rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; }
+  .rp-info { flex: 1; min-width: 0; overflow: hidden;
+    .rp-name { display: block; font-size: 26rpx; color: $text-color-primary; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .rp-price { font-size: 22rpx; color: $text-color-muted; }
+  }
+  .rp-qty { display: flex; align-items: center; gap: 8rpx; flex-shrink: 0; margin-left: 8rpx;
+    .qty-btn { width: 44rpx; height: 44rpx; border: 1rpx solid $border-color; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; text { font-size: 28rpx; color: $text-color-secondary; line-height: 1; } }
+    .qty-value { font-size: $font-size-base; color: $text-color-primary; min-width: 32rpx; text-align: center; }
   }
 }
 
-.loading-spinner {
-  width: 32rpx;
-  height: 32rpx;
-  border: 3rpx solid rgba(30, 41, 59, 0.15);
-  border-top-color: $text-color-primary;
-  border-radius: 50%;
-  animation: spin 0.8s linear infinite;
+.refund-total { display: flex; align-items: center; justify-content: space-between; padding: $spacing-3 0; margin-top: $spacing-2; border-top: 1rpx solid $border-color-light;
+  .total-label { font-size: $font-size-base; color: $text-color-secondary; }
+  .total-value { font-size: $font-size-xl; font-weight: 700; color: $text-color-primary; }
 }
 
-@keyframes spin { to { transform: rotate(360deg); } }
+.modal-footer { display: flex; gap: $spacing-2; padding: 0 $spacing-4 $spacing-4;
+  button { flex: 1; height: 80rpx; line-height: 80rpx; padding: 0; border: none; border-radius: $radius-base; font-size: $font-size-base; font-weight: 500; &::after { border: none; } }
+  .btn-cancel { background: $bg-color-page; color: $text-color-secondary; }
+  .btn-confirm { background: $primary-color; color: $text-color-primary; display: flex; align-items: center; justify-content: center; gap: $spacing-2;
+    &.disabled { opacity: 0.6; pointer-events: none; }
+  }
+}
 </style>

+ 201 - 508
haha-admin-mp/src/pages/replenish/index.vue

@@ -2,170 +2,166 @@
   <view class="page">
     <NavBar title="补货首页" />
 
-    <!-- 补货员信息卡片 -->
-    <view class="user-section">
-      <view class="user-card" @click="navigateToMy">
-        <view class="user-header">
-          <view class="avatar">
-            <text class="avatar-text">{{ replenisherInfo.name?.charAt(0) || '补' }}</text>
-          </view>
-          <view class="user-info">
-            <text class="user-name">{{ replenisherInfo.name || '未知' }}</text>
-            <text class="user-role">补货员</text>
-            <text class="user-employee" v-if="replenisherInfo.employeeId">工号: {{ replenisherInfo.employeeId }}</text>
-          </view>
+    <!-- 补货员信息 -->
+    <view class="user-card">
+      <view class="user-header">
+        <view class="avatar">
+          <text class="avatar-text">{{ (replenisherInfo.name || '补').charAt(0) }}</text>
         </view>
-        <view class="user-stats">
-          <view class="stat-item">
-            <text class="stat-value">{{ deviceList.length }}</text>
-            <text class="stat-label">绑定设备</text>
-          </view>
-          <view class="stat-divider"></view>
-          <view class="stat-item">
-            <text class="stat-value">{{ totalTaskCount }}</text>
-            <text class="stat-label">累计任务</text>
-          </view>
-          <view class="stat-divider"></view>
-          <view class="stat-item">
-            <text class="stat-value">{{ lowStockTotal }}</text>
-            <text class="stat-label">待补货</text>
-          </view>
+        <view class="user-info">
+          <text class="user-name">{{ replenisherInfo.name || '补货员' }}</text>
+          <text class="user-role">补货员</text>
+        </view>
+        <view class="user-meta" v-if="replenisherInfo.employeeId">
+          <text class="meta-label">工号</text>
+          <text class="meta-value">{{ replenisherInfo.employeeId }}</text>
+        </view>
+      </view>
+      <view class="user-stats">
+        <view class="stat-item">
+          <text class="stat-value">{{ deviceList.length }}</text>
+          <text class="stat-label">绑定设备</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">{{ pendingOrderTotal }}</text>
+          <text class="stat-label">待处理补货单</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">{{ lowStockTotal }}</text>
+          <text class="stat-label">待补货商品</text>
         </view>
       </view>
     </view>
 
     <!-- 快捷入口 -->
-    <view class="quick-actions">
-      <view class="action-btn" @click="goToOrders">
-        <text class="action-icon">📋</text>
-        <text class="action-label">补货单</text>
+    <view class="quick-row">
+      <view class="quick-btn orders" @click="goToOrders">
+        <view class="quick-icon doc-icon"></view>
+        <text class="quick-label">补货单</text>
+      </view>
+      <view class="quick-btn scan" @click="handleScanDevice">
+        <view class="quick-icon scan-icon-css"></view>
+        <text class="quick-label">扫码开柜</text>
       </view>
     </view>
 
-    <!-- 加载中 -->
-    <view class="loading-container" v-if="loading">
-      <view class="loading-spinner"></view>
-      <text class="loading-text">加载中...</text>
+    <!-- 筛选标签 -->
+    <view class="filter-tabs">
+      <view class="filter-tab" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">
+        <text>全部</text>
+        <text class="tab-count">{{ deviceList.length }}</text>
+      </view>
+      <view class="filter-tab" :class="{ active: activeFilter === 'pending' }" @click="activeFilter = 'pending'">
+        <text>有补货单</text>
+        <text class="tab-count">{{ pendingDeviceCount }}</text>
+      </view>
+      <view class="filter-tab" :class="{ active: activeFilter === 'submitted' }" @click="activeFilter = 'submitted'">
+        <text>待完成</text>
+        <text class="tab-count">{{ submittedDeviceCount }}</text>
+      </view>
+      <view class="filter-tab" :class="{ active: activeFilter === 'today' }" @click="activeFilter = 'today'">
+        <text>今日已补</text>
+        <text class="tab-count">{{ todayDeviceCount }}</text>
+      </view>
+    </view>
+
+    <!-- 加载 -->
+    <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>
 
     <!-- 设备列表 -->
     <view class="device-section" v-else>
       <view class="section-header">
         <text class="section-title">我的设备</text>
-        <view class="section-actions">
-          <view class="scan-btn" @click="handleScanDevice">
-            <text class="scan-icon">📷</text>
-            <text class="scan-text">扫码开柜</text>
-          </view>
-          <text class="section-count" v-if="deviceList.length">共 {{ deviceList.length }} 台</text>
-        </view>
+        <text class="section-count" v-if="filteredDevices.length">共 {{ filteredDevices.length }} 台</text>
       </view>
 
-      <!-- 空状态 -->
-      <view class="empty-state" v-if="deviceList.length === 0">
-        <view class="empty-icon">
-          <view class="icon-device"></view>
-        </view>
-        <text class="empty-text">暂无绑定的设备</text>
-        <text class="empty-hint">请联系管理员绑定设备后开始补货</text>
+      <view class="empty-state" v-if="filteredDevices.length === 0">
+        <view class="empty-icon"><view class="icon-device-empty"></view></view>
+        <text class="empty-text">{{ activeFilter === 'all' ? '暂无绑定的设备' : '没有匹配的设备' }}</text>
+        <text class="empty-hint">{{ activeFilter === 'all' ? '请联系管理员绑定设备后开始补货' : '当前筛选条件下没有设备' }}</text>
       </view>
 
-      <!-- 设备卡片 -->
-      <view
-        class="device-card"
-        v-for="(device, index) in deviceList"
-        :key="index"
-        @click="goToOperation(device)"
-      >
-        <view class="device-header">
+      <view class="device-card" v-for="(device, index) in filteredDevices" :key="index" @click="goToOperation(device)">
+        <view class="card-head">
           <view class="device-info">
             <text class="device-name">{{ device.name || device.deviceId }}</text>
-            <text class="device-id">SN: {{ device.deviceId }}</text>
+            <text class="device-sn">{{ device.deviceId }}</text>
           </view>
-          <view :class="['device-status', device.status === 1 ? 'online' : 'offline']">
-            {{ device.statusLabel }}
+          <view class="status-tag" :class="device.status === 1 ? 'online' : 'offline'">
+            <view class="status-dot"></view>
+            <text>{{ device.statusLabel || (device.status === 1 ? '在线' : '离线') }}</text>
           </view>
         </view>
 
-        <view class="device-address" v-if="device.address">
-          <text class="address-text">{{ device.address }}</text>
+        <view class="device-addr" v-if="device.address">
+          <text>{{ device.address }}</text>
         </view>
 
-        <!-- 库存概览 -->
-        <view class="stock-overview">
-          <view class="stock-item">
-            <text class="stock-label">商品种类</text>
-            <text class="stock-value">{{ device.productCount || 0 }}</text>
+        <view class="stock-row">
+          <view class="stock-chip" v-if="device.pendingOrderCount > 0">
+            <text class="chip-label">待补货单</text>
+            <text class="chip-value accent">{{ device.pendingOrderCount }}</text>
           </view>
-          <view class="stock-item">
-            <text class="stock-label">总库存</text>
-            <text class="stock-value">{{ device.totalStock || 0 }}</text>
+          <view class="stock-chip">
+            <text class="chip-label">种类</text>
+            <text class="chip-value">{{ device.productCount || 0 }}</text>
           </view>
-          <view class="stock-item warn" v-if="device.lowStockCount > 0">
-            <text class="stock-label">低库存</text>
-            <text class="stock-value">{{ device.lowStockCount }}</text>
+          <view class="stock-chip">
+            <text class="chip-label">总库存</text>
+            <text class="chip-value">{{ device.totalStock || 0 }}</text>
           </view>
-          <view class="stock-item danger" v-if="device.zeroStockCount > 0">
-            <text class="stock-label">缺货</text>
-            <text class="stock-value">{{ device.zeroStockCount }}</text>
+          <view class="stock-chip warn" v-if="device.lowStockCount > 0">
+            <text class="chip-label">低库存</text>
+            <text class="chip-value">{{ device.lowStockCount }}</text>
           </view>
-        </view>
-
-        <!-- 库存条形 -->
-        <view class="stock-bar">
-          <view class="bar-bg">
-            <view
-              class="bar-fill"
-              :style="{ width: getStockRatio(device) + '%' }"
-            ></view>
+          <view class="stock-chip danger" v-if="device.zeroStockCount > 0">
+            <text class="chip-label">缺货</text>
+            <text class="chip-value">{{ device.zeroStockCount }}</text>
           </view>
-          <text class="bar-text">{{ getStockRatio(device) }}%</text>
         </view>
 
-        <view class="device-action">
-          <text class="action-text">查看商品库存 ></text>
+        <view class="card-foot">
+          <text class="foot-text">查看商品库存</text>
+          <text class="foot-arrow">></text>
         </view>
       </view>
     </view>
-
-    <CustomTabBar />
   </view>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue';
+import { ref, computed } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
 import NavBar from '@/components/NavBar.vue';
-import CustomTabBar from '@/components/CustomTabBar.vue';
-import { getReplenisherInfo, getHomePath } from '@/utils/auth';
+import { getReplenisherInfo } from '@/utils/auth';
 import { getMyInfo, getDeviceList } from '@/api/replenish';
 
 const replenisherInfo = ref<any>({});
 const deviceList = ref<any[]>([]);
 const loading = ref(true);
-const totalTaskCount = ref(0);
+const activeFilter = ref('all');
+const pendingOrderTotal = ref(0);
 const lowStockTotal = ref(0);
 
-const navigateToMy = () => {
-  uni.switchTab({ url: '/pages/my/my' });
-};
+const filteredDevices = computed(() => {
+  switch (activeFilter.value) {
+    case 'pending': return deviceList.value.filter(d => (d.pendingOrderCount || 0) > 0);
+    case 'submitted': return deviceList.value.filter(d => (d.submittedOrderCount || 0) > 0);
+    case 'today': return deviceList.value.filter(d => d.todayReplenished);
+    default: return deviceList.value;
+  }
+});
 
-const goToOrders = () => {
-  uni.navigateTo({ url: '/pages/replenish/orders' });
-};
+const pendingDeviceCount = computed(() => deviceList.value.filter(d => (d.pendingOrderCount || 0) > 0).length);
+const submittedDeviceCount = computed(() => deviceList.value.filter(d => (d.submittedOrderCount || 0) > 0).length);
+const todayDeviceCount = computed(() => deviceList.value.filter(d => d.todayReplenished).length);
 
-const goToOperation = (device: any) => {
-  uni.navigateTo({
-    url: `/pages/replenish/operation?deviceId=${device.deviceId}`
-  });
-};
+const goToOrders = () => uni.navigateTo({ url: '/pages/replenish/orders' });
 
-const getStockRatio = (device: any): number => {
-  if (!device.totalStock || !device.productCount) return 0;
-  // 简单计算:假设每个商品标准库存为20,计算整体库存率
-  const standardTotal = device.productCount * 20;
-  if (standardTotal === 0) return 0;
-  const ratio = Math.round((device.totalStock / standardTotal) * 100);
-  return Math.min(ratio, 100);
+const goToOperation = (device: any) => {
+  uni.navigateTo({ url: `/pages/replenish/operation?deviceId=${device.deviceId}` });
 };
 
 const handleScanDevice = () => {
@@ -173,55 +169,32 @@ const handleScanDevice = () => {
     onlyFromCamera: false,
     scanType: ['qrCode'],
     success: (res: any) => {
-      const result = res.result || '';
-      // 机身二维码格式: https://dev-haha.kuaiyuman.cn/{deviceId}
-      const deviceIdMatch = result.match(/\/([^/]+)$/);
-      if (!deviceIdMatch) {
-        uni.showToast({ title: '无效的设备二维码', icon: 'none' });
-        return;
-      }
-      const scannedDeviceId = deviceIdMatch[1];
-      // 校验设备是否在补货员绑定列表中
-      const found = deviceList.value.find((d: any) => d.deviceId === scannedDeviceId);
-      if (!found) {
+      const match = (res.result || '').match(/\/([^/]+)$/);
+      if (!match) { uni.showToast({ title: '无效的设备二维码', icon: 'none' }); return; }
+      const id = match[1];
+      if (!deviceList.value.find((d: any) => d.deviceId === id)) {
         uni.showToast({ title: '该设备未绑定,请联系管理员', icon: 'none' });
         return;
       }
-      uni.navigateTo({
-        url: `/pages/replenish/operation?deviceId=${scannedDeviceId}`
-      });
-    },
-    fail: () => {
-      // 用户取消扫码,不做提示
+      uni.navigateTo({ url: `/pages/replenish/operation?deviceId=${id}` });
     }
   });
 };
 
-onMounted(async () => {
+onShow(async () => {
+  loading.value = true;
   try {
-    // 获取补货员信息
     const info = getReplenisherInfo();
-    if (info && info.id) {
-      replenisherInfo.value = info;
-    }
-
-    // 获取补货员详情
+    if (info?.id) replenisherInfo.value = info;
     const detail = await getMyInfo();
-    if (detail) {
-      replenisherInfo.value = detail;
-      totalTaskCount.value = detail.totalTasks || 0;
-    }
+    if (detail) replenisherInfo.value = detail;
 
-    // 获取设备列表
     const devices = await getDeviceList();
     deviceList.value = devices || [];
-
-    // 统计待补货数量
-    lowStockTotal.value = (devices || []).reduce((sum: number, d: any) => {
-      return sum + (d.lowStockCount || 0) + (d.zeroStockCount || 0);
-    }, 0);
-  } catch (error: any) {
-    uni.showToast({ title: error.message || '加载失败', icon: 'none' });
+    lowStockTotal.value = (devices || []).reduce((s: number, d: any) => s + (d.lowStockCount || 0) + (d.zeroStockCount || 0), 0);
+    pendingOrderTotal.value = (devices || []).reduce((s: number, d: any) => s + (d.pendingOrderCount || 0), 0);
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '加载失败', icon: 'none' });
   } finally {
     loading.value = false;
   }
@@ -229,386 +202,106 @@ onMounted(async () => {
 </script>
 
 <style lang="scss" scoped>
-.page {
-  min-height: 100vh;
-  background: $bg-color-page;
-  padding-bottom: 200rpx;
-}
-
-/* 用户区域 */
-.user-section {
-  padding: 16rpx 24rpx;
-}
-
-.user-card {
-  background: $bg-color-card;
-  border: 1rpx solid $border-color;
-  border-radius: 20rpx;
-  padding: 28rpx;
-
-  .user-header {
-    display: flex;
-    align-items: center;
-    margin-bottom: 20rpx;
-
-    .avatar {
-      width: 80rpx;
-      height: 80rpx;
-      background: $success-color-bg;
-      border-radius: 20rpx;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      margin-right: 20rpx;
-      border: 2rpx solid $primary-color;
-
-      .avatar-text {
-        font-size: 36rpx;
-        font-weight: 700;
-        color: $primary-color;
-      }
-    }
-
-    .user-info {
-      flex: 1;
+.page { min-height: 100vh; background: $bg-color-page; padding-bottom: 48rpx; }
 
-      .user-name {
-        display: block;
-        font-size: 32rpx;
-        font-weight: 700;
-        color: $text-color-primary;
-      }
-
-      .user-role {
-        font-size: 24rpx;
-        color: $primary-color;
-        font-weight: 500;
-      }
-
-      .user-employee {
-        font-size: 22rpx;
-        color: $text-color-muted;
-      }
-    }
+// ====== User card ======
+.user-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); }
+.user-header { display: flex; align-items: center; margin-bottom: $spacing-3;
+  .avatar { width: 72rpx; height: 72rpx; background: $primary-color-bg; border-radius: $radius-lg; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0;
+    .avatar-text { font-size: 32rpx; font-weight: 700; color: $primary-color-dark; }
   }
-
-  .user-stats {
-    display: flex;
-    background: $bg-color-page;
-    border-radius: 12rpx;
-    padding: 16rpx 0;
-
-    .stat-item {
-      flex: 1;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-
-      .stat-value {
-        font-size: 30rpx;
-        font-weight: 700;
-        color: $text-color-primary;
-      }
-
-      .stat-label {
-        font-size: 22rpx;
-        color: $text-color-tertiary;
-      }
-    }
-
-    .stat-divider {
-      width: 1rpx;
-      background: $border-color;
-    }
+  .user-info { flex: 1;
+    .user-name { display: block; font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+    .user-role { font-size: $font-size-sm; color: $primary-color; font-weight: 500; }
+  }
+  .user-meta { text-align: right;
+    .meta-label { display: block; font-size: 20rpx; color: $text-color-muted; }
+    .meta-value { font-size: 22rpx; color: $text-color-secondary; }
   }
 }
-
-/* 快捷入口 */
-.quick-actions {
-  display: flex;
-  padding: 0 24rpx 16rpx;
-  gap: 16rpx;
-
-  .action-btn {
-    display: flex;
-    align-items: center;
-    gap: 8rpx;
-    background: $bg-color-card;
-    border: 1rpx solid $border-color;
-    border-radius: 12rpx;
-    padding: 16rpx 28rpx;
-
-    .action-icon {
-      font-size: 28rpx;
-    }
-
-    .action-label {
-      font-size: 26rpx;
-      font-weight: 500;
-      color: $text-color-primary;
-    }
-
-    &:active {
-      background: $bg-color-secondary;
-    }
+.user-stats { display: flex; background: $bg-color-page; border-radius: $radius-sm; padding: 16rpx 0;
+  .stat-item { flex: 1; display: flex; flex-direction: column; align-items: center;
+    .stat-value { font-size: 30rpx; font-weight: 700; color: $text-color-primary; }
+    .stat-label { font-size: 22rpx; color: $text-color-muted; margin-top: 4rpx; }
   }
 }
 
-/* 加载 */
-.loading-container {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 80rpx 0;
-
-  .loading-text {
-    margin-top: 16rpx;
-    font-size: 26rpx;
-    color: $text-color-muted;
+// ====== Filter tabs ======
+.filter-tabs { display: flex; gap: 8rpx; margin: 0 24rpx 16rpx; }
+.filter-tab { display: flex; align-items: center; gap: 6rpx; padding: 12rpx 20rpx; background: $bg-color-card; border-radius: $radius-full; font-size: 24rpx; color: $text-color-secondary; border: 1rpx solid transparent;
+  .tab-count { font-size: 20rpx; color: $text-color-muted; }
+  &.active { background: $primary-color-bg; border-color: $primary-color; color: $primary-color-dark; font-weight: 500;
+    .tab-count { color: $primary-color; }
   }
 }
 
-/* 设备区域 */
-.device-section {
-  padding: 0 24rpx;
+// ====== Quick actions ======
+.quick-row { display: flex; gap: 16rpx; margin: 0 24rpx 16rpx; }
+.quick-btn { display: flex; align-items: center; gap: 12rpx; flex: 1; padding: 20rpx 24rpx; border-radius: $radius-base; background: $bg-color-card; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  .quick-label { font-size: $font-size-base; font-weight: 500; color: $text-color-primary; }
+  &:active { opacity: 0.7; }
 }
-
-.section-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 24rpx 0 16rpx;
-
-  .section-title {
-    font-size: 30rpx;
-    font-weight: 700;
-    color: $text-color-primary;
-  }
-
-  .section-actions {
-    display: flex;
-    align-items: center;
-    gap: 16rpx;
-
-    .scan-btn {
-      display: flex;
-      align-items: center;
-      gap: 6rpx;
-      padding: 10rpx 20rpx;
-      background: $primary-color;
-      border-radius: 10rpx;
-
-      .scan-icon {
-        font-size: 24rpx;
-      }
-
-      .scan-text {
-        font-size: 24rpx;
-        font-weight: 600;
-        color: #fff;
-      }
-
-      &:active {
-        opacity: 0.85;
-      }
-    }
-  }
-
-  .section-count {
-    font-size: 24rpx;
-    color: $text-color-muted;
-  }
+.quick-icon { width: 40rpx; height: 40rpx; border-radius: $radius-sm; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
+.doc-icon { position: relative;
+  &::before { content: ''; position: absolute; inset: 4rpx; border: 2.5rpx solid $primary-color; border-radius: 4rpx; }
+  &::after { content: ''; position: absolute; top: 16rpx; left: 12rpx; right: 12rpx; height: 2.5rpx; background: $primary-color-dark; border-radius: 1rpx; box-shadow: 0 8rpx 0 $primary-color-dark; }
 }
-
-/* 空状态 */
-.empty-state {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 100rpx 0;
-
-  .empty-icon {
-    width: 120rpx;
-    height: 120rpx;
-    background: $bg-color-secondary;
-    border-radius: 24rpx;
-    margin-bottom: 24rpx;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-
-    .icon-device {
-      width: 40rpx;
-      height: 32rpx;
-      border: 4rpx solid $text-color-muted;
-      border-radius: 6rpx;
-      position: relative;
-
-      &::before {
-        content: '';
-        position: absolute;
-        bottom: -8rpx;
-        left: 50%;
-        transform: translateX(-50%);
-        width: 20rpx;
-        height: 8rpx;
-        background: $text-color-muted;
-        border-radius: 0 0 6rpx 6rpx;
-      }
-    }
-  }
-
-  .empty-text {
-    font-size: 28rpx;
-    color: $text-color-tertiary;
-    margin-bottom: 8rpx;
-  }
-
-  .empty-hint {
-    font-size: 24rpx;
-    color: $text-color-muted;
-  }
+.scan-icon-css { background: $primary-color; position: relative;
+  &::before, &::after { content: ''; position: absolute; width: 12rpx; height: 12rpx; border: 3rpx solid #fff; border-radius: 3rpx; }
+  &::before { top: 8rpx; left: 8rpx; }
+  &::after { bottom: 8rpx; right: 8rpx; }
 }
 
-/* 设备卡片 */
-.device-card {
-  background: $bg-color-card;
-  border: 1rpx solid $border-color;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  margin-bottom: 16rpx;
-
-  &:active {
-    background: $bg-color-secondary;
-  }
-
-  .device-header {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    margin-bottom: 12rpx;
-
-    .device-info {
-      flex: 1;
-
-      .device-name {
-        display: block;
-        font-size: 30rpx;
-        font-weight: 600;
-        color: $text-color-primary;
-      }
-
-      .device-id {
-        font-size: 22rpx;
-        color: $text-color-muted;
-      }
-    }
-
-    .device-status {
-      padding: 6rpx 16rpx;
-      border-radius: 8rpx;
-      font-size: 22rpx;
-      font-weight: 500;
-
-      &.online {
-        background: $success-color-bg;
-        color: $primary-color;
-      }
-
-      &.offline {
-        background: $bg-color-secondary;
-        color: $text-color-muted;
-      }
-    }
-  }
+// ====== Loading ======
+.load-tip { display: flex; justify-content: center; padding: 100rpx 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; } }
 
-  .device-address {
-    margin-bottom: 16rpx;
+// ====== Device ======
+.device-section { padding: 0 24rpx; }
+.section-header { display: flex; align-items: center; justify-content: space-between; padding: $spacing-2 0;
+  .section-title { font-size: $font-size-md; font-weight: 600; color: $text-color-primary; }
+  .section-count { font-size: $font-size-sm; color: $text-color-muted; }
+}
 
-    .address-text {
-      font-size: 24rpx;
-      color: $text-color-tertiary;
-    }
+.empty-state { display: flex; flex-direction: column; align-items: center; padding: 120rpx 0;
+  .empty-icon { width: 120rpx; height: 120rpx; background: $bg-color-secondary; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; margin-bottom: $spacing-3; }
+  .icon-device-empty { width: 44rpx; height: 36rpx; border: 3rpx solid $text-color-placeholder; border-radius: 6rpx; position: relative;
+    &::after { content: ''; position: absolute; bottom: -8rpx; left: 50%; transform: translateX(-50%); width: 22rpx; height: 6rpx; background: $text-color-placeholder; border-radius: 0 0 4rpx 4rpx; }
   }
+  .empty-text { font-size: $font-size-base; color: $text-color-secondary; margin-bottom: $spacing-1; }
+  .empty-hint { font-size: $font-size-sm; color: $text-color-muted; }
+}
 
-  .stock-overview {
-    display: flex;
-    gap: 16rpx;
-    margin-bottom: 12rpx;
-
-    .stock-item {
-      flex: 1;
-      background: $bg-color-page;
-      border-radius: 10rpx;
-      padding: 12rpx;
-      text-align: center;
-
-      .stock-label {
-        display: block;
-        font-size: 20rpx;
-        color: $text-color-muted;
-        margin-bottom: 4rpx;
-      }
-
-      .stock-value {
-        display: block;
-        font-size: 28rpx;
-        font-weight: 700;
-        color: $text-color-primary;
-      }
-
-      &.warn {
-        background: $warning-color-bg;
-
-        .stock-value { color: $warning-color; }
-      }
-
-      &.danger {
-        background: $error-color-bg;
-
-        .stock-value { color: $error-color; }
-      }
-    }
+.device-card { margin-bottom: 16rpx; padding: $spacing-3; background: $bg-color-card; border-radius: $radius-md; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  &:active { opacity: 0.7; }
+}
+.card-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 12rpx;
+  .device-info { flex: 1;
+    .device-name { display: block; font-size: $font-size-base; font-weight: 600; color: $text-color-primary; margin-bottom: 4rpx; }
+    .device-sn { font-size: 22rpx; color: $text-color-muted; }
   }
-
-  .stock-bar {
-    display: flex;
-    align-items: center;
-    gap: 12rpx;
-    margin-bottom: 12rpx;
-
-    .bar-bg {
-      flex: 1;
-      height: 8rpx;
-      background: $bg-color-secondary;
-      border-radius: 4rpx;
-      overflow: hidden;
-
-      .bar-fill {
-        height: 100%;
-        background: $primary-color;
-        border-radius: 4rpx;
-        transition: width 0.3s;
-      }
-    }
-
-    .bar-text {
-      font-size: 20rpx;
-      color: $text-color-muted;
-      width: 48rpx;
-      text-align: right;
-    }
+  .status-tag { display: flex; align-items: center; gap: 6rpx; padding: 6rpx 14rpx; border-radius: $radius-full; font-size: 22rpx; font-weight: 500; flex-shrink: 0;
+    .status-dot { width: 10rpx; height: 10rpx; border-radius: 50%; }
+    &.online { background: $success-color-bg; color: $success-color; .status-dot { background: $success-color; } }
+    &.offline { background: $bg-color-secondary; color: $text-color-muted; .status-dot { background: $text-color-placeholder; } }
   }
-
-  .device-action {
-    padding-top: 12rpx;
-    border-top: 1rpx solid $bg-color-secondary;
-
-    .action-text {
-      font-size: 24rpx;
-      color: $primary-color;
-      font-weight: 500;
-    }
+}
+.device-addr { margin-bottom: 12rpx; text { font-size: $font-size-sm; color: $text-color-muted; } }
+.stock-row { display: flex; gap: 12rpx; margin-bottom: 16rpx;
+  .stock-chip { flex: 1; text-align: center; padding: 12rpx 8rpx; background: $bg-color-page; border-radius: $radius-sm;
+    .chip-label { display: block; font-size: 20rpx; color: $text-color-muted; margin-bottom: 4rpx; }
+    .chip-value { font-size: $font-size-base; font-weight: 700; color: $text-color-primary; }
+    &.warn { background: $warning-color-bg; .chip-value { color: $warning-color; } }
+    &.danger { background: $error-color-bg; .chip-value { color: $error-color; } }
+    .chip-value.accent { color: $accent-color; }
   }
 }
+.card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 12rpx; border-top: 1rpx solid $border-color-light;
+  .foot-text { font-size: $font-size-sm; color: $primary-color; font-weight: 500; }
+  .foot-arrow { font-size: $font-size-sm; color: $text-color-placeholder; }
+}
 </style>

+ 51 - 15
haha-admin-mp/src/pages/replenish/operation.vue

@@ -8,6 +8,11 @@
     </view>
 
     <template v-else>
+      <!-- 补货单提示 -->
+      <view class="order-banner" v-if="orderNo">
+        <text class="order-banner-text">补货单 {{ orderNo }} · 数量已按计划预填</text>
+      </view>
+
       <!-- 设备信息 -->
       <view class="device-section">
         <view class="device-card">
@@ -166,8 +171,12 @@ const decrease = (item: any, index: number) => {
   }
 };
 
+const MAX_QUANTITY = 9999;
+
 const increase = (item: any, index: number) => {
-  replenishItems.value[index].quantity++;
+  if (replenishItems.value[index].quantity < MAX_QUANTITY) {
+    replenishItems.value[index].quantity++;
+  }
 };
 
 const suggestQuantity = (item: any, index: number) => {
@@ -210,11 +219,12 @@ const handleSubmit = async () => {
 
   submitting.value = true;
   try {
-    const result = await replenishStock({
-      deviceId: deviceId.value,
-      items
-    });
+    const payload: any = { deviceId: deviceId.value, items };
+    if (orderId.value) payload.orderId = orderId.value;
+
+    const result = await replenishStock(payload);
 
+    uni.removeStorageSync('pendingReplenishOrder');
     uni.showToast({ title: `补货完成,成功${result.success}项`, icon: 'success' });
     setTimeout(() => {
       uni.navigateBack();
@@ -226,12 +236,15 @@ const handleSubmit = async () => {
   }
 };
 
+const orderId = ref('');
+const orderNo = ref('');
+
 onMounted(async () => {
-  // 获取参数
   const pages = getCurrentPages();
   const currentPage = pages[pages.length - 1];
   const options = (currentPage as any).$page?.options || (currentPage as any).options || {};
   deviceId.value = options.deviceId || '';
+  orderId.value = options.orderId || '';
 
   if (!deviceId.value) {
     uni.showToast({ title: '缺少设备ID', icon: 'none' });
@@ -246,15 +259,34 @@ onMounted(async () => {
     const list = data.inventoryList || [];
     inventoryList.value = list;
 
-    // 初始化补货输入
-    replenishItems.value = list.map((item: any) => ({
-      productId: item.product_id || item.productId || 0,
-      productCode: item.product_code || item.productCode || '',
-      productName: item.product_name || item.productName || '',
-      quantity: 0,
-      shelfNum: item.shelf_num || item.shelfNum || null,
-      position: item.position || null
-    }));
+    // 加载补货单预填数据
+    const orderStr = uni.getStorageSync('pendingReplenishOrder');
+    let orderItems: any[] = [];
+    if (orderStr) {
+      try {
+        const orderData = JSON.parse(orderStr);
+        if (orderData.orderId === orderId.value) {
+          orderNo.value = orderData.orderNo || '';
+          orderItems = orderData.items || [];
+        }
+      } catch { /* ignore parse error */ }
+    }
+
+    // 初始化补货输入,匹配补货单预填数量
+    replenishItems.value = list
+      .filter((item: any) => (item.product_id || item.productId))
+      .map((item: any) => {
+        const code = item.product_code || item.productCode || '';
+        const matched = orderItems.find((oi: any) => oi.productCode === code);
+        return {
+          productId: item.product_id || item.productId,
+          productCode: code,
+          productName: item.product_name || item.productName || '',
+          quantity: matched ? (matched.plannedQuantity || 0) : 0,
+          shelfNum: item.shelf_num || item.shelfNum || null,
+          position: item.position || null
+        };
+      });
   } catch (error: any) {
     uni.showToast({ title: error.message || '加载失败', icon: 'none' });
   } finally {
@@ -270,6 +302,10 @@ onMounted(async () => {
   padding-bottom: 200rpx;
 }
 
+/* 补货单提示 */
+.order-banner { margin: $spacing-2 $spacing-3; padding: $spacing-2 $spacing-3; background: $primary-color-bg; border-radius: $radius-sm; border-left: 4rpx solid $primary-color; }
+.order-banner-text { font-size: $font-size-sm; color: $primary-color-dark; }
+
 /* 加载 */
 .loading-container {
   display: flex;

+ 37 - 0
haha-admin-mp/src/pages/replenish/order-detail.vue

@@ -109,6 +109,13 @@
       </view>
     </template>
 
+    <!-- 执行补货按钮 -->
+    <view class="bottom-bar" v-if="order && canExecute(order)">
+      <view class="action-btn" @click="goReplenish">
+        <text>按补货单执行补货</text>
+      </view>
+    </view>
+
     <CustomTabBar />
   </view>
 </template>
@@ -133,6 +140,29 @@ const statusColorMap: Record<number, string> = {
 function statusText(status: number): string { return statusMap[status] || '未知'; }
 function statusColor(status: number): string { return statusColorMap[status] || ''; }
 
+function canExecute(o: any): boolean {
+  return o.status === 0 || o.status === 1;
+}
+
+function goReplenish() {
+  if (!order.value) return;
+  const orderData = {
+    orderId: order.value.id,
+    orderNo: order.value.orderNo,
+    items: items.value.map((item: any) => ({
+      productCode: item.productCode,
+      productName: item.productName,
+      plannedQuantity: item.plannedQuantity || 0,
+      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}`
+  });
+}
+
 onMounted(async () => {
   try {
     const pages = getCurrentPages();
@@ -345,4 +375,11 @@ onMounted(async () => {
     }
   }
 }
+
+// ====== Bottom bar ======
+.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; }
+}
 </style>

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

@@ -106,6 +106,7 @@ const statusTabs = [
   { label: '全部', value: undefined },
   { label: '草稿', value: 0 },
   { label: '已提交', value: 1 },
+  { label: '已同步', value: 2 },
   { label: '已完成', value: 3 }
 ];
 

+ 1 - 1
haha-admin-web/src/views/replenisher/index.vue

@@ -298,7 +298,7 @@ const {
           <div class="binding-code-section">
             <div class="section-label">绑定码(24小时内有效)</div>
             <div class="binding-code-value" @click="copyBindingCode">
-              {{ formatBindingCode(bindingCodeData.bindingCode) }}
+              {{ bindingCodeData.bindingCode }}
             </div>
             <div class="binding-code-hint">点击绑定码可复制</div>
           </div>

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

@@ -9,12 +9,14 @@ import com.haha.common.vo.PageResult;
 import com.haha.common.vo.Result;
 import com.haha.entity.Device;
 import com.haha.entity.DeviceInventory;
+import com.haha.entity.InventoryLog;
 import com.haha.entity.Replenisher;
 import com.haha.entity.ReplenishmentOrder;
 import com.haha.entity.ReplenishmentOrderItem;
 import com.haha.entity.dto.ReplenishDTO;
 import com.haha.service.DeviceInventoryService;
 import com.haha.service.DeviceService;
+import com.haha.service.InventoryLogService;
 import com.haha.service.ReplenisherService;
 import com.haha.service.ReplenishmentOrderService;
 import jakarta.validation.Valid;
@@ -42,6 +44,7 @@ public class ReplenisherOperationController {
     private final DeviceInventoryService deviceInventoryService;
     private final DeviceService deviceService;
     private final ReplenishmentOrderService replenishmentOrderService;
+    private final InventoryLogService inventoryLogService;
 
     /**
      * 获取当前补货员信息
@@ -70,6 +73,46 @@ public class ReplenisherOperationController {
             return Result.success("查询成功", new ArrayList<>());
         }
 
+        // 批量统计各设备补货单数量
+        Map<String, Long> pendingOrderCountMap = new java.util.HashMap<>();   // 草稿+已提交+已同步
+        Map<String, Long> submittedOrderCountMap = new java.util.HashMap<>(); // 已提交+已同步(不含草稿)
+        if (!deviceIds.isEmpty()) {
+            var qwAll = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ReplenishmentOrder>()
+                .select("device_id", "status", "COUNT(*) as cnt")
+                .in("device_id", deviceIds)
+                .in("status", 0, 1, 2)
+                .groupBy("device_id", "status");
+            List<Map<String, Object>> rows = replenishmentOrderService.listMaps(qwAll);
+            for (Map<String, Object> row : rows) {
+                String did = (String) row.get("device_id");
+                Number cnt = (Number) row.get("cnt");
+                Integer st = (Integer) row.get("status");
+                if (did == null || cnt == null) continue;
+                pendingOrderCountMap.merge(did, cnt.longValue(), Long::sum);
+                if (st != null && (st == 1 || st == 2)) {
+                    submittedOrderCountMap.merge(did, cnt.longValue(), Long::sum);
+                }
+            }
+        }
+
+        // 批量统计今日已补货设备(上货类型、当前补货员、今天)
+        java.time.LocalDate today = java.time.LocalDate.now();
+        Map<String, Boolean> todayRestockedMap = new java.util.HashMap<>();
+        if (!deviceIds.isEmpty()) {
+            var logQw = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<InventoryLog>()
+                .select("DISTINCT device_id")
+                .in("device_id", deviceIds)
+                .eq("change_type", InventoryLog.TYPE_RESTOCK)
+                .eq("operator_id", replenisher.getId())
+                .ge("create_time", today.atStartOfDay())
+                .lt("create_time", today.plusDays(1).atStartOfDay());
+            List<Map<String, Object>> logRows = inventoryLogService.listMaps(logQw);
+            for (Map<String, Object> row : logRows) {
+                String did = (String) row.get("device_id");
+                if (did != null) todayRestockedMap.put(did, true);
+            }
+        }
+
         // 查询设备详情
         List<Map<String, Object>> result = new ArrayList<>();
         for (String deviceId : deviceIds) {
@@ -106,6 +149,9 @@ public class ReplenisherOperationController {
             deviceInfo.put("zeroStockCount", zeroStockCount);
             deviceInfo.put("productCount", inventoryList.size());
             deviceInfo.put("inventoryList", inventoryList);
+            deviceInfo.put("pendingOrderCount", pendingOrderCountMap.getOrDefault(deviceId, 0L));
+            deviceInfo.put("submittedOrderCount", submittedOrderCountMap.getOrDefault(deviceId, 0L));
+            deviceInfo.put("todayReplenished", todayRestockedMap.containsKey(deviceId));
 
             result.add(deviceInfo);
         }