Parcourir la source

feat: 补货员/运营人员角色分离 + 重新开门 + 页面优化

【角色分离】
- 补货员登录→/pages/replenish/home(简洁版): 仅设备列表+库存状态
- 运营人员保留/pages/replenish/index(完整版): 设置/筛选/批量/统计
- operation.vue: 补货员底部[终止任务][重新开门][一键提交]
- 运营人员底部: 共补货N件[确认补货]

【重新开门】
- 后端: POST /replenisher/device/{deviceId}/open(验证绑定关系)
- 前端: handleReopen调用真实API

【页面优化】
- 设备名称前加设备编号(deviceId - name)
- 库存显示: 居中两列+浅灰底+数值加粗+低/缺货变色
- 退出按钮: 移至左侧+SVG图标+加大
- 报警阈值编辑图标: 换SVG铅笔图标
- 补货单选择器与横幅不重复显示
- 库存badge与指标行垂直居中对齐

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline il y a 10 heures
Parent
commit
e2c530dd6d

+ 7 - 0
haha-admin-mp/src/api/replenish.ts

@@ -91,6 +91,13 @@ export async function getDevicePendingOrders(deviceId: string): Promise<any[]> {
   return get(`/replenisher/device/${deviceId}/pending-orders`);
 }
 
+/**
+ * 补货员远程开门
+ */
+export async function openDeviceDoor(deviceId: string): Promise<void> {
+  return post(`/replenisher/device/${deviceId}/open`);
+}
+
 /**
  * 获取补货员的补货单列表
  */

+ 9 - 0
haha-admin-mp/src/pages.json

