skyline 1 месяц назад
Родитель
Сommit
03c06da140
29 измененных файлов с 3742 добавлено и 19 удалено
  1. 31 4
      haha-admin-mp/src/App.vue
  2. 82 0
      haha-admin-mp/src/api/replenish.ts
  3. 27 0
      haha-admin-mp/src/pages.json
  4. 282 0
      haha-admin-mp/src/pages/login/login.vue
  5. 82 6
      haha-admin-mp/src/pages/my/my.vue
  6. 375 0
      haha-admin-mp/src/pages/replenish/bind.vue
  7. 507 0
      haha-admin-mp/src/pages/replenish/index.vue
  8. 584 0
      haha-admin-mp/src/pages/replenish/operation.vue
  9. 64 0
      haha-admin-mp/src/utils/auth.ts
  10. 9 0
      haha-admin-web/src/api/replenisher.ts
  11. 267 0
      haha-admin-web/src/views/replenisher/index.vue
  12. 103 4
      haha-admin-web/src/views/replenisher/utils/hook.tsx
  13. 1 0
      haha-admin-web/src/views/replenisher/utils/types.ts
  14. 2 1
      haha-admin-web/vite.config.ts
  15. 1 0
      haha-admin/src/main/java/com/haha/admin/config/SaTokenConfig.java
  16. 18 0
      haha-admin/src/main/java/com/haha/admin/controller/ReplenisherController.java
  17. 58 0
      haha-admin/src/main/java/com/haha/admin/controller/ReplenisherLoginController.java
  18. 238 0
      haha-admin/src/main/java/com/haha/admin/controller/ReplenisherOperationController.java
  19. 7 0
      haha-admin/src/main/resources/application.yml
  20. 8 0
      haha-common/src/main/java/com/haha/common/constant/RedisConstants.java
  21. 65 0
      haha-entity/src/main/java/com/haha/entity/dto/ReplenishDTO.java
  22. 13 4
      haha-entity/src/main/java/com/haha/entity/dto/ReplenisherBindDTO.java
  23. 15 0
      haha-entity/src/main/java/com/haha/entity/dto/WechatLoginDTO.java
  24. 32 0
      haha-mp/src/App.vue
  25. 32 0
      haha-mp/src/api/replenish.ts
  26. 8 0
      haha-mp/src/pages.json
  27. 565 0
      haha-mp/src/pages/replenish/bind.vue
  28. 30 0
      haha-service/src/main/java/com/haha/service/ReplenisherService.java
  29. 236 0
      haha-service/src/main/java/com/haha/service/impl/ReplenisherServiceImpl.java

+ 31 - 4
haha-admin-mp/src/App.vue

@@ -31,8 +31,19 @@ const checkLoginStatus = () => {
 };
 
 /**
- * 检测启动参数中的邀请码
- * 场景:通过分享链接进入小程序时,会携带 inviteCode 参数
+ * 获取当前登录用户的首页路径
+ */
+const getHomePath = (): string => {
+  const userType = uni.getStorageSync('admin_user_type') || 'admin';
+  if (userType === 'replenisher') {
+    return '/pages/replenish/index';
+  }
+  return '/pages/index/index';
+};
+
+/**
+ * 检测启动参数中的邀请码和补货员绑定码
+ * 场景:通过分享链接进入小程序时,会携带 inviteCode/bindingCode 参数
  */
 const checkInviteCode = (options: any) => {
   // 检查 query 参数中的邀请码
@@ -42,10 +53,26 @@ const checkInviteCode = (options: any) => {
     return;
   }
 
+  // 检查补货员绑定码
+  if (options && options.query && options.query.bindingCode) {
+    console.log('检测到补货员绑定码:', options.query.bindingCode);
+    uni.navigateTo({
+      url: '/pages/replenish/bind?code=' + encodeURIComponent(options.query.bindingCode)
+    });
+    return;
+  }
+
   // 检查 scene 场景值(小程序码场景)
   if (options && options.scene) {
-    // scene 需要后端解码,这里仅做记录
-    console.log('检测到场景值:', options.scene);
+    const scene = decodeURIComponent(options.scene);
+    console.log('检测到场景值:', scene);
+    // 尝试解码场景值,如果是绑定码格式则跳转
+    if (/^[A-Z0-9]{24}$/.test(scene)) {
+      console.log('场景值为补货员绑定码:', scene);
+      uni.navigateTo({
+        url: '/pages/replenish/bind?code=' + scene
+      });
+    }
   }
 };
 

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

@@ -0,0 +1,82 @@
+/**
+ * 补货员相关API
+ */
+import { post, get, put } from '@/utils/request';
+import { setToken, setReplenisherInfo, setUserType } from '@/utils/auth';
+
+export interface WechatLoginParams {
+  code: string;
+}
+
+export interface LoginResponse {
+  token: string;
+  userInfo: any;
+}
+
+/**
+ * 补货员微信静默登录
+ */
+export async function loginByWechat(params: WechatLoginParams): Promise<LoginResponse> {
+  const response = await post<LoginResponse>('/replenisher/login/wechat', params);
+  // 保存登录信息
+  setToken(response.token);
+  setReplenisherInfo(response.userInfo);
+  setUserType('replenisher');
+  return response;
+}
+
+/**
+ * 获取当前补货员信息
+ */
+export async function getMyInfo(): Promise<any> {
+  return get('/replenisher/my-info');
+}
+
+/**
+ * 获取补货员绑定的设备列表(含库存信息)
+ */
+export async function getDeviceList(): Promise<any[]> {
+  return get('/replenisher/device/list');
+}
+
+/**
+ * 获取设备库存详情
+ * @param deviceId 设备SN号
+ */
+export async function getDeviceInventory(deviceId: string): Promise<any> {
+  return get(`/replenisher/device/inventory/${deviceId}`);
+}
+
+export interface ReplenishItem {
+  productId: number;
+  productCode?: string;
+  productName?: string;
+  quantity: number;
+  shelfNum?: number;
+  position?: string;
+}
+
+export interface ReplenishParams {
+  deviceId: string;
+  items: ReplenishItem[];
+}
+
+/**
+ * 执行补货操作
+ */
+export async function replenishStock(params: ReplenishParams): Promise<any> {
+  return post('/replenisher/stock/replenish', params);
+}
+
+/**
+ * 补货员扫码绑定微信
+ * @param params 绑定参数(bindingCode + wechatCode)
+ */
+export async function bindWechat(params: { bindingCode: string; code: string }): Promise<LoginResponse> {
+  const response = await post<LoginResponse>('/replenisher/login/bind', params);
+  // 保存登录信息
+  setToken(response.token);
+  setReplenisherInfo(response.userInfo);
+  setUserType('replenisher');
+  return response;
+}

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

@@ -197,6 +197,33 @@
 				"navigationBarTextStyle": "black",
 				"navigationStyle": "custom"
 			}
+		},
+		{
+			"path": "pages/replenish/index",
+			"style": {
+				"navigationBarTitleText": "补货首页",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"navigationStyle": "custom"
+			}
+		},
+		{
+			"path": "pages/replenish/operation",
+			"style": {
+				"navigationBarTitleText": "补货操作",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"navigationStyle": "custom"
+			}
+		},
+		{
+			"path": "pages/replenish/bind",
+			"style": {
+				"navigationBarTitleText": "绑定微信",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"navigationStyle": "custom"
+			}
 		}
 	],
 	"globalStyle": {

+ 282 - 0
haha-admin-mp/src/pages/login/login.vue

@@ -51,6 +51,40 @@
           <text v-else>登录中...</text>
         </button>
         
+        <!-- 分隔线 -->
+        <view class="login-divider">
+          <view class="divider-line"></view>
+          <text class="divider-text">其他登录方式</text>
+          <view class="divider-line"></view>
+        </view>
+        
+        <!-- 微信登录 -->
+        <button class="wechat-btn" :loading="wechatLoading" :disabled="wechatLoading" @click="handleWechatLogin">
+          <view class="wechat-icon"></view>
+          <text>补货员微信一键登录</text>
+        </button>
+
+        <!-- 绑定码登录 -->
+        <view class="bind-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>
+          <button class="bind-btn" :loading="bindLoading" :disabled="bindLoading || !bindingCode" @click="handleBindWechat">
+            <text v-if="!bindLoading">绑定微信并登录</text>
+            <text v-else>绑定中...</text>
+          </button>
+        </view>
+        <text v-else class="bind-toggle" @click="showBindInput = true">已有绑定码?点击绑定微信</text>
+        
         <view class="login-tips">
           <view class="tips-icon"></view>
           <text class="tips-label">测试账号</text>
@@ -78,6 +112,10 @@ const formData = reactive({
 
 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()) {
@@ -108,6 +146,87 @@ const handleLogin = async () => {
     loading.value = false;
   }
 };
+
+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;
+  }
+};
+
+/**
+ * 补货员绑定微信
+ */
+const handleBindWechat = async () => {
+  if (!bindingCode.value || bindingCode.value.length < 4) {
+    uni.showToast({ title: '请输入有效的绑定码', 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)
+      });
+    });
+    
+    if (!loginRes.code) {
+      uni.showToast({ title: '微信授权失败', icon: 'none' });
+      return;
+    }
+    
+    // 2. 调用绑定接口
+    const { bindWechat } = await import('@/api/replenish');
+    await bindWechat({
+      bindingCode: bindingCode.value.trim().toUpperCase(),
+      code: loginRes.code
+    });
+    
+    uni.showToast({ title: '绑定成功', icon: 'success' });
+    setTimeout(() => {
+      uni.reLaunch({ url: '/pages/replenish/index' });
+    }, 500);
+  } catch (error: any) {
+    uni.showToast({ title: error.message || '绑定失败', icon: 'none' });
+  } finally {
+    bindLoading.value = false;
+  }
+};
 </script>
 
 <style lang="scss" scoped>
@@ -414,6 +533,169 @@ const handleLogin = async () => {
   }
 }
 
