Explorar o código

feat: 小程序新增库存盘点功能

- 新增audit.vue盘点页面:展示商品清单(当前/标准库存),+/-修正,提交调用adjustStock
- 新增increaseStock/adjustStock API函数
- home.vue设备卡片新增「盘点」入口按钮
- pages.json注册audit路由

Co-Authored-By: Claude <noreply@anthropic.com>
skyline hai 1 día
pai
achega
0c9bc550a5

+ 29 - 1
haha-admin-mp/src/api/inventory.ts

@@ -2,7 +2,7 @@
  * 库存管理相关API
  */
 
-import { get } from '@/utils/request';
+import { get, post } from '@/utils/request';
 
 export interface InventoryQueryParams {
   page?: number;
@@ -68,3 +68,31 @@ export async function getInventoryLogs(params: any = {}): Promise<any> {
 export async function getStockRecords(params: any = {}): Promise<any> {
   return get('/inventory/records', params);
 }
+
+/**
+ * 增加库存(上货)
+ */
+export async function increaseStock(data: {
+  deviceId: string;
+  productId: number;
+  productCode: string;
+  productName: string;
+  quantity: number;
+  shelfNum?: number;
+  position?: string;
+  activityId?: string;
+}): Promise<any> {
+  return post('/inventory/increase', data);
+}
+
+/**
+ * 调整库存(盘点修正,直接设置库存为指定值)
+ */
+export async function adjustStock(data: {
+  deviceId: string;
+  productId: number;
+  newStock: number;
+  remark?: string;
+}): Promise<any> {
+  return post('/inventory/adjust', data);
+}

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

@@ -448,6 +448,15 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/replenish/audit",
+      "style": {
+        "navigationBarTitleText": "库存盘点",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "style": {
         "navigationBarBackgroundColor": "#ffffff",

+ 264 - 0
haha-admin-mp/src/pages/replenish/audit.vue

@@ -0,0 +1,264 @@
+<template>
+  <view class="page">
+    <NavBar title="库存盘点" showBack />
+
+    <template v-if="!loading">
+      <!-- 设备信息 -->
+      <view class="device-card">
+        <view class="device-icon"></view>
+        <view class="device-info">
+          <text class="device-name">{{ deviceId }}</text>
+          <text class="device-addr" v-if="deviceName">{{ deviceName }}</text>
+        </view>
+      </view>
+
+      <!-- 商品清单 -->
+      <view class="inventory-section">
+        <view class="section-head">
+          <text class="section-title">盘点清单</text>
+          <text class="section-count">{{ inventoryList.length }} 种</text>
+        </view>
+
+        <view class="empty-state" v-if="inventoryList.length === 0">
+          <view class="empty-icon-box"><view class="empty-doc-icon"></view></view>
+          <text class="empty-text">暂无商品库存数据</text>
+        </view>
+
+        <view class="inventory-card" v-for="(item, index) in inventoryList" :key="index">
+          <view class="item-top">
+            <image v-if="item.product_image" :src="item.product_image" class="item-img" mode="aspectFill" />
+            <view v-else class="item-img-placeholder"><text>无图</text></view>
+            <view class="item-info">
+              <text class="item-name">{{ item.product_name || item.productName || '未知商品' }}</text>
+              <text class="item-code" v-if="item.product_code || item.productCode">{{ item.product_code || item.productCode }}</text>
+            </view>
+            <view class="stock-badge" :class="getDiffClass(item, index)">
+              <text>{{ getDiffLabel(item, index) }}</text>
+            </view>
+          </view>
+
+          <view class="item-stock">
+            <view class="stock-col">
+              <text class="stock-label">标准库存</text>
+              <text class="stock-num">{{ item.standard_stock || item.standardStock || '-' }}</text>
+            </view>
+            <view class="stock-col">
+              <text class="stock-label">当前库存</text>
+              <text class="stock-num">{{ item.stock || 0 }}</text>
+            </view>
+          </view>
+
+          <view class="audit-row">
+            <text class="audit-label">修正为</text>
+            <view class="qty-control">
+              <view class="qty-btn" @click="decrease(index)"><text>-</text></view>
+              <input class="qty-input" type="number" v-model.number="auditItems[index].newStock" />
+              <view class="qty-btn" @click="increase(index)"><text>+</text></view>
+            </view>
+            <view class="quick-btn" @click="setToCurrentStock(item, index)"><text>不变</text></view>
+            <view class="quick-btn" @click="setToStandard(item, index)"><text>设标</text></view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 底部操作栏 -->
+      <view class="bottom-bar" v-if="inventoryList.length > 0">
+        <text class="total-label">修正 <text class="total-num">{{ changedCount }}</text> 项</text>
+        <view class="submit-btn" :class="{ disabled: submitting || changedCount === 0 }" @click="handleSubmit">
+          <view class="btn-spinner" v-if="submitting"></view>
+          <text>{{ submitting ? '提交中' : '提交盘点' }}</text>
+        </view>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getDeviceInventory, adjustStock } from '@/api/inventory';
+
+interface AuditInput {
+  productId: number | null;
+  productCode: string;
+  productName: string;
+  newStock: number;
+  originalStock: number;
+}
+
+const deviceId = ref('');
+const deviceName = ref('');
+const inventoryList = ref<any[]>([]);
+const auditItems = ref<AuditInput[]>([]);
+const loading = ref(true);
+const submitting = ref(false);
+
+const changedCount = computed(() =>
+  auditItems.value.filter(i => i.productId != null && i.newStock !== i.originalStock).length
+);
+
+function decrease(index: number) {
+  if (auditItems.value[index].newStock > 0) auditItems.value[index].newStock--;
+}
+function increase(index: number) {
+  if (auditItems.value[index].newStock < 9999) auditItems.value[index].newStock++;
+}
+function setToCurrentStock(item: any, index: number) {
+  auditItems.value[index].newStock = item.stock || 0;
+}
+function setToStandard(item: any, index: number) {
+  const std = item.standard_stock || item.standardStock || 0;
+  auditItems.value[index].newStock = std;
+}
+function getDiffLabel(item: any, index: number) {
+  if (!auditItems.value[index]) return '正常';
+  const cur = auditItems.value[index].newStock;
+  const std = item.standard_stock || item.standardStock || 0;
+  if (cur < std) return '偏少';
+  if (cur > std) return '偏多';
+  return '正常';
+}
+function getDiffClass(item: any, index: number) {
+  if (!auditItems.value[index]) return 'normal';
+  const cur = auditItems.value[index].newStock;
+  const std = item.standard_stock || item.standardStock || 0;
+  if (cur < std) return 'warning';
+  if (cur > std) return 'danger';
+  return 'normal';
+}
+
+async function handleSubmit() {
+  if (submitting.value || changedCount.value === 0) return;
+  const changes = auditItems.value.filter(i => i.productId != null && i.newStock !== i.originalStock);
+  if (!changes.length) { uni.showToast({ title: '无变更', icon: 'none' }); return; }
+
+  submitting.value = true;
+  let success = 0;
+  let fail = 0;
+  for (const item of changes) {
+    try {
+      await adjustStock({
+        deviceId: deviceId.value,
+        productId: item.productId!,
+        newStock: item.newStock,
+        remark: '小程序盘点修正'
+      });
+      success++;
+    } catch {
+      fail++;
+    }
+  }
+
+  if (fail === 0) {
+    uni.showToast({ title: `盘点完成,修正${success}项`, icon: 'success' });
+    setTimeout(() => uni.navigateBack(), 800);
+  } else {
+    uni.showToast({ title: `成功${success}项,失败${fail}项`, icon: 'none' });
+  }
+  submitting.value = false;
+}
+
+onMounted(async () => {
+  const pages = getCurrentPages();
+  const opts = (pages[pages.length - 1] as any)?.$page?.options || (pages[pages.length - 1] as any)?.options || {};
+  deviceId.value = opts.deviceId || '';
+  if (!deviceId.value) { uni.showToast({ title: '缺少设备ID', icon: 'none' }); setTimeout(() => uni.navigateBack(), 500); return; }
+
+  try {
+    const data = await getDeviceInventory(deviceId.value);
+    deviceName.value = data?.name || '';
+    const list = (data?.inventoryList || data || []) as any[];
+    // 兼容两种返回格式
+    if (Array.isArray(data)) {
+      inventoryList.value = data;
+    } else if (data?.inventoryList) {
+      inventoryList.value = data.inventoryList;
+      deviceName.value = data.name || '';
+    } else {
+      inventoryList.value = [];
+    }
+
+    auditItems.value = inventoryList.value.map((it: any) => ({
+      productId: it.product_id || it.productId || null,
+      productCode: it.product_code || it.productCode || '',
+      productName: it.product_name || it.productName || '',
+      newStock: it.stock || 0,
+      originalStock: it.stock || 0
+    }));
+  } catch (e: any) {
+    uni.showToast({ title: e.message || '加载失败', icon: 'none' });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+
+.device-card { display: flex; align-items: center; margin: 16rpx 24rpx; padding: $spacing-3; background: $bg-color-card; border-radius: $radius-md; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); }
+.device-icon { width: 48rpx; height: 48rpx; background: $success-color-bg; border-radius: $radius-base; margin-right: 16rpx; flex-shrink: 0; position: relative;
+  &::before { content: ''; position: absolute; top: 8rpx; left: 50%; transform: translateX(-50%); width: 20rpx; height: 16rpx; border: 2.5rpx solid $success-color; border-radius: 4rpx; }
+  &::after { content: ''; position: absolute; bottom: 4rpx; left: 50%; transform: translateX(-50%); width: 12rpx; height: 5rpx; background: $success-color; border-radius: 0 0 3rpx 3rpx; }
+}
+.device-info { flex: 1; min-width: 0; }
+.device-name { display: block; font-size: $font-size-md; font-weight: 600; color: $text-color-primary; }
+.device-addr { font-size: $font-size-sm; color: $text-color-muted; }
+
+.inventory-section { padding: 0 24rpx; flex: 1; height: 0; overflow-y: auto; }
+.section-head { display: flex; align-items: center; justify-content: space-between; padding: $spacing-3 0 $spacing-2; }
+.section-title { font-size: $font-size-md; font-weight: 700; color: $text-color-primary; }
+.section-count { font-size: $font-size-sm; color: $text-color-muted; }
+
+.empty-state { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
+.empty-icon-box { width: 96rpx; height: 96rpx; background: $bg-color-secondary; border-radius: $radius-lg; display: flex; align-items: center; justify-content: center; margin-bottom: $spacing-2; }
+.empty-doc-icon { width: 40rpx; height: 48rpx; border: 2.5rpx solid $text-color-placeholder; border-radius: 4rpx; position: relative;
+  &::after { content: ''; position: absolute; top: 14rpx; left: 8rpx; right: 8rpx; height: 2.5rpx; background: $text-color-placeholder; border-radius: 1rpx; box-shadow: 0 8rpx 0 $text-color-placeholder, 0 16rpx 0 $text-color-placeholder; }
+}
+.empty-text { font-size: $font-size-base; color: $text-color-muted; }
+
+.inventory-card { margin-bottom: 16rpx; padding: $spacing-3; background: $bg-color-card; border-radius: $radius-md; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); }
+.item-top { display: flex; align-items: center; margin-bottom: 12rpx; }
+.item-img { width: 72rpx; height: 72rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; margin-right: 12rpx; }
+.item-img-placeholder { width: 72rpx; height: 72rpx; border-radius: $radius-sm; background: $bg-color-page; flex-shrink: 0; margin-right: 12rpx; display: flex; align-items: center; justify-content: center;
+  text { font-size: 20rpx; color: $text-color-muted; }
+}
+.item-info { flex: 1; min-width: 0; overflow: hidden; }
+.item-name { display: block; font-size: $font-size-base; font-weight: 600; color: $text-color-primary; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.item-code { font-size: $font-size-xs; color: $text-color-muted; }
+
+.stock-badge { padding: 4rpx 12rpx; border-radius: $radius-sm; font-size: 22rpx; font-weight: 500; flex-shrink: 0; margin-left: 8rpx;
+  &.normal { background: $success-color-bg; color: $success-color; }
+  &.warning { background: $warning-color-bg; color: $warning-color; }
+  &.danger { background: $error-color-bg; color: $error-color; }
+}
+
+.item-stock { display: flex; gap: 16rpx; margin-bottom: 12rpx; padding: 12rpx 0; background: $bg-color-page; border-radius: $radius-sm; }
+.stock-col { flex: 1; display: flex; flex-direction: column; align-items: center; }
+.stock-label { font-size: 22rpx; color: $text-color-muted; margin-bottom: 4rpx; }
+.stock-num { font-size: $font-size-lg; font-weight: 700; color: $text-color-primary; }
+
+.audit-row { display: flex; align-items: center; background: $bg-color-page; border-radius: $radius-base; padding: 12rpx 16rpx; }
+.audit-label { font-size: $font-size-sm; color: $text-color-muted; margin-right: 12rpx; }
+.qty-control { display: flex; align-items: center; border: 1rpx solid $border-color; border-radius: $radius-sm; overflow: hidden; }
+.qty-btn { width: 52rpx; height: 52rpx; display: flex; align-items: center; justify-content: center; background: $bg-color-card;
+  text { font-size: 28rpx; color: $text-color-secondary; line-height: 1; }
+  &:active { background: $bg-color-secondary; }
+}
+.qty-input { width: 72rpx; height: 52rpx; text-align: center; font-size: $font-size-base; font-weight: 600; color: $text-color-primary; border-left: 1rpx solid $border-color; border-right: 1rpx solid $border-color; }
+.quick-btn { margin-left: 12rpx; padding: 10rpx 20rpx; background: $bg-color-secondary; border-radius: $radius-sm;
+  text { font-size: 22rpx; color: $text-color-secondary; }
+  &:active { opacity: 0.7; }
+}
+
+.bottom-bar { display: flex; align-items: center; gap: 16rpx; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; }
+.total-label { font-size: $font-size-sm; color: $text-color-muted; flex-shrink: 0; }
+.total-num { font-size: $font-size-xl; font-weight: 700; color: $primary-color-dark; margin: 0 4rpx; }
+.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 12rpx; height: 80rpx; background: $primary-color; border-radius: $radius-base;
+  text { font-size: $font-size-md; font-weight: 600; color: $text-color-primary; }
+  &:active { opacity: 0.8; }
+  &.disabled { opacity: 0.5; pointer-events: none; }
+}
+.btn-spinner { width: 28rpx; height: 28rpx; border: 3rpx solid rgba(30, 41, 59, 0.2); border-top-color: $text-color-primary; border-radius: 50%; animation: spin 0.7s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+</style>

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

@@ -46,6 +46,7 @@
         <view class="card-badge" :class="device.remainingStock < device.standardStock ? 'warn' : 'normal'">
           <text>{{ device.remainingStock < device.standardStock ? '待补货' : '正常' }}</text>
         </view>
+        <view class="card-audit" @click.stop="goToAudit(device)"><text>盘点</text></view>
       </view>
     </scroll-view>
   </view>
@@ -74,6 +75,10 @@ const goToOperation = (device: any) => {
   uni.navigateTo({ url: `/pages/replenish/operation?deviceId=${device.deviceId}` });
 };
 
+const goToAudit = (device: any) => {
+  uni.navigateTo({ url: `/pages/replenish/audit?deviceId=${device.deviceId}` });
+};
+
 const handleLogout = () => {
   uni.showModal({
     title: '退出登录',
@@ -142,4 +147,7 @@ onShow(async () => { await loadData(); });
   &.normal { background: $success-color-bg; color: $success-color; }
   &.warn { background: $warning-color-bg; color: $warning-color; }
 }
+.card-audit { margin-left: 12rpx; padding: 8rpx 16rpx; border-radius: $radius-sm; background: $info-color-bg; font-size: 22rpx; color: $info-color; font-weight: 500; flex-shrink: 0;
+  &:active { opacity: 0.7; }
+}
 </style>