@@ -403,6 +403,15 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/replenish/home",
+      "style": {
+        "navigationBarTitleText": "补货管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/replenish/operation",
       "style": {

+ 3 - 3
haha-admin-mp/src/pages/login/login.vue

@@ -105,7 +105,7 @@ const formData = reactive({
 
 const navigateHome = () => {
   const home = getHomePath();
-  if (home === '/pages/replenish/index') {
+  if (home === '/pages/replenish/home') {
     uni.reLaunch({ url: home });
   } else {
     uni.switchTab({ url: home });
@@ -159,7 +159,7 @@ const handleWechatLogin = async () => {
 
     await loginByWechat({ code: loginRes.code });
     uni.showToast({ title: '登录成功', icon: 'success' });
-    setTimeout(() => uni.reLaunch({ url: '/pages/replenish/index' }), 500);
+    setTimeout(() => uni.reLaunch({ url: '/pages/replenish/home' }), 500);
   } catch (error: any) {
     if (error.message?.includes('未绑定')) {
       showBindInput.value = true;
@@ -223,7 +223,7 @@ const handleBindWechat = async () => {
     
     uni.showToast({ title: '绑定成功', icon: 'success' });
     setTimeout(() => {
-      uni.reLaunch({ url: '/pages/replenish/index' });
+      uni.reLaunch({ url: '/pages/replenish/home' });
     }, 500);
   } catch (error: any) {
     uni.showToast({ title: error.message || '绑定失败', icon: 'none' });

+ 145 - 0
haha-admin-mp/src/pages/replenish/home.vue

@@ -0,0 +1,145 @@
+<template>
+  <view class="page">
+    <view class="yellow-header">
+      <view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
+      <view class="header-content">
+        <image class="header-logout" src="/static/icon-logout.svg" @click="handleLogout" />
+        <text class="header-title">补货管理</text>
+      </view>
+    </view>
+    <view class="header-placeholder" :style="{ height: (statusBarHeight + 44) + 'px' }"></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>
+
+    <!-- 设备列表 -->
+    <scroll-view class="device-scroll" scroll-y v-else>
+      <view class="empty-state" v-if="deviceList.length === 0">
+        <view class="empty-icon-box"><view class="empty-device-icon"></view></view>
+        <text class="empty-text">暂无绑定的设备</text>
+        <text class="empty-hint">请联系管理员绑定设备后开始补货</text>
+      </view>
+
+      <view class="device-card" v-for="(device, index) in deviceList" :key="device.deviceId || index" @click="goToOperation(device)">
+        <view class="card-left">
+          <view class="card-icon"></view>
+        </view>
+        <view class="card-main">
+          <text class="card-name">{{ device.deviceId }} - {{ device.name || '' }}</text>
+          <view class="card-stats">
+            <view class="cs-item">
+              <text class="cs-label">标准</text>
+              <text class="cs-value">{{ device.standardStock || 0 }}</text>
+            </view>
+            <view class="cs-item">
+              <text class="cs-label">剩余</text>
+              <text class="cs-value" :class="{ warn: device.remainingStock < device.standardStock }">{{ device.remainingStock || 0 }}</text>
+            </view>
+            <view class="cs-item" v-if="device.pendingOrderCount > 0">
+              <text class="cs-label">待补</text>
+              <text class="cs-value accent">{{ device.pendingOrderCount }}</text>
+            </view>
+          </view>
+        </view>
+        <view class="card-badge" :class="device.remainingStock < device.standardStock ? 'warn' : 'normal'">
+          <text>{{ device.remainingStock < device.standardStock ? '待补货' : '正常' }}</text>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
+import { getDeviceList } from '@/api/replenish';
+import { clearAuth } from '@/utils/auth';
+
+const statusBarHeight = ref(0);
+const deviceList = ref<any[]>([]);
+const loading = ref(true);
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const devices = await getDeviceList();
+    deviceList.value = devices || [];
+  } catch { /* ignore */ }
+  finally { loading.value = false; }
+};
+
+const goToOperation = (device: any) => {
+  uni.navigateTo({ url: `/pages/replenish/operation?deviceId=${device.deviceId}` });
+};
+
+const handleLogout = () => {
+  uni.showModal({
+    title: '退出登录',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) { clearAuth(); uni.reLaunch({ url: '/pages/login/login' }); }
+    }
+  });
+};
+
+onMounted(() => {
+  const info = uni.getSystemInfoSync();
+  statusBarHeight.value = info.statusBarHeight || 20;
+});
+
+onShow(async () => { await loadData(); });
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; overflow: hidden; }
+
+// ====== Header ======
+.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: center; height: 44px; padding: 0 16rpx; position: relative; }
+.header-title { font-size: $font-size-xl; font-weight: 700; color: $text-color-primary; }
+.header-logout { position: absolute; left: 24rpx; width: 40rpx; height: 40rpx; &:active { opacity: 0.7; } }
+.header-placeholder { width: 100%; }
+
+// ====== 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; } }
+
+// ====== Device ======
+.device-scroll { flex: 1; height: 0; }
+.empty-state { display: flex; flex-direction: column; align-items: center; padding: 160rpx 0;
+  .empty-icon-box { width: 120rpx; height: 120rpx; background: $bg-color-secondary; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; margin-bottom: $spacing-3; }
+  .empty-device-icon { 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; }
+}
+
+.device-card { display: flex; align-items: center; gap: 16rpx; margin: 16rpx 24rpx; padding: 24rpx; background: $bg-color-card; border-radius: $radius-md; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  &:active { opacity: 0.7; }
+}
+.card-left { flex-shrink: 0; }
+.card-icon { width: 56rpx; height: 56rpx; background: $success-color-bg; border-radius: $radius-base; position: relative;
+  &::before { content: ''; position: absolute; top: 10rpx; left: 50%; transform: translateX(-50%); width: 24rpx; height: 18rpx; border: 2.5rpx solid $success-color; border-radius: 4rpx; }
+  &::after { content: ''; position: absolute; bottom: 6rpx; left: 50%; transform: translateX(-50%); width: 14rpx; height: 5rpx; background: $success-color; border-radius: 0 0 3rpx 3rpx; }
+}
+.card-main { flex: 1; min-width: 0; }
+.card-name { display: block; font-size: $font-size-base; font-weight: 600; color: $text-color-primary; margin-bottom: 10rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.card-stats { display: flex; gap: 24rpx; }
+.cs-item { display: flex; align-items: baseline; gap: 6rpx; }
+.cs-label { font-size: 22rpx; color: $text-color-muted; }
+.cs-value { font-size: $font-size-base; font-weight: 700; color: $text-color-primary;
+  &.warn { color: $warning-color; }
+  &.accent { color: $accent-color; }
+}
+.card-badge { padding: 8rpx 16rpx; border-radius: $radius-sm; font-size: 22rpx; font-weight: 500; flex-shrink: 0;
+  &.normal { background: $success-color-bg; color: $success-color; }
+  &.warn { background: $warning-color-bg; color: $warning-color; }
+}
+</style>

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

@@ -38,7 +38,7 @@
         </view>
         <view class="threshold-value" @click="editThreshold">
           <text>{{ alertThreshold }}%</text>
-          <text class="edit-icon">✎</text>
+          <image class="edit-icon" src="/static/icon-edit.svg" />
         </view>
       </view>
 
@@ -112,7 +112,7 @@
         @click="goToOperation(device)"
       >
         <view class="card-top">
-          <text class="device-name">{{ device.name || device.deviceId }}</text>
+          <text class="device-name">{{ device.deviceId }} - {{ device.name || '' }}</text>
           <view class="order-tag" v-if="device.replenishOrderGenerated">补货单已生成</view>
         </view>
 
@@ -369,7 +369,7 @@ onShow(async () => {
   height: 44px; padding: 0 16rpx; position: relative;
 }
 .header-title { font-size: $font-size-xl; font-weight: 700; color: $text-color-primary; }
-.header-logout { position: absolute; left: 16rpx; width: 44rpx; height: 44rpx;
+.header-logout { position: absolute; left: 24rpx; width: 40rpx; height: 40rpx;
   &:active { opacity: 0.7; }
 }
 .header-placeholder { width: 100%; }
@@ -413,7 +413,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; display: inline-block; transform: scaleX(-1); }
+    .edit-icon { width: 32rpx; height: 32rpx; flex-shrink: 0; }
   }
 }
 .toggle-switch {
@@ -498,7 +498,7 @@ onShow(async () => {
   .device-name { font-size: $font-size-base; font-weight: 600; color: $text-color-primary; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
   .order-tag { padding: 4rpx 16rpx; background: $primary-color; border-radius: 6rpx; font-size: 22rpx; font-weight: 600; color: #1e293b; flex-shrink: 0; margin-left: 12rpx; }
 }
-.card-body { display: flex; align-items: flex-start; gap: 16rpx;
+.card-body { display: flex; align-items: center; 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: $font-size-xs; color: #fff; }
@@ -508,9 +508,9 @@ onShow(async () => {
     &.normal { background: $success-color; }
   }
   .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: $font-size-xs; color: $text-color-muted; margin-left: 4rpx; }
+    .metric-item { width: 50%; margin-bottom: 8rpx; display: flex; flex-direction: column; align-items: center;
+      .metric-value { font-size: $font-size-md; font-weight: 700; color: $text-color-primary; }
+      .metric-label { font-size: 22rpx; color: $text-color-muted; margin-top: 2rpx; }
       .info-tip { font-size: 18rpx; color: $warning-color; font-style: italic; margin-left: 2rpx; }
     }
   }

+ 62 - 11
haha-admin-mp/src/pages/replenish/operation.vue

@@ -15,7 +15,7 @@
         </picker>
         <text class="selector-status">{{ pendingOrders[selectedOrderIndex]?.statusLabel || '' }}</text>
       </view>
-      <view class="order-banner" v-if="orderNo">
+      <view class="order-banner" v-else-if="orderNo && pendingOrders.length <= 1">
         <text class="order-banner-text">补货单 {{ orderNo }}</text>
       </view>
 
@@ -53,8 +53,14 @@
           </view>
 
           <view class="item-stock">
-            <text class="stock-label">标准库存 {{ item.warning_threshold || item.warningThreshold || '-' }}</text>
-            <text class="stock-label">剩余库存 <text :class="{ warn: isLowStock(item), danger: isOutOfStock(item) }">{{ item.stock || 0 }}</text></text>
+            <view class="stock-col">
+              <text class="stock-label">标准库存</text>
+              <text class="stock-num">{{ item.warning_threshold || item.warningThreshold || '-' }}</text>
+            </view>
+            <view class="stock-col">
+              <text class="stock-label">剩余库存</text>
+              <text class="stock-num" :class="{ warn: isLowStock(item), danger: isOutOfStock(item) }">{{ item.stock || 0 }}</text>
+            </view>
           </view>
 
           <view class="replenish-row">
@@ -69,7 +75,17 @@
         </view>
       </view>
 
-      <view class="bottom-bar" v-if="inventoryList.length > 0">
+      <!-- 补货员底部操作 -->
+      <view class="bottom-bar" v-if="inventoryList.length > 0 && isReplenisherUser">
+        <view class="bottom-btn secondary" @click="handleCancel"><text>终止任务</text></view>
+        <view class="bottom-btn secondary" @click="handleReopen"><text>重新开门</text></view>
+        <view class="bottom-btn primary" :class="{ disabled: submitting || totalReplenishCount === 0 }" @click="handleSubmit">
+          <view class="btn-spinner" v-if="submitting"></view>
+          <text>{{ submitting ? '提交中' : '一键提交' }}</text>
+        </view>
+      </view>
+      <!-- 运营人员底部操作 -->
+      <view class="bottom-bar" v-else-if="inventoryList.length > 0">
         <text class="total-label">共补货 <text class="total-num">{{ totalReplenishCount }}</text> 件</text>
         <view class="submit-btn" :class="{ disabled: submitting || totalReplenishCount === 0 }" @click="handleSubmit">
           <view class="btn-spinner" v-if="submitting"></view>
@@ -83,7 +99,8 @@
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue';
 import NavBar from '@/components/NavBar.vue';
-import { getDeviceInventory, replenishStock, getDevicePendingOrders, getReplenisherOrderDetail } from '@/api/replenish';
+import { getDeviceInventory, replenishStock, getDevicePendingOrders, getReplenisherOrderDetail, openDeviceDoor } from '@/api/replenish';
+import { isReplenisher } from '@/utils/auth';
 
 interface ReplenishInput {
   productId: number;
@@ -100,6 +117,7 @@ const inventoryList = ref<any[]>([]);
 const replenishItems = ref<ReplenishInput[]>([]);
 const loading = ref(true);
 const submitting = ref(false);
+const isReplenisherUser = ref(false);
 const orderId = ref('');
 const orderNo = ref('');
 const showOrderOnly = ref(false);
@@ -196,9 +214,34 @@ const onOrderChange = (e: any) => {
   if (o) loadOrderItems(String(o.id));
 };
 
+const handleCancel = () => {
+  uni.showModal({
+    title: '终止任务',
+    content: '确定要终止当前补货任务吗?',
+    success: (res) => { if (res.confirm) uni.navigateBack(); }
+  });
+};
+
+const handleReopen = () => {
+  uni.showModal({
+    title: '重新开门',
+    content: '确定要远程打开柜门吗?',
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await openDeviceDoor(deviceId.value);
+        uni.showToast({ title: '开门指令已发送', icon: 'success' });
+      } catch (e: any) {
+        uni.showToast({ title: e.message || '开门失败', icon: 'none' });
+      }
+    }
+  });
+};
+
 onMounted(async () => {
   const pages = getCurrentPages();
   const opts = (pages[pages.length - 1] as any)?.$page?.options || (pages[pages.length - 1] as any)?.options || {};
+  isReplenisherUser.value = isReplenisher();
   deviceId.value = opts.deviceId || '';
   orderId.value = opts.orderId || '';
   if (!deviceId.value) { uni.showToast({ title: '缺少设备ID', icon: 'none' }); setTimeout(() => uni.navigateBack(), 500); return; }
@@ -332,11 +375,12 @@ onMounted(async () => {
   &.danger { background: $error-color-bg; color: $error-color; }
 }
 
-.item-stock { display: flex; gap: 24rpx; margin-bottom: 12rpx; }
-.stock-label { font-size: $font-size-sm; color: $text-color-muted;
-  text { font-weight: 600; }
-  .warn { color: $warning-color; }
-  .danger { color: $error-color; }
+.item-stock { display: flex; gap: 16rpx; margin-bottom: 12rpx; padding: 12rpx 0; background: $bg-color-page; border-radius: $radius-sm; }
+.stock-col { flex: 1; display: flex; flex-direction: column; align-items: center; }
+.stock-label { font-size: 22rpx; color: $text-color-muted; margin-bottom: 4rpx; }
+.stock-num { font-size: $font-size-lg; font-weight: 700; color: $text-color-primary;
+  &.warn { color: $warning-color; }
+  &.danger { color: $error-color; }
 }
 
 .replenish-row { display: flex; align-items: center; background: $bg-color-page; border-radius: $radius-base; padding: 12rpx 16rpx; }
@@ -353,9 +397,16 @@ onMounted(async () => {
 }
 
 // ====== Bottom ======
-.bottom-bar { display: flex; align-items: center; gap: 20rpx; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; }
+.bottom-bar { display: flex; align-items: center; gap: 16rpx; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; }
 .total-label { font-size: $font-size-sm; color: $text-color-muted; flex-shrink: 0; }
 .total-num { font-size: $font-size-xl; font-weight: 700; color: $primary-color-dark; margin: 0 4rpx; }
+.bottom-btn { flex: 1; height: 80rpx; display: flex; align-items: center; justify-content: center; border-radius: $radius-base;
+  text { font-size: $font-size-md; font-weight: 600; line-height: 1; }
+  &:active { opacity: 0.8; }
+  &.secondary { background: $bg-color-page; text { color: $text-color-secondary; } }
+  &.primary { background: $primary-color; text { color: $text-color-primary; } }
+  &.disabled { opacity: 0.5; pointer-events: none; }
+}
 .submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 12rpx; height: 80rpx; background: $primary-color; border-radius: $radius-base;
   text { font-size: $font-size-md; font-weight: 600; color: $text-color-primary; }
   &:active { opacity: 0.8; }

+ 4 - 0
haha-admin-mp/src/static/icon-edit.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#FFC107" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+  <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
+  <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
+</svg>

+ 1 - 1
haha-admin-mp/src/utils/auth.ts

@@ -132,7 +132,7 @@ export function logout(): void {
  */
 export function getHomePath(): string {
   if (isReplenisher()) {
-    return '/pages/replenish/index';
+    return '/pages/replenish/home';
   }
   return '/pages/index/index';
 }

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

@@ -229,6 +229,24 @@ public class ReplenisherOperationController {
         return Result.success("查询成功", result);
     }
 
+    /**
+     * 补货员远程开门
+     */
+    @PostMapping("/device/{deviceId}/open")
+    public Result<Void> openDoor(@PathVariable String deviceId) {
+        Replenisher replenisher = getCurrentReplenisher();
+        List<String> boundIds = replenisherService.getBoundDeviceIds(replenisher.getId());
+        if (!boundIds.contains(deviceId)) {
+            return Result.error(403, "您无权操作此设备");
+        }
+        Device device = deviceService.getDeviceBySn(deviceId);
+        if (device == null) {
+            return Result.error(404, "设备不存在");
+        }
+        boolean success = deviceService.openDoor(device.getId(), "A");
+        return success ? Result.success("开门指令已发送", null) : Result.error(500, "开门失败");
+    }
+
     /**
      * 获取设备库存详情
      *