+/* 分隔线 */
+.login-divider {
+  display: flex;
+  align-items: center;
+  margin-top: 32rpx;
+  margin-bottom: 28rpx;
+  
+  .divider-line {
+    flex: 1;
+    height: 1rpx;
+    background: #e2e8f0;
+  }
+  
+  .divider-text {
+    padding: 0 24rpx;
+    font-size: 24rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 微信登录按钮 */
+.wechat-btn {
+  width: 100%;
+  height: 100rpx;
+  background: #ffffff;
+  color: #1e293b;
+  font-size: 30rpx;
+  font-weight: 500;
+  border: 2rpx solid #e2e8f0;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.2s;
+  
+  &::after {
+    border: none;
+  }
+  
+  &:active {
+    background: #f8fafc;
+    border-color: #10b981;
+  }
+  
+  &[disabled] {
+    opacity: 0.6;
+  }
+  
+  .wechat-icon {
+    width: 36rpx;
+    height: 36rpx;
+    background: #07c160;
+    border-radius: 8rpx;
+    margin-right: 12rpx;
+    position: relative;
+    
+    &::before {
+      content: '';
+      position: absolute;
+      top: 8rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 14rpx;
+      height: 10rpx;
+      border: 3rpx solid #fff;
+      border-top: none;
+      border-right: none;
+      transform: translateX(-50%) rotate(-45deg);
+    }
+    
+    &::after {
+      content: '';
+      position: absolute;
+      bottom: 8rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 16rpx;
+      height: 10rpx;
+      border: 3rpx solid #fff;
+      border-bottom: none;
+      border-right: none;
+      transform: translateX(-50%) rotate(45deg);
+    }
+  }
+}
+
+/* 绑定码切换链接 */
+.bind-toggle {
+  display: block;
+  text-align: center;
+  margin-top: 20rpx;
+  font-size: 24rpx;
+  color: #10b981;
+}
+
+/* 绑定码区域 */
+.bind-section {
+  margin-top: 20rpx;
+  
+  .divider-line {
+    height: 1rpx;
+    background: #e2e8f0;
+    margin-bottom: 20rpx;
+  }
+  
+  .bind-hint {
+    display: block;
+    font-size: 24rpx;
+    color: #64748b;
+    margin-bottom: 16rpx;
+    line-height: 1.5;
+  }
+  
+  .bind-input-wrapper {
+    background: #f8fafc;
+    border: 2rpx solid #e2e8f0;
+    border-radius: 16rpx;
+    padding: 0 20rpx;
+    margin-bottom: 16rpx;
+    transition: all 0.2s;
+    
+    &:focus-within {
+      border-color: #10b981;
+      background: #ffffff;
+      box-shadow: 0 0 0 4rpx rgba(16, 185, 129, 0.1);
+    }
+    
+    .bind-input {
+      width: 100%;
+      height: 100rpx;
+      font-size: 30rpx;
+      color: #1e293b;
+      letter-spacing: 4rpx;
+    }
+  }
+  
+  .bind-btn {
+    width: 100%;
+    height: 90rpx;
+    background: #10b981;
+    color: #fff;
+    font-size: 30rpx;
+    font-weight: 600;
+    border-radius: 16rpx;
+    border: none;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    
+    &::after {
+      border: none;
+    }
+    
+    &:active {
+      background: #059669;
+    }
+    
+    &[disabled] {
+      opacity: 0.6;
+    }
+  }
+}
+
 /* 底部 */
 .login-footer {
   padding: 40rpx 0;

+ 82 - 6
haha-admin-mp/src/pages/my/my.vue

@@ -39,7 +39,25 @@
     
     <!-- 菜单区域 -->
     <view class="menu-section">
-      <view class="menu-card">
+      <!-- 补货员菜单 -->
+      <view class="menu-card" v-if="isReplenisherUser">
+        <text class="menu-title">补货管理</text>
+        <view class="menu-list">
+          <view class="menu-item" @click="handleReplenisherHome">
+            <view class="menu-icon green">
+              <view class="icon-device"></view>
+            </view>
+            <view class="menu-content">
+              <text class="menu-name">补货首页</text>
+              <text class="menu-desc">查看设备和执行补货</text>
+            </view>
+            <view class="menu-arrow"></view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 管理员菜单 -->
+      <view class="menu-card" v-if="!isReplenisherUser">
         <text class="menu-title">业务管理</text>
         <view class="menu-list">
           <view class="menu-item" @click="navigateTo('/pages/shop/list')">
@@ -110,7 +128,25 @@
         </view>
       </view>
       
-      <view class="menu-card">
+      <!-- 补货员设置的关闭按钮 -->
+      <view class="menu-card" v-if="isReplenisherUser">
+        <text class="menu-title">系统设置</text>
+        <view class="menu-list">
+          <view class="menu-item" @click="handleAbout">
+            <view class="menu-icon blue">
+              <view class="icon-info"></view>
+            </view>
+            <view class="menu-content">
+              <text class="menu-name">关于我们</text>
+              <text class="menu-desc">版本信息</text>
+            </view>
+            <view class="menu-arrow"></view>
+          </view>
+        </view>
+      </view>
+      
+      <!-- 管理员菜单 -->
+      <view class="menu-card" v-if="!isReplenisherUser">
         <text class="menu-title">系统设置</text>
         <view class="menu-list">
           <view class="menu-item" @click="handleChangePassword">
@@ -159,15 +195,31 @@
 import { ref, onMounted } from 'vue';
 import NavBar from '@/components/NavBar.vue';
 import CustomTabBar from '@/components/CustomTabBar.vue';
-import { getUserInfo, clearAuth } from '@/utils/auth';
+import { getUserInfo, getReplenisherInfo, isReplenisher, clearAuth } from '@/utils/auth';
 import { showConfirm, showToast } from '@/utils/common';
 
 const userInfo = ref<any>({});
+const isReplenisherUser = ref(false);
 
 const loadUserInfo = () => {
-  const info = getUserInfo();
-  if (info) {
-    userInfo.value = info;
+  // 判断用户类型
+  isReplenisherUser.value = isReplenisher();
+  
+  if (isReplenisherUser.value) {
+    // 补货员使用补货员信息
+    const info = getReplenisherInfo();
+    if (info) {
+      userInfo.value = {
+        nickname: info.nickname || info.name || '补货员',
+        roleName: '补货员'
+      };
+    }
+  } else {
+    // 管理员使用原有用户信息
+    const info = getUserInfo();
+    if (info) {
+      userInfo.value = info;
+    }
   }
 };
 
@@ -183,6 +235,10 @@ const handleAbout = () => {
   showToast('哈哈运营平台 v1.0.0');
 };
 
+const handleReplenisherHome = () => {
+  uni.reLaunch({ url: '/pages/replenish/index' });
+};
+
 const handleLogout = async () => {
   const confirmed = await showConfirm('确定要退出登录吗?');
   if (confirmed) {
@@ -537,6 +593,26 @@ onMounted(() => {
     & { height: 16rpx; margin-right: 2rpx; }
     &::after { height: 12rpx; }
   }
+
+  .icon-device {
+    width: 20rpx;
+    height: 16rpx;
+    border: 3rpx solid #10b981;
+    border-radius: 4rpx;
+    position: relative;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: -6rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 12rpx;
+      height: 6rpx;
+      background: #10b981;
+      border-radius: 0 0 4rpx 4rpx;
+    }
+  }
 }
 
 .menu-content {

+ 375 - 0
haha-admin-mp/src/pages/replenish/bind.vue

@@ -0,0 +1,375 @@
+<template>
+  <view class="page">
+    <NavBar title="绑定微信" />
+
+    <view class="content" v-if="!bound">
+      <view class="icon-section">
+        <view class="bind-icon">
+          <view class="icon-wechat"></view>
+        </view>
+      </view>
+
+      <text class="title">绑定补货员微信</text>
+      <text class="subtitle">请确认以下绑定码,点击按钮完成微信绑定</text>
+
+      <view class="code-card">
+        <text class="code-label">绑定码</text>
+        <text class="code-value">{{ displayCode }}</text>
+        <text class="code-expire">有效期24小时,过期请重新生成</text>
+      </view>
+
+      <view class="bind-info">
+        <view class="info-item">
+          <view class="info-icon done"></view>
+          <text class="info-text">后台已创建补货员账号</text>
+        </view>
+        <view class="info-item">
+          <view class="info-icon done"></view>
+          <text class="info-text">管理员已生成一次性绑定码</text>
+        </view>
+        <view class="info-item">
+          <view class="info-icon active"></view>
+          <text class="info-text">点击下方按钮完成微信绑定</text>
+        </view>
+      </view>
+
+      <button class="bind-btn" :loading="bindLoading" :disabled="bindLoading" @click="handleBind">
+        <text v-if="!bindLoading">绑定微信</text>
+        <text v-else>绑定中...</text>
+      </button>
+
+      <text class="back-link" @click="goToLogin">返回登录页</text>
+    </view>
+
+    <view class="content success-section" v-else>
+      <view class="success-icon">
+        <text class="success-check">✓</text>
+      </view>
+      <text class="success-title">绑定成功</text>
+      <text class="success-desc">即将进入补货首页...</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+
+const bindingCode = ref('');
+const bindLoading = ref(false);
+const bound = ref(false);
+
+const displayCode = computed(() => {
+  const code = bindingCode.value;
+  if (!code) return '---';
+  // 每4位一组显示
+  return code.replace(/(.{4})/g, '$1 ').trim();
+});
+
+onMounted(() => {
+  // 获取绑定码
+  const pages = getCurrentPages();
+  const currentPage = pages[pages.length - 1] as any;
+  if (currentPage && currentPage.options) {
+    bindingCode.value = currentPage.options.code || currentPage.options.bindingCode || '';
+  }
+
+  if (!bindingCode.value) {
+    uni.showToast({ title: '缺少绑定码参数', icon: 'none' });
+  }
+});
+
+const goToLogin = () => {
+  uni.reLaunch({ url: '/pages/login/login' });
+};
+
+const handleBind = async () => {
+  if (!bindingCode.value) {
+    uni.showToast({ title: '绑定码无效', icon: 'none' });
+    return;
+  }
+
+  bindLoading.value = true;
+  try {
+    // 1. 微信授权
+    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 { bindWechat } = await import('@/api/replenish');
+    await bindWechat({
+      bindingCode: bindingCode.value.trim().toUpperCase(),
+      code: loginRes.code
+    });
+
+    // 3. 显示成功状态
+    bound.value = true;
+    setTimeout(() => {
+      uni.reLaunch({ url: '/pages/replenish/index' });
+    }, 1500);
+  } catch (error: any) {
+    uni.showToast({ title: error.message || '绑定失败', icon: 'none' });
+  } finally {
+    bindLoading.value = false;
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f8fafc;
+}
+
+.content {
+  padding: 40rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* 图标区域 */
+.icon-section {
+  margin: 40rpx 0 32rpx;
+
+  .bind-icon {
+    width: 160rpx;
+    height: 160rpx;
+    background: #ecfdf5;
+    border-radius: 40rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .icon-wechat {
+      width: 64rpx;
+      height: 54rpx;
+      background: #07c160;
+      border-radius: 12rpx;
+      position: relative;
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: 12rpx;
+        left: 50%;
+        transform: translateX(-50%);
+        width: 24rpx;
+        height: 16rpx;
+        border: 4rpx solid #fff;
+        border-top: none;
+        border-right: none;
+        transform: translateX(-50%) rotate(-45deg);
+      }
+
+      &::after {
+        content: '';
+        position: absolute;
+        bottom: 12rpx;
+        left: 50%;
+        transform: translateX(-50%);
+        width: 28rpx;
+        height: 16rpx;
+        border: 4rpx solid #fff;
+        border-bottom: none;
+        border-right: none;
+        transform: translateX(-50%) rotate(45deg);
+      }
+    }
+  }
+}
+
+.title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #1e293b;
+  margin-bottom: 12rpx;
+}
+
+.subtitle {
+  font-size: 26rpx;
+  color: #64748b;
+  margin-bottom: 40rpx;
+  text-align: center;
+}
+
+/* 绑定码卡片 */
+.code-card {
+  width: 100%;
+  background: #ffffff;
+  border: 2rpx solid #10b981;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  text-align: center;
+  margin-bottom: 32rpx;
+
+  .code-label {
+    display: block;
+    font-size: 24rpx;
+    color: #94a3b8;
+    margin-bottom: 16rpx;
+  }
+
+  .code-value {
+    display: block;
+    font-size: 36rpx;
+    font-weight: 700;
+    color: #1e293b;
+    letter-spacing: 6rpx;
+    font-family: 'Courier New', monospace;
+    margin-bottom: 16rpx;
+  }
+
+  .code-expire {
+    font-size: 22rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 绑定流程说明 */
+.bind-info {
+  width: 100%;
+  background: #ffffff;
+  border: 1rpx solid #e2e8f0;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 40rpx;
+
+  .info-item {
+    display: flex;
+    align-items: center;
+    margin-bottom: 20rpx;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    .info-icon {
+      width: 32rpx;
+      height: 32rpx;
+      border-radius: 50%;
+      margin-right: 16rpx;
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      &.done {
+        background: #10b981;
+
+        &::after {
+          content: '';
+          width: 10rpx;
+          height: 16rpx;
+          border: 3rpx solid #fff;
+          border-top: none;
+          border-left: none;
+          transform: rotate(45deg) translateY(-2rpx);
+        }
+      }
+
+      &.active {
+        background: #10b981;
+        animation: pulse 2s infinite;
+
+        &::after {
+          content: '';
+          width: 8rpx;
+          height: 8rpx;
+          background: #fff;
+          border-radius: 50%;
+        }
+      }
+    }
+
+    .info-text {
+      font-size: 26rpx;
+      color: #475569;
+    }
+  }
+}
+
+@keyframes pulse {
+  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
+  50% { box-shadow: 0 0 0 12rpx rgba(16, 185, 129, 0); }
+}
+
+/* 绑定按钮 */
+.bind-btn {
+  width: 100%;
+  height: 100rpx;
+  background: #10b981;
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 600;
+  border-radius: 16rpx;
+  border: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  &::after {
+    border: none;
+  }
+
+  &:active {
+    background: #059669;
+  }
+
+  &[disabled] {
+    opacity: 0.6;
+  }
+}
+
+.back-link {
+  display: block;
+  margin-top: 28rpx;
+  font-size: 26rpx;
+  color: #94a3b8;
+}
+
+/* 成功状态 */
+.success-section {
+  justify-content: center;
+  min-height: 70vh;
+
+  .success-icon {
+    width: 140rpx;
+    height: 140rpx;
+    background: #ecfdf5;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-bottom: 28rpx;
+
+    .success-check {
+      font-size: 64rpx;
+      color: #10b981;
+      font-weight: 700;
+    }
+  }
+
+  .success-title {
+    font-size: 36rpx;
+    font-weight: 700;
+    color: #1e293b;
+    margin-bottom: 12rpx;
+  }
+
+  .success-desc {
+    font-size: 28rpx;
+    color: #64748b;
+  }
+}
+</style>

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

@@ -0,0 +1,507 @@
+<template>
+  <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>
+        <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>
+      </view>
+    </view>
+
+    <!-- 加载中 -->
+    <view class="loading-container" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 设备列表 -->
+    <view class="device-section" v-else>
+      <view class="section-header">
+        <text class="section-title">我的设备</text>
+        <text class="section-count" v-if="deviceList.length">共 {{ deviceList.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>
+
+      <!-- 设备卡片 -->
+      <view
+        class="device-card"
+        v-for="(device, index) in deviceList"
+        :key="index"
+        @click="goToOperation(device)"
+      >
+        <view class="device-header">
+          <view class="device-info">
+            <text class="device-name">{{ device.name || device.deviceId }}</text>
+            <text class="device-id">SN: {{ device.deviceId }}</text>
+          </view>
+          <view :class="['device-status', device.status === 1 ? 'online' : 'offline']">
+            {{ device.statusLabel }}
+          </view>
+        </view>
+
+        <view class="device-address" v-if="device.address">
+          <text class="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>
+          <view class="stock-item">
+            <text class="stock-label">总库存</text>
+            <text class="stock-value">{{ device.totalStock || 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>
+          <view class="stock-item danger" v-if="device.zeroStockCount > 0">
+            <text class="stock-label">缺货</text>
+            <text class="stock-value">{{ device.zeroStockCount }}</text>
+          </view>
+        </view>
+
+        <!-- 库存条形 -->
+        <view class="stock-bar">
+          <view class="bar-bg">
+            <view
+              class="bar-fill"
+              :style="{ width: getStockRatio(device) + '%' }"
+            ></view>
+          </view>
+          <text class="bar-text">{{ getStockRatio(device) }}%</text>
+        </view>
+
+        <view class="device-action">
+          <text class="action-text">查看商品库存 ></text>
+        </view>
+      </view>
+    </view>
+
+    <CustomTabBar />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import CustomTabBar from '@/components/CustomTabBar.vue';
+import { getReplenisherInfo, getHomePath } 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 lowStockTotal = ref(0);
+
+const navigateToMy = () => {
+  uni.switchTab({ url: '/pages/my/my' });
+};
+
+const goToOperation = (device: any) => {
+  uni.navigateTo({
+    url: `/pages/replenish/operation?deviceId=${device.deviceId}`
+  });
+};
+
+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);
+};
+
+onMounted(async () => {
+  try {
+    // 获取补货员信息
+    const info = getReplenisherInfo();
+    if (info && info.id) {
+      replenisherInfo.value = info;
+    }
+
+    // 获取补货员详情
+    const detail = await getMyInfo();
+    if (detail) {
+      replenisherInfo.value = detail;
+      totalTaskCount.value = detail.totalTasks || 0;
+    }
+
+    // 获取设备列表
+    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' });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f8fafc;
+  padding-bottom: 200rpx;
+}
+
+/* 用户区域 */
+.user-section {
+  padding: 16rpx 24rpx;
+}
+
+.user-card {
+  background: #ffffff;
+  border: 1rpx solid #e2e8f0;
+  border-radius: 20rpx;
+  padding: 28rpx;
+
+  .user-header {
+    display: flex;
+    align-items: center;
+    margin-bottom: 20rpx;
+
+    .avatar {
+      width: 80rpx;
+      height: 80rpx;
+      background: #ecfdf5;
+      border-radius: 20rpx;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin-right: 20rpx;
+      border: 2rpx solid #10b981;
+
+      .avatar-text {
+        font-size: 36rpx;
+        font-weight: 700;
+        color: #10b981;
+      }
+    }
+
+    .user-info {
+      flex: 1;
+
+      .user-name {
+        display: block;
+        font-size: 32rpx;
+        font-weight: 700;
+        color: #1e293b;
+      }
+
+      .user-role {
+        font-size: 24rpx;
+        color: #10b981;
+        font-weight: 500;
+      }
+
+      .user-employee {
+        font-size: 22rpx;
+        color: #94a3b8;
+      }
+    }
+  }
+
+  .user-stats {
+    display: flex;
+    background: #f8fafc;
+    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: #1e293b;
+      }
+
+      .stat-label {
+        font-size: 22rpx;
+        color: #64748b;
+      }
+    }
+
+    .stat-divider {
+      width: 1rpx;
+      background: #e2e8f0;
+    }
+  }
+}
+
+/* 加载 */
+.loading-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+
+  .loading-text {
+    margin-top: 16rpx;
+    font-size: 26rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 设备区域 */
+.device-section {
+  padding: 0 24rpx;
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24rpx 0 16rpx;
+
+  .section-title {
+    font-size: 30rpx;
+    font-weight: 700;
+    color: #1e293b;
+  }
+
+  .section-count {
+    font-size: 24rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 空状态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0;
+
+  .empty-icon {
+    width: 120rpx;
+    height: 120rpx;
+    background: #f1f5f9;
+    border-radius: 24rpx;
+    margin-bottom: 24rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .icon-device {
+      width: 40rpx;
+      height: 32rpx;
+      border: 4rpx solid #94a3b8;
+      border-radius: 6rpx;
+      position: relative;
+
+      &::before {
+        content: '';
+        position: absolute;
+        bottom: -8rpx;
+        left: 50%;
+        transform: translateX(-50%);
+        width: 20rpx;
+        height: 8rpx;
+        background: #94a3b8;
+        border-radius: 0 0 6rpx 6rpx;
+      }
+    }
+  }
+
+  .empty-text {
+    font-size: 28rpx;
+    color: #64748b;
+    margin-bottom: 8rpx;
+  }
+
+  .empty-hint {
+    font-size: 24rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 设备卡片 */
+.device-card {
+  background: #ffffff;
+  border: 1rpx solid #e2e8f0;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+
+  &:active {
+    background: #fafafa;
+  }
+
+  .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: #1e293b;
+      }
+
+      .device-id {
+        font-size: 22rpx;
+        color: #94a3b8;
+      }
+    }
+
+    .device-status {
+      padding: 6rpx 16rpx;
+      border-radius: 8rpx;
+      font-size: 22rpx;
+      font-weight: 500;
+
+      &.online {
+        background: #ecfdf5;
+        color: #10b981;
+      }
+
+      &.offline {
+        background: #f1f5f9;
+        color: #94a3b8;
+      }
+    }
+  }
+
+  .device-address {
+    margin-bottom: 16rpx;
+
+    .address-text {
+      font-size: 24rpx;
+      color: #64748b;
+    }
+  }
+
+  .stock-overview {
+    display: flex;
+    gap: 16rpx;
+    margin-bottom: 12rpx;
+
+    .stock-item {
+      flex: 1;
+      background: #f8fafc;
+      border-radius: 10rpx;
+      padding: 12rpx;
+      text-align: center;
+
+      .stock-label {
+        display: block;
+        font-size: 20rpx;
+        color: #94a3b8;
+        margin-bottom: 4rpx;
+      }
+
+      .stock-value {
+        display: block;
+        font-size: 28rpx;
+        font-weight: 700;
+        color: #1e293b;
+      }
+
+      &.warn {
+        background: #fffbeb;
+
+        .stock-value { color: #f59e0b; }
+      }
+
+      &.danger {
+        background: #fef2f2;
+
+        .stock-value { color: #ef4444; }
+      }
+    }
+  }
+
+  .stock-bar {
+    display: flex;
+    align-items: center;
+    gap: 12rpx;
+    margin-bottom: 12rpx;
+
+    .bar-bg {
+      flex: 1;
+      height: 8rpx;
+      background: #f1f5f9;
+      border-radius: 4rpx;
+      overflow: hidden;
+
+      .bar-fill {
+        height: 100%;
+        background: #10b981;
+        border-radius: 4rpx;
+        transition: width 0.3s;
+      }
+    }
+
+    .bar-text {
+      font-size: 20rpx;
+      color: #94a3b8;
+      width: 48rpx;
+      text-align: right;
+    }
+  }
+
+  .device-action {
+    padding-top: 12rpx;
+    border-top: 1rpx solid #f1f5f9;
+
+    .action-text {
+      font-size: 24rpx;
+      color: #10b981;
+      font-weight: 500;
+    }
+  }
+}
+</style>

+ 584 - 0
haha-admin-mp/src/pages/replenish/operation.vue

@@ -0,0 +1,584 @@
+<template>
+  <view class="page">
+    <NavBar title="补货操作" showBack />
+
+    <view class="loading-container" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载设备信息...</text>
+    </view>
+
+    <template v-else>
+      <!-- 设备信息 -->
+      <view class="device-section">
+        <view class="device-card">
+          <view class="device-header">
+            <view class="device-icon"></view>
+            <view class="device-info">
+              <text class="device-name">{{ deviceInfo.name || deviceInfo.deviceId }}</text>
+              <text class="device-id" v-if="deviceInfo.address">{{ deviceInfo.address }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 商品库存清单 -->
+      <view class="inventory-section">
+        <view class="section-header">
+          <text class="section-title">商品库存清单</text>
+        </view>
+
+        <!-- 空状态 -->
+        <view class="empty-state" v-if="inventoryList.length === 0">
+          <text class="empty-text">暂无商品库存数据</text>
+        </view>
+
+        <!-- 商品列表 -->
+        <view
+          class="inventory-card"
+          v-for="(item, index) in inventoryList"
+          :key="index"
+        >
+          <view class="item-header">
+            <text class="item-name">{{ item.product_name || item.productName || '未知商品' }}</text>
+            <text class="item-code" v-if="item.product_code || item.productCode">
+              {{ item.product_code || item.productCode }}
+            </text>
+            <view :class="['stock-status', getStockStatus(item)]">
+              {{ getStockStatusText(item) }}
+            </view>
+          </view>
+
+          <view class="item-stock">
+            <view class="stock-row">
+              <text class="stock-label">当前库存</text>
+              <text :class="['stock-value', { 'low-stock': isLowStock(item), 'out-of-stock': isOutOfStock(item) }]">
+                {{ item.stock || 0 }}
+              </text>
+            </view>
+            <view class="stock-row">
+              <text class="stock-label">预警阈值</text>
+              <text class="stock-value muted">{{ item.warning_threshold || item.warningThreshold || 5 }}</text>
+            </view>
+          </view>
+
+          <!-- 补货输入 -->
+          <view class="replenish-input-section">
+            <text class="input-label">补货数量</text>
+            <view class="quantity-control">
+              <view class="qty-btn" @click="decrease(item, index)">-</view>
+              <input
+                class="qty-input"
+                type="number"
+                v-model="replenishItems[index].quantity"
+                @blur="validateQuantity(index)"
+              />
+              <view class="qty-btn" @click="increase(item, index)">+</view>
+            </view>
+            <view class="suggest-btn" @click="suggestQuantity(item, index)">
+              一键补满
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 底部提交 -->
+      <view class="bottom-actions" v-if="inventoryList.length > 0">
+        <view class="total-info">
+          <text class="total-label">共补货</text>
+          <text class="total-value">{{ totalReplenishCount }}</text>
+          <text class="total-label">件商品</text>
+        </view>
+        <button class="submit-btn" :loading="submitting" :disabled="submitting || totalReplenishCount === 0" @click="handleSubmit">
+          <text v-if="!submitting">确认补货</text>
+          <text v-else>提交中...</text>
+        </button>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getDeviceInventory, replenishStock } from '@/api/replenish';
+
+interface InventoryItem {
+  product_id?: number;
+  productId?: number;
+  product_code?: string;
+  productCode?: string;
+  product_name?: string;
+  productName?: string;
+  stock?: number;
+  warning_threshold?: number;
+  warningThreshold?: number;
+  shelf_num?: number;
+  shelfNum?: number;
+  position?: string;
+}
+
+interface ReplenishInput {
+  productId: number;
+  productCode?: string;
+  productName?: string;
+  quantity: number;
+  shelfNum?: number;
+  position?: string;
+}
+
+const deviceId = ref('');
+const deviceInfo = ref<any>({});
+const inventoryList = ref<InventoryItem[]>([]);
+const replenishItems = ref<ReplenishInput[]>([]);
+const loading = ref(true);
+const submitting = ref(false);
+
+const totalReplenishCount = computed(() => {
+  return replenishItems.value.reduce((sum, item) => sum + (item.quantity || 0), 0);
+});
+
+const isLowStock = (item: any): boolean => {
+  const stock = item.stock || 0;
+  const threshold = item.warning_threshold || item.warningThreshold || 5;
+  return stock > 0 && stock <= threshold;
+};
+
+const isOutOfStock = (item: any): boolean => {
+  return (item.stock || 0) === 0;
+};
+
+const getStockStatus = (item: any): string => {
+  if (isOutOfStock(item)) return 'danger';
+  if (isLowStock(item)) return 'warning';
+  return 'normal';
+};
+
+const getStockStatusText = (item: any): string => {
+  if ((item.stock || 0) === 0) return '缺货';
+  const threshold = item.warning_threshold || item.warningThreshold || 5;
+  if ((item.stock || 0) <= threshold) return '低库存';
+  return '正常';
+};
+
+const decrease = (item: any, index: number) => {
+  if (replenishItems.value[index].quantity > 0) {
+    replenishItems.value[index].quantity--;
+  }
+};
+
+const increase = (item: any, index: number) => {
+  replenishItems.value[index].quantity++;
+};
+
+const suggestQuantity = (item: any, index: number) => {
+  const threshold = item.warning_threshold || item.warningThreshold || 5;
+  const currentStock = item.stock || 0;
+  // 建议补满到预警阈值的3倍(即建议库存水平)
+  const suggestLevel = threshold * 3;
+  const suggest = Math.max(suggestLevel - currentStock, 0);
+  replenishItems.value[index].quantity = suggest || 1;
+};
+
+const validateQuantity = (index: number) => {
+  if (replenishItems.value[index].quantity < 0) {
+    replenishItems.value[index].quantity = 0;
+  }
+};
+
+const handleSubmit = async () => {
+  if (totalReplenishCount.value === 0) {
+    uni.showToast({ title: '请至少补货一件商品', icon: 'none' });
+    return;
+  }
+
+  // 过滤掉数量为0的项
+  const items = replenishItems.value
+    .filter(item => item.quantity > 0)
+    .map(item => ({
+      productId: item.productId,
+      productCode: item.productCode,
+      productName: item.productName,
+      quantity: item.quantity,
+      shelfNum: item.shelfNum,
+      position: item.position
+    }));
+
+  if (items.length === 0) {
+    uni.showToast({ title: '请至少补货一件商品', icon: 'none' });
+    return;
+  }
+
+  submitting.value = true;
+  try {
+    const result = await replenishStock({
+      deviceId: deviceId.value,
+      items
+    });
+
+    uni.showToast({ title: `补货完成,成功${result.success}项`, icon: 'success' });
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 800);
+  } catch (error: any) {
+    uni.showToast({ title: error.message || '补货失败', icon: 'none' });
+  } finally {
+    submitting.value = false;
+  }
+};
+
+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 || '';
+
+  if (!deviceId.value) {
+    uni.showToast({ title: '缺少设备ID', icon: 'none' });
+    setTimeout(() => uni.navigateBack(), 500);
+    return;
+  }
+
+  try {
+    const data = await getDeviceInventory(deviceId.value);
+    deviceInfo.value = data;
+
+    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
+    }));
+  } catch (error: any) {
+    uni.showToast({ title: error.message || '加载失败', icon: 'none' });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: #f8fafc;
+  padding-bottom: 200rpx;
+}
+
+/* 加载 */
+.loading-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0;
+
+  .loading-text {
+    margin-top: 16rpx;
+    font-size: 26rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 设备信息 */
+.device-section {
+  padding: 16rpx 24rpx 0;
+
+  .device-card {
+    background: #ffffff;
+    border: 1rpx solid #e2e8f0;
+    border-radius: 16rpx;
+    padding: 24rpx;
+
+    .device-header {
+      display: flex;
+      align-items: center;
+
+      .device-icon {
+        width: 48rpx;
+        height: 48rpx;
+        background: #ecfdf5;
+        border-radius: 12rpx;
+        margin-right: 16rpx;
+        position: relative;
+
+        &::before {
+          content: '';
+          position: absolute;
+          top: 8rpx;
+          left: 50%;
+          transform: translateX(-50%);
+          width: 20rpx;
+          height: 16rpx;
+          border: 3rpx solid #10b981;
+          border-radius: 4rpx;
+        }
+
+        &::after {
+          content: '';
+          position: absolute;
+          bottom: 4rpx;
+          left: 50%;
+          transform: translateX(-50%);
+          width: 12rpx;
+          height: 6rpx;
+          background: #10b981;
+          border-radius: 0 0 4rpx 4rpx;
+        }
+      }
+
+      .device-info {
+        flex: 1;
+
+        .device-name {
+          display: block;
+          font-size: 30rpx;
+          font-weight: 600;
+          color: #1e293b;
+        }
+
+        .device-id {
+          font-size: 24rpx;
+          color: #64748b;
+        }
+      }
+    }
+  }
+}
+
+/* 库存区域 */
+.inventory-section {
+  padding: 0 24rpx;
+}
+
+.section-header {
+  padding: 24rpx 0 16rpx;
+
+  .section-title {
+    font-size: 30rpx;
+    font-weight: 700;
+    color: #1e293b;
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 60rpx 0;
+
+  .empty-text {
+    font-size: 26rpx;
+    color: #94a3b8;
+  }
+}
+
+/* 商品卡片 */
+.inventory-card {
+  background: #ffffff;
+  border: 1rpx solid #e2e8f0;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+
+  .item-header {
+    display: flex;
+    align-items: center;
+    margin-bottom: 16rpx;
+
+    .item-name {
+      font-size: 28rpx;
+      font-weight: 600;
+      color: #1e293b;
+      flex: 1;
+    }
+
+    .item-code {
+      font-size: 20rpx;
+      color: #94a3b8;
+      margin-right: 12rpx;
+    }
+
+    .stock-status {
+      padding: 4rpx 12rpx;
+      border-radius: 6rpx;
+      font-size: 20rpx;
+      font-weight: 500;
+
+      &.normal {
+        background: #ecfdf5;
+        color: #10b981;
+      }
+
+      &.warning {
+        background: #fffbeb;
+        color: #f59e0b;
+      }
+
+      &.danger {
+        background: #fef2f2;
+        color: #ef4444;
+      }
+    }
+  }
+
+  .item-stock {
+    display: flex;
+    gap: 24rpx;
+    margin-bottom: 16rpx;
+
+    .stock-row {
+      flex: 1;
+      display: flex;
+      align-items: center;
+
+      .stock-label {
+        font-size: 24rpx;
+        color: #94a3b8;
+        margin-right: 8rpx;
+      }
+
+      .stock-value {
+        font-size: 28rpx;
+        font-weight: 700;
+        color: #1e293b;
+
+        &.muted {
+          color: #94a3b8;
+        }
+
+        &.low-stock {
+          color: #f59e0b;
+        }
+
+        &.out-of-stock {
+          color: #ef4444;
+        }
+      }
+    }
+  }
+
+  .replenish-input-section {
+    display: flex;
+    align-items: center;
+    background: #f8fafc;
+    border-radius: 12rpx;
+    padding: 16rpx;
+
+    .input-label {
+      font-size: 24rpx;
+      color: #64748b;
+      margin-right: 16rpx;
+    }
+
+    .quantity-control {
+      display: flex;
+      align-items: center;
+      border: 1rpx solid #e2e8f0;
+      border-radius: 8rpx;
+      overflow: hidden;
+
+      .qty-btn {
+        width: 56rpx;
+        height: 56rpx;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-size: 32rpx;
+        font-weight: 500;
+        color: #64748b;
+        background: #ffffff;
+
+        &:active {
+          background: #f1f5f9;
+        }
+      }
+
+      .qty-input {
+        width: 80rpx;
+        height: 56rpx;
+        text-align: center;
+        font-size: 28rpx;
+        font-weight: 600;
+        color: #1e293b;
+        border-left: 1rpx solid #e2e8f0;
+        border-right: 1rpx solid #e2e8f0;
+      }
+    }
+
+    .suggest-btn {
+      margin-left: auto;
+      padding: 10rpx 20rpx;
+      background: #ecfdf5;
+      border-radius: 8rpx;
+      font-size: 22rpx;
+      color: #10b981;
+      font-weight: 500;
+
+      &:active {
+        background: #d1fae5;
+      }
+    }
+  }
+}
+
+/* 底部操作 */
+.bottom-actions {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #ffffff;
+  border-top: 1rpx solid #e2e8f0;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+
+  .total-info {
+    display: flex;
+    align-items: center;
+
+    .total-label {
+      font-size: 24rpx;
+      color: #64748b;
+    }
+
+    .total-value {
+      font-size: 36rpx;
+      font-weight: 700;
+      color: #10b981;
+      margin: 0 8rpx;
+    }
+  }
+
+  .submit-btn {
+    flex: 1;
+    height: 88rpx;
+    background: #10b981;
+    color: #fff;
+    font-size: 30rpx;
+    font-weight: 600;
+    border-radius: 12rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &::after {
+      border: none;
+    }
+
+    &:active {
+      background: #059669;
+    }
+
+    &[disabled] {
+      opacity: 0.6;
+    }
+  }
+}
+</style>

+ 64 - 0
haha-admin-mp/src/utils/auth.ts

@@ -4,6 +4,8 @@
 
 const TOKEN_KEY = 'admin_token';
 const USER_INFO_KEY = 'admin_user_info';
+const USER_TYPE_KEY = 'admin_user_type';
+const REPLENISHER_INFO_KEY = 'replenisher_info';
 
 /**
  * 获取Token
@@ -48,12 +50,64 @@ export function removeUserInfo(): void {
   uni.removeStorageSync(USER_INFO_KEY);
 }
 
+/**
+ * 获取用户类型
+ */
+export function getUserType(): string {
+  return uni.getStorageSync(USER_TYPE_KEY) || 'admin';
+}
+
+/**
+ * 设置用户类型
+ */
+export function setUserType(type: string): void {
+  uni.setStorageSync(USER_TYPE_KEY, type);
+}
+
+/**
+ * 移除用户类型
+ */
+export function removeUserType(): void {
+  uni.removeStorageSync(USER_TYPE_KEY);
+}
+
+/**
+ * 获取补货员信息
+ */
+export function getReplenisherInfo(): any {
+  const info = uni.getStorageSync(REPLENISHER_INFO_KEY);
+  return info ? JSON.parse(info) : null;
+}
+
+/**
+ * 设置补货员信息
+ */
+export function setReplenisherInfo(info: any): void {
+  uni.setStorageSync(REPLENISHER_INFO_KEY, JSON.stringify(info));
+}
+
+/**
+ * 移除补货员信息
+ */
+export function removeReplenisherInfo(): void {
+  uni.removeStorageSync(REPLENISHER_INFO_KEY);
+}
+
+/**
+ * 是否为补货员
+ */
+export function isReplenisher(): boolean {
+  return getUserType() === 'replenisher';
+}
+
 /**
  * 清除所有登录信息
  */
 export function clearAuth(): void {
   removeToken();
   removeUserInfo();
+  removeUserType();
+  removeReplenisherInfo();
 }
 
 /**
@@ -72,3 +126,13 @@ export function logout(): void {
     url: '/pages/login/login'
   });
 }
+
+/**
+ * 获取登录用户首页路径
+ */
+export function getHomePath(): string {
+  if (isReplenisher()) {
+    return '/pages/replenish/index';
+  }
+  return '/pages/index/index';
+}

+ 9 - 0
haha-admin-web/src/api/replenisher.ts

@@ -86,3 +86,12 @@ export const batchBindDevices = (data: {
 }) => {
   return http.request<Result>("post", "/replenishers/batch-bind", { data });
 };
+
+/**
+ * 生成补货员绑定码
+ * @param id 补货员ID
+ * @returns bindingCode
+ */
+export const generateBindingCode = (id: number) => {
+  return http.request<Result>("post", `/replenishers/${id}/binding-code`);
+};

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

@@ -28,6 +28,12 @@ const {
   resetForm,
   openDialog,
   handleDelete,
+  openBindingCodeDialog,
+  bindingDialogVisible,
+  bindingCodeData,
+  generatingCode,
+  formatBindingCode,
+  copyBindingCode,
   openBindDialog,
   bindDialogVisible,
   currentReplenisher,
@@ -131,6 +137,15 @@ const {
             >
               设备
             </el-button>
+            <el-button
+              class="reset-margin"
+              link
+              type="primary"
+              :size="size"
+              @click="openBindingCodeDialog(row)"
+            >
+              绑定码
+            </el-button>
             <el-button
               class="reset-margin"
               link
@@ -243,6 +258,81 @@ const {
         </el-button>
       </template>
     </el-dialog>
+
+    <!-- 绑定码弹窗 -->
+    <el-dialog
+      v-model="bindingDialogVisible"
+      title="补货员微信绑定"
+      width="520px"
+      draggable
+      :close-on-click-modal="false"
+      @close="bindingDialogVisible = false"
+    >
+      <div v-loading="generatingCode" class="binding-dialog-body">
+        <!-- 补货员信息 -->
+        <div class="binding-header">
+          <div class="binding-avatar">{{ (currentReplenisher?.name || '').charAt(0) || '?' }}</div>
+          <div class="binding-user-info">
+            <div class="binding-user-name">{{ currentReplenisher?.name || '未知' }}</div>
+            <div class="binding-user-tag">补货员</div>
+          </div>
+        </div>
+
+        <div class="binding-steps">
+          <div class="step-item">
+            <div class="step-num active">1</div>
+            <div class="step-desc">管理员确认补货员身份,生成一次性绑定码</div>
+          </div>
+          <div class="step-item">
+            <div class="step-num" :class="{ active: bindingCodeData.bindingCode }">2</div>
+            <div class="step-desc">分享绑定码或二维码给补货员</div>
+          </div>
+          <div class="step-item">
+            <div class="step-num" :class="{ active: bindingCodeData.bindingCode }">3</div>
+            <div class="step-desc">补货员在小程序端完成微信绑定</div>
+          </div>
+        </div>
+
+        <!-- 绑定码展示 -->
+        <template v-if="bindingCodeData.bindingCode">
+          <div class="binding-code-section">
+            <div class="section-label">绑定码(24小时内有效)</div>
+            <div class="binding-code-value" @click="copyBindingCode">
+              {{ formatBindingCode(bindingCodeData.bindingCode) }}
+            </div>
+            <div class="binding-code-hint">点击绑定码可复制</div>
+          </div>
+
+          <!-- 二维码展示 -->
+          <div v-if="bindingCodeData.qrCodeDataUrl" class="qr-section">
+            <div class="section-label">绑定二维码</div>
+            <div class="qr-wrapper">
+              <img :src="bindingCodeData.qrCodeDataUrl" alt="绑定二维码" class="qr-image" />
+            </div>
+            <div class="qr-hint">微信扫码后自动打开小程序绑定页</div>
+          </div>
+
+          <!-- 手动绑定指引 -->
+          <div class="manual-section">
+            <div class="section-label">手动绑定方式</div>
+            <div class="manual-content">
+              <div class="manual-step">1. 补货员打开"哈哈运营平台"小程序</div>
+              <div class="manual-step">2. 登录页点击"已有绑定码?点击绑定微信"</div>
+              <div class="manual-step">3. 输入上方24位绑定码完成绑定</div>
+            </div>
+          </div>
+        </template>
+
+        <!-- 空状态 -->
+        <div v-if="!bindingCodeData.bindingCode && !generatingCode" class="binding-placeholder">
+          <el-empty description="请点击'生成'按钮" :image-size="80" />
+        </div>
+      </div>
+
+      <template #footer>
+        <el-button @click="bindingDialogVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -270,4 +360,181 @@ const {
     background-color: var(--el-fill-color-darker);
   }
 }
+
+/* 绑定码弹窗样式 */
+.binding-dialog-body {
+  min-height: 200px;
+  padding: 8px 0;
+
+  .binding-header {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    margin-bottom: 24px;
+    padding-bottom: 20px;
+    border-bottom: 1px solid var(--el-border-color-lighter);
+
+    .binding-avatar {
+      width: 48px;
+      height: 48px;
+      background: var(--el-color-primary-light-9);
+      border-radius: 12px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 22px;
+      font-weight: 700;
+      color: var(--el-color-primary);
+      flex-shrink: 0;
+    }
+
+    .binding-user-info {
+      .binding-user-name {
+        font-size: 16px;
+        font-weight: 600;
+        color: var(--el-text-color-primary);
+      }
+
+      .binding-user-tag {
+        font-size: 12px;
+        color: var(--el-color-primary);
+        background: var(--el-color-primary-light-9);
+        display: inline-block;
+        padding: 2px 10px;
+        border-radius: 4px;
+        margin-top: 4px;
+      }
+    }
+  }
+
+  .binding-steps {
+    margin-bottom: 24px;
+
+    .step-item {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 14px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      .step-num {
+        width: 22px;
+        height: 22px;
+        border-radius: 50%;
+        background: var(--el-border-color-lighter);
+        color: var(--el-text-color-placeholder);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-size: 12px;
+        font-weight: 600;
+        flex-shrink: 0;
+        margin-top: 1px;
+
+        &.active {
+          background: var(--el-color-primary);
+          color: #fff;
+        }
+      }
+
+      .step-desc {
+        font-size: 13px;
+        color: var(--el-text-color-regular);
+        line-height: 1.5;
+      }
+    }
+  }
+
+  .section-label {
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    margin-bottom: 10px;
+  }
+
+  .binding-code-section {
+    margin-bottom: 24px;
+
+    .binding-code-value {
+      background: var(--el-fill-color-light);
+      border: 1px dashed var(--el-color-primary);
+      border-radius: 8px;
+      padding: 14px 20px;
+      font-size: 20px;
+      font-weight: 700;
+      font-family: 'Courier New', Courier, monospace;
+      letter-spacing: 4px;
+      text-align: center;
+      color: var(--el-color-primary);
+      cursor: pointer;
+      transition: background 0.15s;
+      user-select: all;
+
+      &:hover {
+        background: var(--el-color-primary-light-9);
+      }
+    }
+
+    .binding-code-hint {
+      font-size: 12px;
+      color: var(--el-text-color-placeholder);
+      margin-top: 6px;
+      text-align: center;
+    }
+  }
+
+  .qr-section {
+    margin-bottom: 24px;
+
+    .qr-wrapper {
+      display: flex;
+      justify-content: center;
+      background: #fff;
+      border: 1px solid var(--el-border-color-lighter);
+      border-radius: 8px;
+      padding: 16px;
+      width: fit-content;
+      margin: 0 auto;
+
+      .qr-image {
+        width: 200px;
+        height: 200px;
+        display: block;
+      }
+    }
+
+    .qr-hint {
+      font-size: 12px;
+      color: var(--el-text-color-placeholder);
+      margin-top: 6px;
+      text-align: center;
+    }
+  }
+
+  .manual-section {
+    margin-bottom: 8px;
+
+    .manual-content {
+      background: var(--el-fill-color-lighter);
+      border-radius: 6px;
+      padding: 12px 16px;
+
+      .manual-step {
+        font-size: 13px;
+        color: var(--el-text-color-secondary);
+        line-height: 1.8;
+
+        &:hover {
+          color: var(--el-text-color-primary);
+        }
+      }
+    }
+  }
+
+  .binding-placeholder {
+    padding: 30px 0;
+  }
+}
 </style>

+ 103 - 4
haha-admin-web/src/views/replenisher/utils/hook.tsx

@@ -2,17 +2,17 @@ import dayjs from "dayjs";
 import { message } from "@/utils/message";
 import { addDialog } from "@/components/ReDialog";
 import { usePublicHooks } from "@/views/system/hooks";
-import type { PaginationProps } from "@pureadmin/table";
+import QRCode from "qrcode";
 import { deviceDetection } from "@pureadmin/utils";
-import {
-  getReplenisherList,
+import { getReplenisherList,
   createReplenisher,
   updateReplenisher,
   updateReplenisherStatus,
   deleteReplenisher,
   getBoundDevices,
   bindDevices,
-  unbindDevice
+  unbindDevice,
+  generateBindingCode
 } from "@/api/replenisher";
 import { getEnabledShops, getShopDevices } from "@/api/shop";
 import {
@@ -325,6 +325,99 @@ export function useReplenisher(tableRef: Ref) {
     }
   }
 
+  // 绑定码生成弹窗
+  const bindingDialogVisible = ref(false);
+  const bindingCodeData = reactive<{
+    replenisherName: string;
+    bindingCode: string;
+    qrCodeDataUrl: string;
+  }>({
+    replenisherName: "",
+    bindingCode: "",
+    qrCodeDataUrl: ""
+  });
+  const generatingCode = ref(false);
+
+  function openBindingCodeDialog(row: ReplenisherFormItem) {
+    bindingCodeData.replenisherName = row.name;
+    bindingCodeData.bindingCode = "";
+    bindingCodeData.qrCodeDataUrl = "";
+    bindingDialogVisible.value = true;
+    handleGenerateAndShowBindingCode(row);
+  }
+
+  async function handleGenerateAndShowBindingCode(row: ReplenisherFormItem) {
+    if (!row || !row.id) {
+      message("补货员信息不完整", { type: "error" });
+      return;
+    }
+    generatingCode.value = true;
+    try {
+      // 检查是否已绑定微信
+      if (row.wechatOpenid) {
+        message("该补货员已绑定微信,无需重新生成绑定码", { type: "warning" });
+        return;
+      }
+
+      const res = await generateBindingCode(row.id);
+      if (res.code === 200 && res.data?.bindingCode) {
+        const code = res.data.bindingCode;
+        bindingCodeData.bindingCode = code;
+
+        // 生成二维码(将绑定码编码到URL中,扫码后打开小程序)
+        try {
+          // 使用完整小程序路径:/pages/replenish/bind?code=xxx
+          const miniProgramPath = `/pages/replenish/bind?code=${code}`;
+          bindingCodeData.qrCodeDataUrl = await QRCode.toDataURL(miniProgramPath, {
+            width: 280,
+            margin: 2,
+            color: {
+              dark: "#1e293b",
+              light: "#ffffff"
+            }
+          });
+        } catch (qrErr) {
+          console.error("生成二维码失败:", qrErr);
+          bindingCodeData.qrCodeDataUrl = "";
+        }
+
+        // 刷新列表更新 wechatOpenid 状态
+        onSearch();
+      } else {
+        message(res.message || "生成绑定码失败", { type: "error" });
+      }
+    } catch (error) {
+      console.error("生成绑定码失败:", error);
+      message("生成绑定码失败", { type: "error" });
+    } finally {
+      generatingCode.value = false;
+    }
+  }
+
+  // 分割绑定码,每4位一组加空格显示
+  function formatBindingCode(code: string): string {
+    if (!code) return "";
+    return code.replace(/(.{4})/g, "$1 ").trim();
+  }
+
+  // 复制绑定码
+  function copyBindingCode() {
+    const code = bindingCodeData.bindingCode;
+    if (!code) return;
+    navigator.clipboard.writeText(code).then(() => {
+      message("绑定码已复制", { type: "success" });
+    }).catch(() => {
+      // 备用方式
+      const textarea = document.createElement("textarea");
+      textarea.value = code;
+      document.body.appendChild(textarea);
+      textarea.select();
+      document.execCommand("copy");
+      document.body.removeChild(textarea);
+      message("绑定码已复制", { type: "success" });
+    });
+  }
+
   // 分页
   function handleSizeChange(val: number) {
     pagination.pageSize = val;
@@ -481,6 +574,12 @@ export function useReplenisher(tableRef: Ref) {
     openDialog,
     handleDelete,
     openBindDialog,
+    openBindingCodeDialog,
+    bindingDialogVisible,
+    bindingCodeData,
+    generatingCode,
+    formatBindingCode,
+    copyBindingCode,
     bindDialogVisible,
     currentReplenisher,
     deviceLoading,

+ 1 - 0
haha-admin-web/src/views/replenisher/utils/types.ts

@@ -11,6 +11,7 @@ export interface ReplenisherFormItem {
   createTime?: string;
   statusLabel?: string;
   statusColor?: string;
+  wechatOpenid?: string;
 }
 
 // 搜索表单类型

+ 2 - 1
haha-admin-web/vite.config.ts

@@ -39,7 +39,8 @@ export default ({ mode }: ConfigEnv): UserConfigExport => {
     "/timed-discount",
     "/layer-templates",
     "/distribution",
-    "/refund"
+    "/refund",
+    "/replenishers"
   ];
   
   // 动态生成代理配置

+ 1 - 0
haha-admin/src/main/java/com/haha/admin/config/SaTokenConfig.java

@@ -16,6 +16,7 @@ public class SaTokenConfig implements WebMvcConfigurer {
 
     private static final List<String> EXCLUDE_PATHS = Arrays.asList(
         "/login/**",
+        "/replenisher/login/**",
         "/health/**",
         "/static/**",
         "/favicon.ico",

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

@@ -183,4 +183,22 @@ public class ReplenisherController {
         int total = dto.getReplenisherIds().size() * dto.getDeviceIds().size();
         return Result.success("批量绑定完成", java.util.Map.of("success", count, "total", total));
     }
+
+    /**
+     * 生成补货员绑定码
+     * 用于补货员扫码绑定微信
+     *
+     * @param id 补货员ID
+     * @return 绑定码
+     */
+    @RequirePermission("replenisher:update")
+    @Log(module = "补货员管理", operation = OperationType.OTHER, summary = "生成补货员绑定码")
+    @PostMapping("/{id}/binding-code")
+    public Result<java.util.Map<String, Object>> generateBindingCode(@PathVariable Long id) {
+        String bindingCode = replenisherService.generateBindingCode(id);
+        java.util.Map<String, Object> result = new java.util.HashMap<>();
+        result.put("bindingCode", bindingCode);
+        result.put("replenisherId", id.toString());
+        return Result.success("生成绑定码成功", result);
+    }
 }

+ 58 - 0
haha-admin/src/main/java/com/haha/admin/controller/ReplenisherLoginController.java

@@ -0,0 +1,58 @@
+package com.haha.admin.controller;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import com.haha.common.vo.LoginVO;
+import com.haha.common.vo.Result;
+import com.haha.entity.dto.ReplenisherBindDTO;
+import com.haha.entity.dto.WechatLoginDTO;
+import com.haha.service.ReplenisherService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 补货员登录控制器
+ * 提供补货员微信静默登录和扫码绑定接口
+ */
+@Slf4j
+@RestController
+@RequestMapping("/replenisher/login")
+@RequiredArgsConstructor
+@SaIgnore
+public class ReplenisherLoginController {
+
+    private final ReplenisherService replenisherService;
+
+    /**
+     * 补货员微信静默登录
+     * 仅限已绑定微信的补货员使用
+     *
+     * @param dto 微信登录参数
+     * @return 登录结果,包含token和用户信息
+     */
+    @PostMapping("/wechat")
+    public Result<LoginVO> wechatLogin(@RequestBody WechatLoginDTO dto) {
+        String code = dto.getCode();
+        log.info("[补货员登录] 微信静默登录请求 - code: {}",
+                code != null ? code.substring(0, Math.min(8, code.length())) + "..." : "null");
+        return replenisherService.loginByWechat(code);
+    }
+
+    /**
+     * 补货员扫码绑定微信
+     * 管理员创建补货员后生成绑定码,补货员通过此接口完成微信绑定
+     *
+     * @param dto 绑定参数(绑定码 + 微信登录凭证)
+     * @return 绑定+登录结果
+     */
+    @PostMapping("/bind")
+    public Result<LoginVO> bindWechat(@Valid @RequestBody ReplenisherBindDTO dto) {
+        log.info("[补货员登录] 扫码绑定微信请求 - bindingCode: {}",
+                dto.getBindingCode() != null ? dto.getBindingCode().substring(0, Math.min(8, dto.getBindingCode().length())) + "..." : "null");
+        return replenisherService.bindWechat(dto.getBindingCode(), dto.getCode());
+    }
+}

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

@@ -0,0 +1,238 @@
+package com.haha.admin.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.haha.common.exception.BusinessException;
+import com.haha.common.vo.Result;
+import com.haha.entity.Device;
+import com.haha.entity.DeviceInventory;
+import com.haha.entity.Replenisher;
+import com.haha.entity.dto.ReplenishDTO;
+import com.haha.service.DeviceInventoryService;
+import com.haha.service.DeviceService;
+import com.haha.service.ReplenisherService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 补货员操作控制器
+ * 提供补货员的设备查询、库存查看、补货操作等接口
+ */
+@Slf4j
+@RestController
+@RequestMapping("/replenisher")
+@RequiredArgsConstructor
+public class ReplenisherOperationController {
+
+    private final ReplenisherService replenisherService;
+    private final DeviceInventoryService deviceInventoryService;
+    private final DeviceService deviceService;
+
+    /**
+     * 获取当前补货员信息
+     */
+    @GetMapping("/my-info")
+    public Result<Replenisher> getMyInfo() {
+        // 验证当前登录用户是补货员
+        Replenisher replenisher = getCurrentReplenisher();
+        replenisher = replenisherService.getReplenisherDetail(replenisher.getId());
+        if (replenisher == null) {
+            return Result.error(404, "补货员不存在");
+        }
+        return Result.success("查询成功", replenisher);
+    }
+
+    /**
+     * 获取补货员绑定的设备列表(含库存概览)
+     */
+    @GetMapping("/device/list")
+    public Result<List<Map<String, Object>>> getDeviceList() {
+        Replenisher replenisher = getCurrentReplenisher();
+
+        // 获取补货员绑定的设备ID列表
+        List<String> deviceIds = replenisherService.getBoundDeviceIds(replenisher.getId());
+        if (deviceIds.isEmpty()) {
+            return Result.success("查询成功", new ArrayList<>());
+        }
+
+        // 查询设备详情
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (String deviceId : deviceIds) {
+            Device device = deviceService.getDeviceBySn(deviceId);
+            if (device == null) {
+                continue;
+            }
+
+            Map<String, Object> deviceInfo = new HashMap<>();
+            deviceInfo.put("deviceId", device.getDeviceId());
+            deviceInfo.put("name", device.getName());
+            deviceInfo.put("shopId", device.getShopId() != null ? device.getShopId().toString() : null);
+            deviceInfo.put("status", device.getStatus());
+            deviceInfo.put("statusLabel", getDeviceStatusLabel(device.getStatus()));
+            deviceInfo.put("address", device.getAddress());
+
+            // 查询该设备的库存概览
+            List<Map<String, Object>> inventoryList = deviceInventoryService.getInventoryWithProduct(deviceId);
+            int totalStock = 0;
+            int lowStockCount = 0;
+            int zeroStockCount = 0;
+            for (Map<String, Object> inv : inventoryList) {
+                Integer stock = inv.get("stock") != null ? ((Number) inv.get("stock")).intValue() : 0;
+                Integer warningThreshold = inv.get("warning_threshold") != null ? ((Number) inv.get("warning_threshold")).intValue() : 0;
+                totalStock += stock;
+                if (stock == 0) {
+                    zeroStockCount++;
+                } else if (stock <= warningThreshold) {
+                    lowStockCount++;
+                }
+            }
+            deviceInfo.put("totalStock", totalStock);
+            deviceInfo.put("lowStockCount", lowStockCount);
+            deviceInfo.put("zeroStockCount", zeroStockCount);
+            deviceInfo.put("productCount", inventoryList.size());
+            deviceInfo.put("inventoryList", inventoryList);
+
+            result.add(deviceInfo);
+        }
+
+        return Result.success("查询成功", result);
+    }
+
+    /**
+     * 获取设备库存详情
+     *
+     * @param deviceId 设备SN号
+     */
+    @GetMapping("/device/inventory/{deviceId}")
+    public Result<Map<String, Object>> getDeviceInventory(@PathVariable String deviceId) {
+        // 验证当前登录用户是补货员
+        Replenisher replenisher = getCurrentReplenisher();
+
+        // 验证该设备是否绑定到此补货员
+        List<String> boundDeviceIds = replenisherService.getBoundDeviceIds(replenisher.getId());
+        if (!boundDeviceIds.contains(deviceId)) {
+            return Result.error(403, "您无权查看此设备的库存");
+        }
+
+        // 查询设备信息
+        Device device = deviceService.getDeviceBySn(deviceId);
+        if (device == null) {
+            return Result.error(404, "设备不存在");
+        }
+
+        // 查询库存详情
+        List<Map<String, Object>> inventoryList = deviceInventoryService.getInventoryWithProduct(deviceId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("deviceId", device.getDeviceId());
+        result.put("name", device.getName());
+        result.put("address", device.getAddress());
+        result.put("inventoryList", inventoryList);
+
+        return Result.success("查询成功", result);
+    }
+
+    /**
+     * 执行补货操作
+     *
+     * @param dto 补货参数
+     */
+    @PostMapping("/stock/replenish")
+    public Result<Object> replenishStock(@Valid @RequestBody ReplenishDTO dto) {
+        Replenisher replenisher = getCurrentReplenisher();
+
+        // 验证该设备是否绑定到此补货员
+        List<String> boundDeviceIds = replenisherService.getBoundDeviceIds(replenisher.getId());
+        if (!boundDeviceIds.contains(dto.getDeviceId())) {
+            return Result.error(403, "您无权对此设备进行补货操作");
+        }
+
+        int successCount = 0;
+        List<Map<String, Object>> results = new ArrayList<>();
+
+        for (ReplenishDTO.ReplenishItem item : dto.getItems()) {
+            try {
+                DeviceInventory inventory = deviceInventoryService.increaseStock(
+                        dto.getDeviceId(),
+                        item.getProductId(),
+                        item.getProductCode(),
+                        item.getProductName(),
+                        item.getQuantity(),
+                        item.getShelfNum(),
+                        item.getPosition(),
+                        replenisher.getId(),
+                        replenisher.getName(),
+                        null
+                );
+
+                successCount++;
+                Map<String, Object> itemResult = new HashMap<>();
+                itemResult.put("productId", item.getProductId());
+                itemResult.put("productName", item.getProductName());
+                itemResult.put("quantity", item.getQuantity());
+                itemResult.put("afterStock", inventory.getStock());
+                itemResult.put("success", true);
+                results.add(itemResult);
+
+                log.info("[补货操作] 补货成功: deviceId={}, productId={}, quantity={}, afterStock={}",
+                        dto.getDeviceId(), item.getProductId(), item.getQuantity(), inventory.getStock());
+            } catch (Exception e) {
+                log.error("[补货操作] 补货失败: deviceId={}, productId={}, quantity={}, error={}",
+                        dto.getDeviceId(), item.getProductId(), item.getQuantity(), e.getMessage());
+                Map<String, Object> itemResult = new HashMap<>();
+                itemResult.put("productId", item.getProductId());
+                itemResult.put("productName", item.getProductName());
+                itemResult.put("quantity", item.getQuantity());
+                itemResult.put("success", false);
+                itemResult.put("error", e.getMessage());
+                results.add(itemResult);
+            }
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("total", dto.getItems().size());
+        result.put("success", successCount);
+        result.put("details", results);
+
+        return Result.success(String.format("补货完成,成功%d项,失败%d项", successCount, dto.getItems().size() - successCount), result);
+    }
+
+    /**
+     * 获取当前登录的补货员
+     */
+    private Replenisher getCurrentReplenisher() {
+        // 验证当前登录用户是否为补货员
+        String userType = StpUtil.getTokenSession().getString("userType");
+        if (!"REPLENISHER".equals(userType)) {
+            log.warn("非补货员用户尝试访问补货员接口");
+            throw new BusinessException(403, "无访问权限");
+        }
+
+        String loginId = StpUtil.getLoginIdAsString();
+        Replenisher replenisher = replenisherService.getById(loginId);
+        if (replenisher == null) {
+            throw new BusinessException(404, "补货员不存在");
+        }
+        return replenisher;
+    }
+
+    /**
+     * 获取设备状态标签
+     */
+    private String getDeviceStatusLabel(Integer status) {
+        if (status == null) return "未知";
+        switch (status) {
+            case 1: return "在线";
+            case 0: return "离线";
+            case 2: return "故障";
+            default: return "未知";
+        }
+    }
+}

+ 7 - 0
haha-admin/src/main/resources/application.yml

@@ -96,6 +96,13 @@ sa-token:
   # 是否输出操作日志
   is-log: true
 
+# 微信小程序配置
+wechat:
+  # 管理端小程序(haha-admin-mp)配置 - 用于补货员微信登录等
+  admin-miniapp:
+    app-id: wx_admin_mp_appid
+    secret: wx_admin_mp_secret
+
 # 哈哈零售 API 配置
 haha:
   api:

+ 8 - 0
haha-common/src/main/java/com/haha/common/constant/RedisConstants.java

@@ -72,4 +72,12 @@ public final class RedisConstants {
     /** 开门分布式锁键前缀 */
     public static final String LOCK_DOOR_KEY = "lock:door:%s";
     public static final String LOCK_DOOR_KEY_PREFIX = "lock:door:";
+
+    // ==================== 补货员绑定相关 ====================
+
+    /** 补货员绑定码Redis键前缀 */
+    public static final String REPLENISHER_BIND_CODE_KEY = "replenisher:bind:code:%s";
+
+    /** 补货员绑定码Redis键前缀(无占位符) */
+    public static final String REPLENISHER_BIND_CODE_KEY_PREFIX = "replenisher:bind:code:";
 }

+ 65 - 0
haha-entity/src/main/java/com/haha/entity/dto/ReplenishDTO.java

@@ -0,0 +1,65 @@
+package com.haha.entity.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 补货操作DTO
+ */
+@Data
+public class ReplenishDTO {
+
+    /**
+     * 设备SN号
+     */
+    @NotBlank(message = "设备ID不能为空")
+    private String deviceId;
+
+    /**
+     * 补货商品列表
+     */
+    @NotNull(message = "补货商品列表不能为空")
+    @Valid
+    private List<ReplenishItem> items;
+
+    @Data
+    public static class ReplenishItem {
+
+        /**
+         * 商品ID
+         */
+        @NotNull(message = "商品ID不能为空")
+        private Long productId;
+
+        /**
+         * 商品编码
+         */
+        private String productCode;
+
+        /**
+         * 商品名称
+         */
+        private String productName;
+
+        /**
+         * 补货数量
+         */
+        @Min(value = 1, message = "补货数量至少为1")
+        private Integer quantity;
+
+        /**
+         * 货架层号
+         */
+        private Integer shelfNum;
+
+        /**
+         * 货道位置
+         */
+        private String position;
+    }
+}

+ 13 - 4
haha-entity/src/main/java/com/haha/entity/dto/ReplenisherBindDTO.java

@@ -1,14 +1,23 @@
 package com.haha.entity.dto;
 
-import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.NotEmpty;
 import lombok.Data;
 
 /**
- * 补货员绑定DTO(通用,用于门店/设备绑定补货员)
+ * 补货员扫码绑定微信请求DTO
  */
 @Data
 public class ReplenisherBindDTO {
 
-    @NotNull(message = "补货员ID不能为空")
-    private Long replenisherId;
+    /**
+     * 绑定码(管理员生成的24位大写绑定码)
+     */
+    @NotEmpty(message = "绑定码不能为空")
+    private String bindingCode;
+
+    /**
+     * 微信登录凭证(通过wx.login获取的code)
+     */
+    @NotEmpty(message = "微信登录凭证不能为空")
+    private String code;
 }

+ 15 - 0
haha-entity/src/main/java/com/haha/entity/dto/WechatLoginDTO.java

@@ -0,0 +1,15 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+
+/**
+ * 微信静默登录DTO
+ */
+@Data
+public class WechatLoginDTO {
+
+    /**
+     * 微信登录凭证(通过wx.login获取的code)
+     */
+    private String code;
+}

+ 32 - 0
haha-mp/src/App.vue

@@ -9,8 +9,39 @@ import { handleUnauthorized, clearAuth } from './utils/auth';
 onLaunch((options: any) => {
   console.log("App Launch, options:", JSON.stringify(options));
   checkLoginStatus(options);
+  checkBindingCode(options);
 });
 
+/**
+ * 检测启动参数中的补货员绑定码
+ * 场景:通过管理后台生成的二维码进入小程序时,会携带 scene/bindingCode 参数
+ */
+const checkBindingCode = (options: any) => {
+  if (!options) return;
+
+  // 检查 scene 场景值(小程序码场景)
+  if (options.scene) {
+    const scene = decodeURIComponent(options.scene);
+    console.log('[App] 检测到场景值:', scene);
+    // 24位大写字母数字组合为绑定码格式
+    if (/^[A-Z0-9]{24}$/.test(scene)) {
+      console.log('[App] 场景值为补货员绑定码');
+      uni.navigateTo({
+        url: '/pages/replenish/bind?code=' + scene
+      });
+      return;
+    }
+  }
+
+  // 检查 query 参数中的绑定码
+  if (options.query && options.query.bindingCode) {
+    console.log('[App] 检测到补货员绑定码:', options.query.bindingCode);
+    uni.navigateTo({
+      url: '/pages/replenish/bind?code=' + encodeURIComponent(options.query.bindingCode)
+    });
+  }
+};
+
 /**
  * 应用显示
  */
@@ -39,6 +70,7 @@ onHide(() => {
 const LOGIN_WHITE_LIST = [
   'pages/login/login',
   'pages/products/products',
+  'pages/replenish/bind',
 ];
 
 /**

+ 32 - 0
haha-mp/src/api/replenish.ts

@@ -0,0 +1,32 @@
+/**
+ * 补货员相关API
+ */
+import { post, get } from '../utils/request';
+
+export interface LoginResponse {
+  token: string;
+  userInfo: any;
+}
+
+/**
+ * 补货员微信静默登录
+ * @param params { code: 微信登录凭证 }
+ */
+export const loginByWechat = (params: { code: string }): Promise<LoginResponse> => {
+  return post<LoginResponse>('/replenisher/login/wechat', params);
+};
+
+/**
+ * 补货员扫码绑定微信
+ * @param params { bindingCode: 绑定码, code: 微信登录凭证 }
+ */
+export const bindWechat = (params: { bindingCode: string; code: string }): Promise<LoginResponse> => {
+  return post<LoginResponse>('/replenisher/login/bind', params);
+};
+
+/**
+ * 获取当前补货员信息
+ */
+export const getMyInfo = (): Promise<any> => {
+  return get('/replenisher/my-info');
+};

+ 8 - 0
haha-mp/src/pages.json

@@ -120,6 +120,14 @@
 				"navigationBarBackgroundColor": "#FFD700",
 				"navigationBarTextStyle": "black"
 			}
+		},
+		{
+			"path": "pages/replenish/bind",
+			"style": {
+				"navigationBarTitleText": "上货员绑定",
+				"navigationBarBackgroundColor": "#FFD700",
+				"navigationBarTextStyle": "black"
+			}
 		}
 	],
 	"globalStyle": {

+ 565 - 0
haha-mp/src/pages/replenish/bind.vue

@@ -0,0 +1,565 @@
+<template>
+  <view class="container">
+    <!-- 顶部品牌区 -->
+    <view class="brand-section">
+      <view class="brand-icon">
+        <view class="brand-icon-inner">
+          <text class="brand-icon-text">HH</text>
+        </view>
+      </view>
+      <text class="brand-title">哈哈零售</text>
+      <text class="brand-subtitle">设备上货员·微信绑定</text>
+    </view>
+
+    <!-- 绑定卡片 -->
+    <view class="bind-card" v-if="!bound">
+      <!-- 绑定码卡片 -->
+      <view class="code-card">
+        <view class="code-header">
+          <text class="code-label">绑定码</text>
+          <text class="code-expire">24小时内有效</text>
+        </view>
+        <text class="code-value">{{ displayCode }}</text>
+      </view>
+
+      <!-- 绑定流程步骤 -->
+      <view class="steps">
+        <view class="step-item" :class="{ active: step === 1, done: step > 1 }">
+          <view class="step-circle">
+            <text class="step-number" v-if="step === 0">1</text>
+            <text class="step-check" v-else-if="step > 0">✓</text>
+          </view>
+          <view class="step-content">
+            <text class="step-title">获取绑定码</text>
+            <text class="step-desc">由管理员在后台生成</text>
+          </view>
+        </view>
+        <view class="step-line" :class="{ active: step >= 1 }"></view>
+        <view class="step-item" :class="{ active: step === 2, done: step > 2 }">
+          <view class="step-circle">
+            <text class="step-number" v-if="step <= 1">2</text>
+            <text class="step-check" v-else>✓</text>
+          </view>
+          <view class="step-content">
+            <text class="step-title">微信授权绑定</text>
+            <text class="step-desc">授权微信账号完成绑定</text>
+          </view>
+        </view>
+        <view class="step-line" :class="{ active: step >= 2 }"></view>
+        <view class="step-item" :class="{ active: step === 3 }">
+          <view class="step-circle">
+            <text class="step-number">3</text>
+          </view>
+          <view class="step-content">
+            <text class="step-title">绑定成功</text>
+            <text class="step-desc">进入上货员工作台</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 绑定按钮 -->
+      <button
+        class="bind-btn"
+        :disabled="bindLoading || !bindingCode"
+        :loading="bindLoading"
+        hover-class="bind-btn-hover"
+        @tap="handleBind"
+      >
+        <text v-if="!bindLoading && hasValidCode">绑定微信</text>
+        <text v-else-if="!hasValidCode">绑定码无效</text>
+        <text v-else>绑定中...</text>
+      </button>
+
+      <text class="back-link" @tap="goBack">返回首页</text>
+    </view>
+
+    <!-- 成功状态 -->
+    <view class="success-section" v-else>
+      <view class="success-icon">
+        <text class="success-check">✓</text>
+      </view>
+      <text class="success-title">绑定成功</text>
+      <text class="success-desc">即将进入上货员工作台...</text>
+      <view class="success-dots">
+        <view class="dot" v-for="i in 3" :key="i"></view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { bindWechat, loginByWechat } from '@/api/replenish';
+
+const bindingCode = ref('');
+const bindLoading = ref(false);
+const bound = ref(false);
+const step = ref(0);
+
+const hasValidCode = computed(() => {
+  return bindingCode.value && bindingCode.value.length >= 4;
+});
+
+const displayCode = computed(() => {
+  const code = bindingCode.value;
+  if (!code) return '---';
+  return code.replace(/(.{4})/g, '$1 ').trim();
+});
+
+onLoad((options: any) => {
+  console.log('[补货员绑定] onLoad options:', options);
+
+  // 从 scene 场景值或 query 参数获取绑定码
+  if (options.scene) {
+    const scene = decodeURIComponent(options.scene);
+    if (/^[A-Z0-9]{24}$/.test(scene)) {
+      bindingCode.value = scene;
+      step.value = 1;
+      console.log('[补货员绑定] 从场景值获取绑定码:', scene);
+      return;
+    }
+  }
+
+  if (options.code) {
+    bindingCode.value = options.code;
+    step.value = 1;
+    console.log('[补货员绑定] 从参数获取绑定码:', options.code);
+    return;
+  }
+
+  if (options.bindingCode) {
+    bindingCode.value = options.bindingCode;
+    step.value = 1;
+    console.log('[补货员绑定] 从参数获取绑定码:', options.bindingCode);
+    return;
+  }
+
+  // 无绑定码参数时提示
+  uni.showToast({ title: '缺少绑定码参数', icon: 'none' });
+});
+
+const goBack = () => {
+  uni.reLaunch({ url: '/pages/index/index' });
+};
+
+const handleBind = async () => {
+  if (!hasValidCode.value) {
+    uni.showToast({ title: '绑定码无效', icon: 'none' });
+    return;
+  }
+
+  bindLoading.value = true;
+  step.value = 2;
+
+  try {
+    // 1. 微信授权获取code
+    console.log('[补货员绑定] 开始微信授权...');
+    const loginRes = await new Promise<any>((resolve, reject) => {
+      uni.login({
+        provider: 'weixin',
+        success: (res) => {
+          console.log('[补货员绑定] 微信授权成功:', res);
+          resolve(res);
+        },
+        fail: (err) => {
+          console.error('[补货员绑定] 微信授权失败:', err);
+          reject(err);
+        }
+      });
+    });
+
+    if (!loginRes.code) {
+      uni.showToast({ title: '微信授权失败', icon: 'none' });
+      step.value = 1;
+      return;
+    }
+
+    // 2. 调用绑定接口
+    console.log('[补货员绑定] 开始调用绑定接口...');
+    const response = await bindWechat({
+      bindingCode: bindingCode.value.trim().toUpperCase(),
+      code: loginRes.code
+    });
+
+    // 3. 保存登录信息
+    if (response.token) {
+      uni.setStorageSync('accessToken', response.token);
+      uni.setStorageSync('haha-user-info', response.userInfo);
+    }
+
+    // 4. 显示成功状态
+    bound.value = true;
+    step.value = 3;
+
+    setTimeout(() => {
+      // 跳转到首页,补货员登录后在首页可能会有对应的入口
+      uni.reLaunch({ url: '/pages/index/index' });
+    }, 2000);
+  } catch (error: any) {
+    console.error('[补货员绑定] 绑定失败:', error);
+    step.value = 1;
+    // 错误已在 request.ts 中统一 toast
+  } finally {
+    bindLoading.value = false;
+  }
+};
+</script>
+
+<style lang="scss">
+.container {
+  min-height: 100vh;
+  background: $color-bg-secondary;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding-bottom: calc(80rpx + env(safe-area-inset-bottom));
+}
+
+/* ===== 品牌区域 ===== */
+.brand-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0 48rpx;
+  animation: slideDown 0.6s $ease-out;
+
+  .brand-icon {
+    width: 120rpx;
+    height: 120rpx;
+    background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
+    border-radius: $radius-xl;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-bottom: 24rpx;
+    box-shadow: $shadow-primary;
+
+    .brand-icon-inner {
+      .brand-icon-text {
+        font-size: 36rpx;
+        font-weight: 700;
+        color: #fff;
+        letter-spacing: 4rpx;
+      }
+    }
+  }
+
+  .brand-title {
+    font-size: 36rpx;
+    font-weight: 700;
+    color: $color-text-primary;
+    margin-bottom: 8rpx;
+  }
+
+  .brand-subtitle {
+    font-size: 26rpx;
+    color: $color-text-secondary;
+  }
+}
+
+/* ===== 绑定卡片 ===== */
+.bind-card {
+  width: 100%;
+  max-width: 600rpx;
+  padding: 0 32rpx;
+  animation: slideUp 0.6s $ease-out 0.2s both;
+}
+
+/* 绑定码展示 */
+.code-card {
+  background: $color-bg-primary;
+  border: 2rpx dashed $color-primary;
+  border-radius: $radius-lg;
+  padding: 32rpx;
+  text-align: center;
+  margin-bottom: 40rpx;
+  box-shadow: $shadow-sm;
+
+  .code-header {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 16rpx;
+    margin-bottom: 20rpx;
+
+    .code-label {
+      font-size: 24rpx;
+      color: $color-text-secondary;
+    }
+
+    .code-expire {
+      font-size: 22rpx;
+      color: $color-text-tertiary;
+      padding: 4rpx 12rpx;
+      background: $color-primary-bg;
+      border-radius: $radius-sm;
+    }
+  }
+
+  .code-value {
+    display: block;
+    font-size: 38rpx;
+    font-weight: 700;
+    color: $color-text-primary;
+    letter-spacing: 8rpx;
+    font-family: 'Courier New', Courier, monospace;
+  }
+}
+
+/* ===== 步骤流程 ===== */
+.steps {
+  padding: 0 16rpx;
+  margin-bottom: 48rpx;
+
+  .step-item {
+    display: flex;
+    align-items: flex-start;
+    padding: 16rpx 0;
+    opacity: 0.4;
+    transition: opacity $duration-normal $ease-out;
+
+    &.active {
+      opacity: 1;
+
+      .step-circle {
+        background: $color-primary;
+        border-color: $color-primary;
+      }
+
+      .step-title {
+        color: $color-text-primary;
+        font-weight: 600;
+      }
+    }
+
+    &.done {
+      opacity: 0.7;
+
+      .step-circle {
+        background: $color-success;
+        border-color: $color-success;
+      }
+
+      .step-title {
+        color: $color-text-secondary;
+      }
+    }
+
+    .step-circle {
+      width: 48rpx;
+      height: 48rpx;
+      border-radius: $radius-circle;
+      border: 2rpx solid $color-border;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+      margin-right: 20rpx;
+      transition: all $duration-normal $ease-out;
+
+      .step-number {
+        font-size: 24rpx;
+        font-weight: 600;
+        color: $color-text-tertiary;
+      }
+
+      .step-check {
+        font-size: 24rpx;
+        color: #fff;
+        font-weight: 700;
+      }
+    }
+
+    .step-content {
+      flex: 1;
+      padding-top: 6rpx;
+
+      .step-title {
+        font-size: 28rpx;
+        color: $color-text-tertiary;
+        margin-bottom: 4rpx;
+        transition: color $duration-normal $ease-out;
+      }
+
+      .step-desc {
+        font-size: 22rpx;
+        color: $color-text-tertiary;
+      }
+    }
+  }
+
+  .step-line {
+    width: 2rpx;
+    height: 40rpx;
+    background: $color-border;
+    margin-left: 23rpx;
+    transition: background $duration-normal $ease-out;
+
+    &.active {
+      background: $color-primary;
+    }
+  }
+}
+
+/* ===== 绑定按钮 ===== */
+.bind-btn {
+  width: 100%;
+  height: 100rpx;
+  background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
+  border-radius: 50rpx;
+  border: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: $shadow-primary;
+  transition: all $duration-fast $ease-out;
+
+  &::after {
+    border: none;
+  }
+
+  text {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: #1A1A1A;
+    letter-spacing: 4rpx;
+  }
+
+  &.bind-btn-hover {
+    opacity: 0.9;
+    transform: scale(0.98);
+  }
+
+  &[disabled] {
+    opacity: 0.5;
+    box-shadow: none;
+  }
+}
+
+.back-link {
+  display: block;
+  text-align: center;
+  margin-top: 32rpx;
+  font-size: 26rpx;
+  color: $color-text-tertiary;
+  padding: 16rpx;
+
+  &:active {
+    opacity: 0.6;
+  }
+}
+
+/* ===== 成功状态 ===== */
+.success-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 70vh;
+  animation: fadeIn 0.4s $ease-out;
+
+  .success-icon {
+    width: 160rpx;
+    height: 160rpx;
+    background: $color-primary-bg;
+    border-radius: $radius-circle;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-bottom: 32rpx;
+    animation: bounceIn 0.6s $bounce;
+
+    .success-check {
+      font-size: 72rpx;
+      color: $color-primary-dark;
+      font-weight: 700;
+    }
+  }
+
+  .success-title {
+    font-size: 40rpx;
+    font-weight: 700;
+    color: $color-text-primary;
+    margin-bottom: 12rpx;
+    animation: slideUp 0.4s $ease-out 0.3s both;
+  }
+
+  .success-desc {
+    font-size: 28rpx;
+    color: $color-text-secondary;
+    animation: slideUp 0.4s $ease-out 0.4s both;
+  }
+
+  .success-dots {
+    display: flex;
+    gap: 12rpx;
+    margin-top: 40rpx;
+
+    .dot {
+      width: 12rpx;
+      height: 12rpx;
+      border-radius: $radius-circle;
+      background: $color-primary;
+      animation: loadingDot 1.2s ease-in-out infinite;
+
+      &:nth-child(1) { animation-delay: 0s; }
+      &:nth-child(2) { animation-delay: 0.2s; }
+      &:nth-child(3) { animation-delay: 0.4s; }
+    }
+  }
+}
+
+/* ===== 动画 ===== */
+@keyframes slideDown {
+  from {
+    opacity: 0;
+    transform: translateY(-40rpx);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes slideUp {
+  from {
+    opacity: 0;
+    transform: translateY(40rpx);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes fadeIn {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+@keyframes bounceIn {
+  0% {
+    transform: scale(0);
+    opacity: 0;
+  }
+  50% {
+    transform: scale(1.1);
+  }
+  100% {
+    transform: scale(1);
+    opacity: 1;
+  }
+}
+
+@keyframes loadingDot {
+  0%, 80%, 100% {
+    transform: scale(0.6);
+    opacity: 0.4;
+  }
+  40% {
+    transform: scale(1);
+    opacity: 1;
+  }
+}
+</style>

+ 30 - 0
haha-service/src/main/java/com/haha/service/ReplenisherService.java

@@ -2,6 +2,8 @@ package com.haha.service;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.haha.common.vo.LoginVO;
+import com.haha.common.vo.Result;
 import com.haha.entity.Replenisher;
 import com.haha.entity.dto.ReplenisherCreateDTO;
 import com.haha.entity.dto.ReplenisherQueryDTO;
@@ -14,6 +16,34 @@ import java.util.List;
  */
 public interface ReplenisherService extends IService<Replenisher> {
 
+    /**
+     * 微信静默登录
+     * 通过 wx.login 获取的 code 换取 openid,登录已绑定微信的补货员
+     *
+     * @param code 微信登录凭证
+     * @return 登录结果,包含token和用户信息
+     */
+    Result<LoginVO> loginByWechat(String code);
+
+    /**
+     * 生成补货员绑定码
+     * 管理员在后端创建补货员后,生成一次性绑定码用于补货员扫码绑定微信
+     *
+     * @param replenisherId 补货员ID
+     * @return 绑定码字符串
+     */
+    String generateBindingCode(Long replenisherId);
+
+    /**
+     * 补货员扫码绑定微信
+     * 补货员通过小程序扫描绑定二维码或输入绑定码,完成微信OpenID绑定并自动登录
+     *
+     * @param bindingCode 绑定码
+     * @param wechatCode  微信登录凭证(wx.login获取的code)
+     * @return 登录结果,包含token和用户信息
+     */
+    Result<LoginVO> bindWechat(String bindingCode, String wechatCode);
+
     /**
      * 分页查询补货员
      *

+ 236 - 0
haha-service/src/main/java/com/haha/service/impl/ReplenisherServiceImpl.java

@@ -1,10 +1,15 @@
 package com.haha.service.impl;
 
+import cn.dev33.satoken.stp.StpUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.haha.common.constant.RedisConstants;
 import com.haha.common.exception.BusinessException;
+import com.haha.common.vo.LoginVO;
+import com.haha.common.vo.Result;
+import com.haha.common.vo.UserVO;
 import com.haha.entity.Replenisher;
 import com.haha.entity.ReplenisherDevice;
 import com.haha.entity.dto.ReplenisherCreateDTO;
@@ -15,13 +20,18 @@ import com.haha.mapper.ReplenisherMapper;
 import com.haha.service.ReplenisherService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestTemplate;
 
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 补货员服务实现类
@@ -33,6 +43,14 @@ public class ReplenisherServiceImpl extends ServiceImpl<ReplenisherMapper, Reple
 
     private final ReplenisherMapper replenisherMapper;
     private final ReplenisherDeviceMapper replenisherDeviceMapper;
+    private final RestTemplate restTemplate;
+    private final StringRedisTemplate stringRedisTemplate;
+
+    @Value("${wechat.admin-miniapp.app-id}")
+    private String adminMiniappAppId;
+
+    @Value("${wechat.admin-miniapp.secret}")
+    private String adminMiniappSecret;
 
     @Override
     public IPage<Replenisher> getPage(ReplenisherQueryDTO queryDTO) {
@@ -257,6 +275,224 @@ public class ReplenisherServiceImpl extends ServiceImpl<ReplenisherMapper, Reple
         return replenisherMapper.searchByKeyword(keyword);
     }
 
+    @Override
+    public Result<LoginVO> loginByWechat(String code) {
+        try {
+            log.info("[补货员] 微信静默登录 - code: {}",
+                    code != null ? code.substring(0, Math.min(8, code.length())) + "..." : "null");
+
+            // 1. 通过code获取openid
+            String openid = getWechatOpenid(code);
+            if (openid == null || openid.isEmpty()) {
+                log.warn("[补货员] 获取openid失败 - code: {}",
+                        code != null ? code.substring(0, Math.min(8, code.length())) + "..." : "null");
+                return Result.error(400, "获取用户标识失败,请重试");
+            }
+
+            // 2. 根据openid查找补货员(仅限已绑定微信的账号)
+            LambdaQueryWrapper<Replenisher> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(Replenisher::getWechatOpenid, openid);
+            Replenisher replenisher = this.getOne(wrapper);
+
+            if (replenisher == null) {
+                // 不允许自动注册:补货员必须先由后台创建并通过扫码绑定微信
+                log.warn("[补货员] 微信登录 - openid未绑定任何补货员账号: openid={}",
+                        openid.substring(0, Math.min(10, openid.length())) + "...");
+                return Result.error(403, "该微信未绑定补货员账号,请联系管理员获取绑定二维码");
+            }
+
+            // 3. 检查状态
+            if (replenisher.getStatus() == null || replenisher.getStatus() == 0) {
+                log.warn("[补货员] 账号已被禁用: id={}, openid={}", replenisher.getId(), openid);
+                return Result.error(403, "您的账号已被禁用,请联系管理员");
+            }
+
+            // 4. 生成token
+            StpUtil.login(replenisher.getId());
+            StpUtil.getTokenSession().set("userType", "REPLENISHER");
+            String token = StpUtil.getTokenValue();
+
+            log.info("[补货员] 微信登录成功: id={}, name={}", replenisher.getId(), replenisher.getName());
+
+            // 5. 构建返回结果
+            UserVO userVO = UserVO.builder()
+                    .id(replenisher.getId())
+                    .nickname(replenisher.getName())
+                    .avatar(replenisher.getAvatar())
+                    .phone(replenisher.getPhone())
+                    .build();
+
+            LoginVO loginVO = LoginVO.builder()
+                    .token(token)
+                    .userInfo(userVO)
+                    .build();
+
+            return Result.success("登录成功", loginVO);
+
+        } catch (Exception e) {
+            log.error("[补货员] 微信静默登录异常", e);
+            return Result.error(500, "登录失败:" + e.getMessage());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String generateBindingCode(Long replenisherId) {
+        // 1. 检查补货员是否存在
+        Replenisher replenisher = this.getById(replenisherId);
+        if (replenisher == null) {
+            throw new BusinessException(404, "补货员不存在");
+        }
+
+        // 2. 如果已经绑定了微信,不允许再次生成绑定码
+        if (StringUtils.hasText(replenisher.getWechatOpenid())) {
+            throw new BusinessException(400, "该补货员已绑定微信,无需重新绑定");
+        }
+
+        // 3. 生成UUID绑定码并存入Redis(24小时有效)
+        String bindingCode = UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase();
+        String redisKey = String.format(RedisConstants.REPLENISHER_BIND_CODE_KEY, bindingCode);
+        stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(replenisherId), 24, TimeUnit.HOURS);
+
+        log.info("[补货员] 生成绑定码: replenisherId={}, bindingCode={}", replenisherId, bindingCode);
+
+        return bindingCode;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Result<LoginVO> bindWechat(String bindingCode, String wechatCode) {
+        try {
+            log.info("[补货员] 扫码绑定微信 - bindingCode: {}",
+                    bindingCode != null ? bindingCode.substring(0, Math.min(8, bindingCode.length())) + "..." : "null");
+
+            // 1. 验证绑定码
+            String redisKey = String.format(RedisConstants.REPLENISHER_BIND_CODE_KEY, bindingCode);
+            String replenisherIdStr = stringRedisTemplate.opsForValue().get(redisKey);
+            if (replenisherIdStr == null || replenisherIdStr.isEmpty()) {
+                log.warn("[补货员] 绑定码无效或已过期: bindingCode={}", bindingCode);
+                return Result.error(400, "绑定码无效或已过期,请联系管理员重新生成");
+            }
+
+            Long replenisherId = Long.valueOf(replenisherIdStr);
+
+            // 2. 获取微信openid
+            String openid = getWechatOpenid(wechatCode);
+            if (openid == null || openid.isEmpty()) {
+                log.warn("[补货员] 绑定 - 获取openid失败");
+                return Result.error(400, "获取微信标识失败,请重试");
+            }
+
+            // 3. 检查openid是否已被其他补货员绑定
+            LambdaQueryWrapper<Replenisher> openidCheck = new LambdaQueryWrapper<>();
+            openidCheck.eq(Replenisher::getWechatOpenid, openid)
+                    .ne(Replenisher::getId, replenisherId);
+            if (this.count(openidCheck) > 0) {
+                log.warn("[补货员] 绑定 - openid已被其他补货员绑定: openid={}", openid);
+                return Result.error(400, "该微信已绑定其他补货员账号");
+            }
+
+            // 4. 检查补货员是否存在
+            Replenisher replenisher = this.getById(replenisherId);
+            if (replenisher == null) {
+                log.warn("[补货员] 绑定 - 补货员不存在: id={}", replenisherId);
+                return Result.error(404, "补货员不存在");
+            }
+
+            // 5. 如果已绑定微信且不是同一个,拒绝
+            if (StringUtils.hasText(replenisher.getWechatOpenid()) && !openid.equals(replenisher.getWechatOpenid())) {
+                log.warn("[补货员] 绑定 - 补货员已绑定其他微信: id={}", replenisherId);
+                return Result.error(400, "该补货员已绑定其他微信账号");
+            }
+
+            // 6. 绑定微信openid + 激活账号
+            replenisher.setWechatOpenid(openid);
+            replenisher.setStatus(1);
+            replenisher.setUpdateTime(LocalDateTime.now());
+            this.updateById(replenisher);
+
+            // 7. 删除已使用的绑定码
+            stringRedisTemplate.delete(redisKey);
+
+            log.info("[补货员] 绑定微信成功: id={}, name={}, openid={}",
+                    replenisherId, replenisher.getName(),
+                    openid.substring(0, Math.min(10, openid.length())) + "...");
+
+            // 8. 登录
+            StpUtil.login(replenisher.getId());
+            StpUtil.getTokenSession().set("userType", "REPLENISHER");
+            String token = StpUtil.getTokenValue();
+
+            UserVO userVO = UserVO.builder()
+                    .id(replenisher.getId())
+                    .nickname(replenisher.getName())
+                    .avatar(replenisher.getAvatar())
+                    .phone(replenisher.getPhone())
+                    .build();
+
+            LoginVO loginVO = LoginVO.builder()
+                    .token(token)
+                    .userInfo(userVO)
+                    .build();
+
+            return Result.success("绑定并登录成功", loginVO);
+
+        } catch (Exception e) {
+            log.error("[补货员] 扫码绑定微信异常", e);
+            return Result.error(500, "绑定失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 通过微信登录凭证code获取openid
+     */
+    private String getWechatOpenid(String code) {
+        try {
+            log.info("[微信API-管理端] 开始获取openid - appid: {}, code: {}",
+                    adminMiniappAppId,
+                    code != null ? code.substring(0, Math.min(10, code.length())) + "..." : "null");
+
+            String url = String.format(
+                    "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
+                    adminMiniappAppId, adminMiniappSecret, code
+            );
+
+            log.debug("[微信API-管理端] 请求URL: {}", url.replace(adminMiniappSecret, "***"));
+
+            String response = restTemplate.getForObject(url, String.class);
+            log.info("[微信API-管理端] 获取openid响应: {}", response);
+
+            if (response != null && !response.isEmpty()) {
+                try {
+                    com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
+                    java.util.Map<String, Object> result = objectMapper.readValue(response, java.util.Map.class);
+
+                    if (result.containsKey("errcode")) {
+                        Integer errcode = (Integer) result.get("errcode");
+                        String errmsg = (String) result.get("errmsg");
+                        log.error("[微信API-管理端] 获取openid失败 - errcode: {}, errmsg: {}", errcode, errmsg);
+                        return null;
+                    }
+
+                    if (result.containsKey("openid")) {
+                        String openid = (String) result.get("openid");
+                        log.info("[微信API-管理端] 获取openid成功 - openid: {}",
+                                openid != null ? openid.substring(0, Math.min(10, openid.length())) + "..." : "null");
+                        return openid;
+                    }
+                } catch (Exception parseEx) {
+                    log.error("[微信API-管理端] 解析响应JSON失败", parseEx);
+                }
+            }
+
+            log.error("[微信API-管理端] 获取openid失败 - response: {}", response);
+            return null;
+        } catch (Exception e) {
+            log.error("[微信API-管理端] 调用jscode2session异常", e);
+            return null;
+        }
+    }
+
     /**
      * 填充状态标签
      */