1472 lines
46 KiB
Vue
1472 lines
46 KiB
Vue
<template>
|
||
<div class="app-container mobile-optimized">
|
||
|
||
<el-card v-if="!isSessionActive" class="main-card idle-card" shadow="never">
|
||
<div class="idle-content">
|
||
<div class="icon-area">
|
||
<el-icon :size="80" color="#409EFF"><VideoPlay /></el-icon>
|
||
</div>
|
||
<h2 class="app-title">库存盲盘系统</h2>
|
||
<p class="subtitle">单件自动确认,多件弹窗录入</p>
|
||
|
||
<div class="idle-actions">
|
||
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
|
||
开始新盘点
|
||
</el-button>
|
||
|
||
<el-button
|
||
v-if="serverDraftCount > 0 && userStore.hasPermission('inventory_stocktake:operation')"
|
||
type="warning"
|
||
plain
|
||
size="large"
|
||
class="action-btn-full"
|
||
@click="resumeSession"
|
||
:loading="btnLoading"
|
||
>
|
||
继续上次盘点 <span class="sub-text">({{ serverDraftCount }}项)</span>
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="safe-tip">
|
||
<el-icon><Cloudy /></el-icon>
|
||
<span>数据实时同步,支持断点续传</span>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card v-else class="main-card active-card" shadow="never" v-loading="loading">
|
||
<template #header>
|
||
<div class="header-row">
|
||
<div class="title-box">
|
||
<span class="title-text">📷 盲盘作业中</span>
|
||
<el-tag v-if="syncStatus === 'success'" type="success" size="small" effect="dark" round>已同步</el-tag>
|
||
<el-tag v-else-if="syncStatus === 'syncing'" type="warning" size="small" effect="dark" round>同步中...</el-tag>
|
||
<el-tag v-else type="danger" size="small" effect="dark" round>同步失败</el-tag>
|
||
</div>
|
||
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="info" text bg size="small" @click="pauseSession" :icon="VideoPause">
|
||
暂停
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="scan-section">
|
||
<div v-if="hasPermission" class="camera-placeholder" @click="openFullscreenScanner">
|
||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||
<span class="text">点击开启全屏扫码</span>
|
||
</div>
|
||
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||
<span class="text">无扫码权限</span>
|
||
</div>
|
||
|
||
<div class="input-box">
|
||
<el-input
|
||
v-model="barcodeInput"
|
||
placeholder="扫描或输入条码回车"
|
||
@keyup.enter="handleManualInput"
|
||
clearable
|
||
ref="barcodeRef"
|
||
size="large"
|
||
:disabled="!hasPermission"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><EditPen /></el-icon>
|
||
</template>
|
||
<template #append>
|
||
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-dashboard" @click="openInventoryList">
|
||
<div class="stat-card">
|
||
<div class="stat-val">{{ stats.total }}</div>
|
||
<div class="stat-label">总品项</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="stat-val">{{ stats.scanned }}</div>
|
||
<div class="stat-label">已盘</div>
|
||
</div>
|
||
<div class="stat-card warning">
|
||
<div class="stat-val">{{ stats.total - stats.scanned }}</div>
|
||
<div class="stat-label">未盘</div>
|
||
</div>
|
||
<div class="stat-arrow"><el-icon><ArrowRight /></el-icon></div>
|
||
</div>
|
||
|
||
<div class="main-actions">
|
||
<el-row :gutter="10">
|
||
<el-col :span="8">
|
||
<el-button type="success" size="large" class="w-100 action-btn" @click="exportToExcel" :icon="Download">
|
||
导出Excel
|
||
</el-button>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-button type="primary" plain size="large" class="w-100 action-btn" @click="openInventoryList" :icon="Search">
|
||
盘点明细
|
||
</el-button>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" size="large" class="w-100 action-btn" @click="handleGenerateMissing" :icon="Checked">
|
||
结束盘点并生成差异
|
||
</el-button>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- ★ 新增: 防呆确认弹窗 -->
|
||
<el-dialog
|
||
v-model="showConfirmDialog"
|
||
title="⚠️ 确认清除盘点数据"
|
||
width="400"
|
||
:close-on-click-modal="false"
|
||
:close-on-press-escape="false"
|
||
show-close
|
||
align-center
|
||
>
|
||
<div class="confirm-content">
|
||
<el-icon :size="48" color="#E6A23C"><WarningFilled /></el-icon>
|
||
<p class="confirm-text">存在未完成记录,开始新盘点将清除它们,确定吗?</p>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="cancelConfirm">取消</el-button>
|
||
<el-button
|
||
type="danger"
|
||
:disabled="countdown > 0"
|
||
@click="confirmClear"
|
||
>
|
||
{{ countdown > 0 ? `确认清除 (${countdown}s)` : '确认清除' }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="showQtyDialog"
|
||
title="🔢 录入实盘数量"
|
||
width="90%"
|
||
align-center
|
||
:close-on-click-modal="false"
|
||
destroy-on-close
|
||
class="qty-dialog"
|
||
>
|
||
<div v-if="currentItem" class="qty-content">
|
||
<div class="item-info">
|
||
<div class="info-row">
|
||
<span class="label">名称:</span>
|
||
<span class="value">{{ currentItem.name }}</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="label">规格:</span>
|
||
<span class="value">{{ currentItem.standard || '-' }}</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="label">SKU:</span>
|
||
<span class="value">{{ currentItem.sku }}</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="label">批号:</span>
|
||
<span class="value">{{ currentItem.batch_no || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-divider><el-icon><Edit /></el-icon> {{ currentItem.scanned ? '修改实盘数' : '录入实盘数' }}</el-divider>
|
||
|
||
<div class="input-area">
|
||
<el-input-number
|
||
v-model="inputQty"
|
||
:min="0"
|
||
:precision="0"
|
||
size="large"
|
||
style="width: 100%"
|
||
ref="qtyInputRef"
|
||
placeholder="请输入实际点数"
|
||
class="large-control-input"
|
||
/>
|
||
<p class="unit-text">单位: {{ currentItem.unit || '个' }}</p>
|
||
</div>
|
||
|
||
<!-- ★ 新增: 备注输入框 -->
|
||
<div class="remark-area" style="margin-top: 15px;">
|
||
<el-form-item label="备注:" label-width="60px">
|
||
<el-input
|
||
v-model="inputRemark"
|
||
placeholder="选填,可填写差异原因说明"
|
||
type="textarea"
|
||
:rows="2"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="showQtyDialog = false">取消</el-button>
|
||
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" @click="handleManualConfirm" size="large">确认数量</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-drawer
|
||
v-model="showList"
|
||
title="📦 盘点清单"
|
||
direction="btt"
|
||
size="100%"
|
||
destroy-on-close
|
||
class="inventory-drawer"
|
||
>
|
||
<div class="drawer-layout" v-loading="listLoading">
|
||
<!-- 搜索和状态筛选 -->
|
||
<div class="search-bar" style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px;">
|
||
<el-input v-model="listKeyword" placeholder="搜索 SKU/名称..." :prefix-icon="Search" clearable @keyup.enter="handleListSearch" @clear="handleListSearch" style="width: 240px" />
|
||
<el-button type="primary" @click="handleListSearch">搜索</el-button>
|
||
<el-radio-group v-model="listStatusFilter" @change="handleListSearch" size="small">
|
||
<el-radio-button value="all">全部</el-radio-button>
|
||
<el-radio-button value="counted">已盘</el-radio-button>
|
||
<el-radio-button value="uncounted">未盘</el-radio-button>
|
||
</el-radio-group>
|
||
<span style="margin-left: auto; color: #909399; font-size: 13px;">
|
||
总品项: {{ stats.total }} | 已盘: {{ stats.scanned }} | 未盘: {{ stats.total - stats.scanned }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<el-table
|
||
:data="filteredListData"
|
||
height="100%"
|
||
stripe
|
||
border
|
||
row-key="id"
|
||
style="width: 100%"
|
||
>
|
||
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
||
<el-table-column prop="material_name" label="名称" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="spec_model" label="规格" width="120" show-overflow-tooltip />
|
||
<!-- 盲盘:隐藏账面数和差异列 -->
|
||
<!-- <el-table-column prop="stock_qty" label="账面数" width="80" align="center" /> -->
|
||
<el-table-column label="实盘数" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-input-number
|
||
v-model="row.quantity"
|
||
:min="0"
|
||
:step="1"
|
||
size="small"
|
||
controls-position="right"
|
||
@change="(val) => handleQuantityChange(row, val)"
|
||
:disabled="!userStore.hasPermission('inventory_stocktake:operation')"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<!-- <el-table-column label="差异" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<span :style="{ color: row.diff_qty > 0 ? '#67C23A' : row.diff_qty < 0 ? '#F56C6C' : '#909399' }">
|
||
{{ row.diff_qty > 0 ? '+' : '' }}{{ row.diff_qty || 0 }}
|
||
</span>
|
||
</template>
|
||
</el-table-column> -->
|
||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" size="small" @blur="handleRemarkChange(row)" />
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div class="drawer-footer" style="display: flex; justify-content: flex-end; padding: 10px;">
|
||
<el-pagination
|
||
v-model:current-page="listPage"
|
||
v-model:page-size="listLimit"
|
||
:page-sizes="[20, 50, 100, 200]"
|
||
:total="listTotalFiltered"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
@size-change="handleListLimitChange"
|
||
@current-change="handleListPageChange"
|
||
/>
|
||
</div>
|
||
|
||
<div class="drawer-footer">
|
||
<el-button @click="showList = false" style="width: 100%">关闭列表</el-button>
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
|
||
<!-- 全屏扫码 Overlay -->
|
||
<div v-if="showCamera" class="fullscreen-scanner-overlay">
|
||
<div class="scanner-header">
|
||
<el-button circle icon="Close" @click="closeScanner" class="close-btn" />
|
||
<span class="scanner-title">扫码模式</span>
|
||
<div class="scanner-placeholder"></div>
|
||
</div>
|
||
|
||
<div class="scanner-body">
|
||
<QrScanner @decode="onScanSuccess" />
|
||
</div>
|
||
|
||
<div class="scanner-footer">
|
||
<p>请将条码/二维码放入镜头范围</p>
|
||
<p v-if="stats.scanned > 0" class="current-count">已盘点: {{ stats.scanned }} 项</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ★ 新增: 盘点差异审核对话框 -->
|
||
<el-dialog
|
||
v-model="showVarianceDialog"
|
||
title="📋 盘点差异审核"
|
||
width="95%"
|
||
align-center
|
||
:close-on-click-modal="false"
|
||
destroy-on-close
|
||
class="variance-dialog"
|
||
>
|
||
<div v-loading="varianceLoading">
|
||
<el-alert
|
||
title="差异审核说明"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 15px;"
|
||
>
|
||
<template #default>
|
||
以下列表显示所有已结束盘点但尚未平账的差异记录。请核实后进行后续处理。
|
||
</template>
|
||
</el-alert>
|
||
|
||
<!-- ★ 新增: 差异列表搜索 -->
|
||
<el-input
|
||
v-model="searchSku"
|
||
placeholder="输入 SKU/条码/名称快速定位"
|
||
clearable
|
||
style="width: 250px; margin-bottom: 15px;"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
|
||
<el-table
|
||
:data="filteredVarianceList"
|
||
height="500"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
>
|
||
<el-table-column prop="uuid" label="SKU/条码" width="140" show-overflow-tooltip />
|
||
<el-table-column prop="stock_name" label="物品名称" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="stock_spec" label="规格型号" width="120" show-overflow-tooltip />
|
||
<el-table-column prop="stock_location" label="库位" width="100" />
|
||
<el-table-column label="账面库存" width="80" align="center">
|
||
<template #default="scope">
|
||
{{ scope.row.stock_qty }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="实盘数量" width="80" align="center">
|
||
<template #default="scope">
|
||
{{ scope.row.quantity }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="差异" width="100" align="center">
|
||
<template #default="scope">
|
||
<el-tag
|
||
:type="scope.row.diff_qty > 0 ? 'success' : 'danger'"
|
||
size="large"
|
||
effect="dark"
|
||
>
|
||
{{ scope.row.diff_qty > 0 ? '+' : '' }}{{ scope.row.diff_qty }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="差异类型" width="80" align="center">
|
||
<template #default="scope">
|
||
<el-tag :type="scope.row.diff_qty > 0 ? 'success' : 'danger'">
|
||
{{ scope.row.diff_qty > 0 ? '盘盈' : '盘亏' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="80" align="center">
|
||
<template #default="scope">
|
||
<el-tag v-if="scope.row.is_processed" type="info">已处理</el-tag>
|
||
<el-tag v-else type="warning">待审核</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-empty v-if="filteredVarianceList.length === 0" :description="searchSku ? '未找到匹配的差异记录' : '暂无待审核的差异记录'" />
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<!-- ★ 后悔药:返回继续扫码 -->
|
||
<el-button type="primary" plain @click="returnToScan">
|
||
🔄 返回继续扫码
|
||
</el-button>
|
||
<el-button @click="showVarianceDialog = false">关闭</el-button>
|
||
<el-button type="success" @click="exportToExcel" :icon="Download">导出差异报告</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||
import { getStockList, getAllStocktakeItems, updateStocktakeQuantity } from '@/api/inbound/stock'
|
||
import QrScanner from '@/components/QrScanner/index.vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
||
import request from '@/utils/request'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
const userStore = useUserStore()
|
||
const currentUser = userStore.username || 'admin'
|
||
|
||
interface StockItem {
|
||
id: number
|
||
name: string
|
||
standard: string
|
||
sku: string
|
||
batch_no: string
|
||
serial_number?: string
|
||
uuid: string
|
||
bar_code: string
|
||
qty_stock: number
|
||
qty_actual: number
|
||
scanned: boolean
|
||
uniqueKey: string
|
||
unit?: string
|
||
type?: string
|
||
category?: string
|
||
price?: number
|
||
source_table?: string
|
||
stock_id?: number
|
||
[key: string]: any
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const btnLoading = ref(false)
|
||
const printing = ref(false)
|
||
const isSessionActive = ref(false)
|
||
const serverDraftCount = ref(0)
|
||
const syncStatus = ref<'success' | 'syncing' | 'failed'>('success')
|
||
|
||
// 新增:扫码相关状态
|
||
const showCamera = ref(false)
|
||
const barcodeInput = ref('')
|
||
const barcodeRef = ref()
|
||
const hasPermission = userStore.hasPermission('inventory_stocktake:operation')
|
||
|
||
const showList = ref(false)
|
||
const showFinishDialog = ref(false)
|
||
const showQtyDialog = ref(false)
|
||
|
||
// ★ 新增: 防呆确认弹窗
|
||
const showConfirmDialog = ref(false)
|
||
|
||
// ★ 新增: 盘点开始防呆倒计时
|
||
const countdown = ref(0)
|
||
let countdownTimer: any = null
|
||
|
||
// ★ 新增: 差异审核对话框
|
||
const showVarianceDialog = ref(false)
|
||
|
||
// ★ 新增: 差异列表搜索
|
||
const searchSku = ref('')
|
||
|
||
// ★ 新增: 盘点清单弹窗分页
|
||
const listPage = ref(1)
|
||
const listLimit = ref(20)
|
||
const listTotal = ref(0)
|
||
const listKeyword = ref('')
|
||
const listLoading = ref(false)
|
||
const listData = ref<any[]>([])
|
||
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
|
||
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
|
||
const listTotalFiltered = ref(0) // 过滤后的总数
|
||
|
||
// ★ 新增: 会话ID
|
||
const currentSessionId = ref<string>('')
|
||
|
||
// 获取应盘物资清单(盘点基数)
|
||
const fetchAllStockItems = async () => {
|
||
try {
|
||
const res: any = await getAllStocktakeItems()
|
||
if (res && res.code === 200) {
|
||
allStockItems.value = res.data.items || []
|
||
}
|
||
} catch (e) {
|
||
console.error('获取应盘物资清单失败', e)
|
||
}
|
||
}
|
||
|
||
// 过滤后的列表数据(直接使用已过滤的 listData)
|
||
const filteredListData = computed(() => listData.value)
|
||
|
||
// 统计信息:从全量应盘物资中计算
|
||
const stats = computed(() => {
|
||
// 已盘点数量:从已扫描的记录中获取
|
||
const countedItems = new Set()
|
||
listData.value.forEach(item => {
|
||
if (item.stock_id && item.source_table) {
|
||
countedItems.add(`${item.source_table}-${item.stock_id}`)
|
||
}
|
||
})
|
||
|
||
// 全量应盘物资中已盘的数量
|
||
let scanned = 0
|
||
allStockItems.value.forEach(item => {
|
||
const key = `${item.source_table}-${item.id}`
|
||
if (countedItems.has(key)) {
|
||
scanned++
|
||
}
|
||
})
|
||
|
||
// 如果 allStockItems 为空,回退到旧的统计逻辑
|
||
if (allStockItems.value.length === 0) {
|
||
const total = listData.value.length
|
||
const scannedCount = listData.value.filter(i => i.quantity > 0).length
|
||
return {
|
||
total,
|
||
scanned: scannedCount,
|
||
varianceItems: 0
|
||
}
|
||
}
|
||
|
||
return {
|
||
total: allStockItems.value.length,
|
||
scanned: scanned,
|
||
varianceItems: 0
|
||
}
|
||
})
|
||
|
||
const varianceLoading = ref(false)
|
||
|
||
const currentItem = ref<StockItem | null>(null)
|
||
const inputQty = ref<number | undefined>(undefined)
|
||
const inputRemark = ref('')
|
||
const qtyInputRef = ref()
|
||
|
||
const api = {
|
||
getDrafts: (sessionId?: string) => request({
|
||
url: '/v1/inbound/stock/draft/list',
|
||
method: 'get',
|
||
params: { session_id: sessionId }
|
||
}),
|
||
addDraft: (data: any) => request({
|
||
url: '/v1/inbound/stock/draft/add',
|
||
method: 'post',
|
||
data: { ...data, session_id: currentSessionId.value }
|
||
}),
|
||
// ★ 新增: 开始新会话
|
||
startNewSession: () => request({
|
||
url: '/v1/inbound/stock/draft/start-new',
|
||
method: 'post',
|
||
data: {}
|
||
}),
|
||
// ★ 新增: 结束盘点
|
||
finishStocktake: () => request({
|
||
url: '/v1/inbound/stock/finish',
|
||
method: 'post',
|
||
data: { session_id: currentSessionId.value }
|
||
}),
|
||
// ★ 新增: 获取差异报告
|
||
getVarianceReport: () => request({
|
||
url: '/v1/inbound/stock/variance-report',
|
||
method: 'get',
|
||
params: {}
|
||
}),
|
||
// ★ 保留清除功能(用于兼容性)
|
||
clearDraft: () => request({
|
||
url: '/v1/inbound/stock/draft/clear',
|
||
method: 'post',
|
||
data: {}
|
||
})
|
||
}
|
||
|
||
const typeToSourceTable = (type: string): string => {
|
||
switch (type) {
|
||
case 'material': return 'stock_buy'
|
||
case 'semi': return 'stock_semi'
|
||
case 'product': return 'stock_product'
|
||
default: return ''
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await checkServerDraft()
|
||
})
|
||
|
||
|
||
const checkServerDraft = async () => {
|
||
try {
|
||
// 获取草稿数量(后端已移除 is_finished 字段,直接返回所有记录)
|
||
const res: any = await request({
|
||
url: '/v1/inbound/stock/draft/list',
|
||
method: 'get',
|
||
params: { page: 1, limit: 1 } // 只获取一条记录来获取总数
|
||
})
|
||
// 后端返回格式已改为 { items: [], total: number }
|
||
serverDraftCount.value = (res && res.total) || 0
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ★ 重写: 开始新盘点 - 使用新 API
|
||
const startNewSession = async () => {
|
||
// ★ 新增: 防呆确认弹窗
|
||
if (serverDraftCount.value > 0) {
|
||
showConfirmDialog.value = true
|
||
countdown.value = 10
|
||
if (countdownTimer) clearInterval(countdownTimer)
|
||
countdownTimer = setInterval(() => {
|
||
countdown.value--
|
||
if (countdown.value <= 0) {
|
||
clearInterval(countdownTimer!)
|
||
countdownTimer = null
|
||
}
|
||
}, 1000)
|
||
return
|
||
}
|
||
await doStartNewSession()
|
||
}
|
||
|
||
const doStartNewSession = async () => {
|
||
showConfirmDialog.value = false
|
||
btnLoading.value = true
|
||
try {
|
||
// 调用新 API 开始新会话
|
||
const res: any = await api.startNewSession()
|
||
currentSessionId.value = res.session_id || ''
|
||
isSessionActive.value = true
|
||
// ★ 标记当前阶段为 scanning(扫码中)
|
||
localStorage.setItem('stocktake_phase', 'scanning')
|
||
ElMessage.success('新盘点会话已开始')
|
||
} catch (e: any) {
|
||
if (e !== 'cancel') {
|
||
ElMessage.error(e?.message || '操作失败')
|
||
}
|
||
} finally { btnLoading.value = false }
|
||
}
|
||
|
||
const confirmClear = () => {
|
||
if (countdown.value > 0) return
|
||
doStartNewSession()
|
||
}
|
||
|
||
const cancelConfirm = () => {
|
||
showConfirmDialog.value = false
|
||
if (countdownTimer) {
|
||
clearInterval(countdownTimer)
|
||
countdownTimer = null
|
||
}
|
||
countdown.value = 0
|
||
}
|
||
|
||
// ★ 重写: 继续上次盘点 - 恢复扫码作业
|
||
const resumeSession = async () => {
|
||
btnLoading.value = true
|
||
try {
|
||
// 获取最新的会话(后端已移除 is_finished 字段)
|
||
// 后端返回格式已改为 { items: [], total: number }
|
||
const res: any = await request({
|
||
url: '/v1/inbound/stock/draft/list',
|
||
method: 'get',
|
||
params: { page: 1, limit: 10000 } // 获取足够多的数据
|
||
})
|
||
|
||
const drafts = res && res.items ? res.items : []
|
||
|
||
if (!drafts || drafts.length === 0) {
|
||
ElMessage.warning('没有找到未完成的盘点记录')
|
||
return
|
||
}
|
||
|
||
// 恢复已扫描的数据 - 简化为直接激活会话
|
||
// 从草稿中获取 session_id
|
||
const sessionIds = [...new Set(drafts.map((d: any) => d.session_id))]
|
||
currentSessionId.value = sessionIds[0]
|
||
|
||
// 直接恢复会话状态
|
||
isSessionActive.value = true
|
||
|
||
// ★ 智能路由:根据本地记忆的阶段决定下一步
|
||
const phase = localStorage.getItem('stocktake_phase')
|
||
if (phase === 'review') {
|
||
// 如果记忆中已经是核对阶段,直接打开差异弹窗
|
||
await openVarianceDialog()
|
||
ElMessage.info('已恢复差异审核')
|
||
} else {
|
||
// 默认打开扫码镜头
|
||
ElMessage.success('已恢复扫码,继续盘点')
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error('恢复失败')
|
||
} finally { btnLoading.value = false }
|
||
}
|
||
|
||
const pauseSession = () => {
|
||
isSessionActive.value = false
|
||
checkServerDraft()
|
||
ElMessage.success('进度已保存')
|
||
}
|
||
|
||
// ★ 后悔药:从差异面板返回继续扫码
|
||
const returnToScan = () => {
|
||
showVarianceDialog.value = false
|
||
isSessionActive.value = true
|
||
// ★ 标记当前阶段为 scanning(扫码中)
|
||
localStorage.setItem('stocktake_phase', 'scanning')
|
||
ElMessage.info('继续扫码,发现漏扫的物料')
|
||
}
|
||
|
||
// ★★★ 核心修改:扫码成功后实时查询后端匹配 ★★★
|
||
const onScanSuccess = async (code: string) => {
|
||
if (!code || loading.value) return
|
||
const trimCode = code.trim()
|
||
|
||
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
|
||
ElMessage.warning(`识别到异常字符:${trimCode}`)
|
||
return
|
||
}
|
||
|
||
if (trimCode.length < 3) {
|
||
ElMessage.warning('扫描结果过短,请对准重试')
|
||
return
|
||
}
|
||
|
||
// 实时查询后端匹配
|
||
loading.value = true
|
||
try {
|
||
const res: any = await getStockList({
|
||
page: 1,
|
||
pageSize: 10,
|
||
keyword: trimCode
|
||
})
|
||
|
||
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
|
||
ElMessage.error(`未找到该物料库存: ${trimCode}`)
|
||
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
|
||
return
|
||
}
|
||
|
||
// 查找匹配的物料
|
||
const foundItem = res.data.list.find((i: any) =>
|
||
i.uuid === trimCode || i.sku === trimCode || i.barcode === trimCode || i.bar_code === trimCode
|
||
)
|
||
|
||
if (!foundItem) {
|
||
ElMessage.error(`未找到该物料库存: ${trimCode}`)
|
||
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
|
||
return
|
||
}
|
||
|
||
if (navigator.vibrate) navigator.vibrate(100)
|
||
|
||
// 关闭全屏扫码,弹出填数对话框
|
||
showCamera.value = false
|
||
|
||
// 处理数据格式
|
||
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
||
const type = foundItem.stock_type || foundItem.type || 'material'
|
||
const item: StockItem = {
|
||
...foundItem,
|
||
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
|
||
standard: foundItem.spec_model || foundItem.standard || foundItem.model || '',
|
||
sku: foundItem.sku || '',
|
||
uuid: foundItem.uuid || foundItem.sku || '',
|
||
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
||
qty_stock: stock,
|
||
qty_actual: 1,
|
||
scanned: true,
|
||
uniqueKey: `${type}_${foundItem.id}`,
|
||
source_table: typeToSourceTable(type),
|
||
stock_id: foundItem.id
|
||
}
|
||
|
||
openQtyDialog(item)
|
||
} catch (e) {
|
||
ElMessage.error('查询库存失败')
|
||
console.error(e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 开启全屏扫码
|
||
const openFullscreenScanner = () => {
|
||
if (!hasPermission) {
|
||
ElMessage.warning('无扫码权限')
|
||
return
|
||
}
|
||
showCamera.value = true
|
||
}
|
||
|
||
// 关闭全屏扫码
|
||
const closeScanner = () => {
|
||
showCamera.value = false
|
||
}
|
||
|
||
// 手动输入条码 - 实时查询后端
|
||
const handleManualInput = async () => {
|
||
const code = barcodeInput.value.trim()
|
||
if (!code) return
|
||
|
||
if (code.length < 3) {
|
||
ElMessage.warning('输入内容过短,请输入完整条码')
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
const res: any = await getStockList({
|
||
page: 1,
|
||
pageSize: 10,
|
||
keyword: code
|
||
})
|
||
|
||
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
|
||
ElMessage.error(`未找到该物料库存: ${code}`)
|
||
return
|
||
}
|
||
|
||
const foundItem = res.data.list.find((i: any) =>
|
||
i.uuid === code || i.sku === code || i.barcode === code || i.bar_code === code
|
||
)
|
||
|
||
if (!foundItem) {
|
||
ElMessage.error(`未找到该物料库存: ${code}`)
|
||
return
|
||
}
|
||
|
||
if (navigator.vibrate) navigator.vibrate(100)
|
||
|
||
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
||
const type = foundItem.stock_type || foundItem.type || 'material'
|
||
const item: StockItem = {
|
||
...foundItem,
|
||
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
|
||
standard: foundItem.spec_model || foundItem.standard || foundItem.model || '',
|
||
sku: foundItem.sku || '',
|
||
uuid: foundItem.uuid || foundItem.sku || '',
|
||
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
||
qty_stock: stock,
|
||
qty_actual: 1,
|
||
scanned: true,
|
||
uniqueKey: `${type}_${foundItem.id}`,
|
||
source_table: typeToSourceTable(type),
|
||
stock_id: foundItem.id
|
||
}
|
||
|
||
openQtyDialog(item)
|
||
} catch (e) {
|
||
ElMessage.error('查询库存失败')
|
||
console.error(e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const openQtyDialog = (item: StockItem) => {
|
||
currentItem.value = item
|
||
inputQty.value = item.scanned ? item.qty_actual : 1
|
||
showQtyDialog.value = true
|
||
|
||
nextTick(() => {
|
||
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
|
||
if(inputEl) inputEl.focus()
|
||
})
|
||
}
|
||
|
||
const handleManualConfirm = () => {
|
||
if (!currentItem.value) return
|
||
const val = inputQty.value === undefined ? 0 : inputQty.value
|
||
const remark = inputRemark.value
|
||
|
||
// ★★★ 直接保存到后端,不使用本地缓存 ★★★
|
||
showQtyDialog.value = false
|
||
inputRemark.value = ''
|
||
ElMessage.success(`已记录实盘: ${val}`)
|
||
|
||
// ★★★ 异步保存到后端,不阻塞 UI(fire-and-forget)★★★
|
||
syncToBackend(currentItem.value.uuid, val, remark)
|
||
}
|
||
|
||
// ★★★ 乐观更新:异步保存到后端,不阻塞 UI ★★★
|
||
const syncToBackend = (uuid: string, quantity: number, remark: string) => {
|
||
syncStatus.value = 'syncing'
|
||
api.addDraft({ uuid, quantity, remark })
|
||
.then(() => {
|
||
syncStatus.value = 'success'
|
||
})
|
||
.catch(() => {
|
||
syncStatus.value = 'failed'
|
||
})
|
||
}
|
||
|
||
const updateAndSync = async (item: StockItem, quantity: number, remark: string = '') => {
|
||
// 直接保存到后端,不使用本地缓存
|
||
item.scanned = true
|
||
item.qty_actual = quantity
|
||
}
|
||
|
||
const closeOverlays = () => {
|
||
showList.value = false
|
||
showQtyDialog.value = false
|
||
}
|
||
|
||
// --- 导出 Excel 逻辑 (调用后端API) ---
|
||
const exportToExcel = async () => {
|
||
try {
|
||
// ===== 调试代码 =====
|
||
console.warn('---- 触发了导出 Excel ----');
|
||
// ===== 调试结束 =====
|
||
|
||
ElMessage.info('正在生成盘点报告,请稍候...');
|
||
// 使用项目封装的 request 发送请求,确保自动携带 JWT Token
|
||
const res: any = await request({
|
||
url: '/v1/inbound/stock/export-stocktake',
|
||
method: 'get',
|
||
responseType: 'blob' as any, // 核心:接收二进制文件流
|
||
headers: {
|
||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
}
|
||
});
|
||
|
||
// 触发静默下载
|
||
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
const dateStr = new Date().toISOString().split('T')[0];
|
||
link.download = `盘点差异报告_${dateStr}.xlsx`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
ElMessage.success('报告导出成功');
|
||
} catch (error) {
|
||
console.error('导出失败:', error);
|
||
ElMessage.error('导出失败,请重试');
|
||
}
|
||
}
|
||
|
||
const varianceList = computed(() => {
|
||
return listData.value
|
||
.filter(i => !i.quantity || i.quantity !== i.stock_quantity)
|
||
.map(i => ({
|
||
...i,
|
||
// 映射字段名以匹配模板
|
||
stock_name: i.name,
|
||
stock_spec: i.standard,
|
||
stock_location: i.location || i.warehouse_loc || '',
|
||
stock_qty: i.stock_quantity,
|
||
quantity: i.quantity,
|
||
diff_qty: i.quantity - i.stock_quantity
|
||
}))
|
||
})
|
||
|
||
// ★ 新增: 本地搜索过滤后的差异列表
|
||
const filteredVarianceList = computed(() => {
|
||
if (!searchSku.value) return varianceList.value
|
||
const kw = searchSku.value.toLowerCase()
|
||
return varianceList.value.filter(i =>
|
||
(i.uuid && i.uuid.toLowerCase().includes(kw)) ||
|
||
(i.sku && i.sku.toLowerCase().includes(kw)) ||
|
||
(i.stock_name && i.stock_name.toLowerCase().includes(kw)) ||
|
||
(i.stock_spec && i.stock_spec.toLowerCase().includes(kw))
|
||
)
|
||
})
|
||
|
||
// ★ 新增: 获取盘点清单数据(合并全量应盘物资 + 已盘点记录)
|
||
const fetchInventoryList = async () => {
|
||
listLoading.value = true
|
||
try {
|
||
// 1. 获取已盘点记录
|
||
const res: any = await request({
|
||
url: '/v1/inbound/stock/draft/list',
|
||
method: 'get',
|
||
params: {
|
||
page: 1,
|
||
limit: 10000, // 获取全部已盘点记录
|
||
keyword: listKeyword.value
|
||
}
|
||
})
|
||
|
||
const scannedDrafts = res?.items || []
|
||
|
||
// 2. 使用全量应盘物资列表
|
||
// 对于每个应盘物资,检查是否有对应的盘点记录
|
||
let mergedData = allStockItems.value.map(item => {
|
||
// 查找对应的盘点记录
|
||
const draft = scannedDrafts.find((d: any) =>
|
||
d.source_table === item.source_table && d.stock_id === item.id
|
||
)
|
||
|
||
return {
|
||
id: draft?.id || null,
|
||
stock_id: item.id,
|
||
source_table: item.source_table,
|
||
sku: item.sku,
|
||
material_name: item.material_name,
|
||
spec_model: item.spec_model,
|
||
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
|
||
quantity: draft?.quantity || 0, // 实盘数
|
||
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
|
||
remark: draft?.remark || '',
|
||
warehouse_location: item.warehouse_location
|
||
}
|
||
})
|
||
|
||
// 3. 关键词过滤
|
||
if (listKeyword.value) {
|
||
const kw = listKeyword.value.toLowerCase()
|
||
mergedData = mergedData.filter((item: any) =>
|
||
item.sku?.toLowerCase().includes(kw) ||
|
||
item.material_name?.toLowerCase().includes(kw)
|
||
)
|
||
}
|
||
|
||
// 4. 状态过滤
|
||
if (listStatusFilter.value === 'counted') {
|
||
mergedData = mergedData.filter((item: any) => item.quantity > 0)
|
||
} else if (listStatusFilter.value === 'uncounted') {
|
||
mergedData = mergedData.filter((item: any) => !item.quantity || item.quantity === 0)
|
||
}
|
||
|
||
// 5. 分页
|
||
listTotalFiltered.value = mergedData.length
|
||
const start = (listPage.value - 1) * listLimit.value
|
||
listData.value = mergedData.slice(start, start + listLimit.value)
|
||
|
||
} catch (e) {
|
||
ElMessage.error('获取盘点清单失败')
|
||
} finally {
|
||
listLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ★ 修改: 打开盘点清单弹窗
|
||
const openInventoryList = async () => {
|
||
showList.value = true
|
||
listPage.value = 1
|
||
listKeyword.value = ''
|
||
listStatusFilter.value = 'all'
|
||
// 获取盘点基数(应盘物资清单)
|
||
await fetchAllStockItems()
|
||
// 获取已盘点列表
|
||
await fetchInventoryList()
|
||
}
|
||
|
||
// ★ 新增: 盘点清单搜索
|
||
const handleListSearch = () => {
|
||
listPage.value = 1
|
||
fetchInventoryList()
|
||
}
|
||
|
||
// ★ 新增: 盘点清单分页变化
|
||
const handleListPageChange = (page: number) => {
|
||
listPage.value = page
|
||
fetchInventoryList()
|
||
}
|
||
|
||
const handleListLimitChange = (limit: number) => {
|
||
listLimit.value = limit
|
||
listPage.value = 1
|
||
fetchInventoryList()
|
||
}
|
||
|
||
// ★ 新增: 更新实盘数量(手动修改)
|
||
const handleQuantityChange = async (row: any, val: number) => {
|
||
try {
|
||
await updateStocktakeQuantity({
|
||
stock_id: row.stock_id,
|
||
source_table: row.source_table,
|
||
quantity: val
|
||
})
|
||
ElMessage.success('实盘数已更新')
|
||
} catch (e) {
|
||
console.error('更新实盘数失败', e)
|
||
ElMessage.error('更新失败')
|
||
// 重新获取列表数据
|
||
fetchInventoryList()
|
||
}
|
||
}
|
||
|
||
// ★ 新增: 更新备注
|
||
const handleRemarkChange = async (row: any) => {
|
||
// 备注更新可以批量处理或直接调用后端接口
|
||
// 这里暂时只更新本地数据,实际项目中可以调用后端保存
|
||
console.log('备注更新:', row.remark, row.id)
|
||
}
|
||
|
||
// ★ 修改:结束盘点按钮直接调用 finishStocktake,跳过二次确认弹窗
|
||
const openFinishDialog = () => {
|
||
if (stats.value.total === 0) {
|
||
ElMessage.warning('暂无盘点数据')
|
||
return
|
||
}
|
||
// 直接执行结束盘点流程
|
||
finishStocktake()
|
||
}
|
||
|
||
// ★ 新增:结束盘点(计算漏盘)- 将未扫描的库存标记为全额盘亏
|
||
const handleGenerateMissing = async () => {
|
||
if (stats.value.total === 0) {
|
||
ElMessage.warning('暂无盘点数据')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'确认结束当前盘点吗?系统将自动把所有未扫描到的库存标记为全额盘亏!',
|
||
'提示',
|
||
{
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}
|
||
)
|
||
|
||
btnLoading.value = true
|
||
const res = await request({
|
||
url: '/v1/inbound/stock/stocktake/generate-missing',
|
||
method: 'post'
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
ElMessage.success(`成功生成 ${res.data.count} 条漏盘记录`)
|
||
// 刷新差异列表
|
||
await checkServerDraft()
|
||
} else {
|
||
ElMessage.error(res.msg || '生成漏盘数据失败')
|
||
}
|
||
} catch (e) {
|
||
if (e !== 'cancel') {
|
||
ElMessage.error('生成漏盘数据失败')
|
||
}
|
||
} finally {
|
||
btnLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ★ 重写: 结束盘点 - 纯前端状态流转,不再调用后端
|
||
const finishStocktake = async () => {
|
||
try {
|
||
// ★ 二次确认:防止误触
|
||
await ElMessageBox.confirm(
|
||
'确认仓库已全部盘点完毕,结束当前扫码并开始核对差异吗?',
|
||
'结束盘点确认',
|
||
{
|
||
confirmButtonText: '确定结束',
|
||
cancelButtonText: '继续扫码',
|
||
type: 'warning'
|
||
}
|
||
)
|
||
|
||
// 用户确认后,执行 UI 状态流转
|
||
btnLoading.value = true
|
||
await checkServerDraft() // 确保最新数据
|
||
|
||
isSessionActive.value = false // 收起扫码面板
|
||
showFinishDialog.value = false
|
||
|
||
ElMessage.success('盘点扫描结束,请核对差异')
|
||
await openVarianceDialog() // 自动弹出差异审核列表
|
||
|
||
// ★ 标记当前阶段为 review(差异审核)
|
||
localStorage.setItem('stocktake_phase', 'review')
|
||
|
||
} catch (e: any) {
|
||
if (e === 'cancel' || e === 'close' || String(e).includes('cancel')) {
|
||
// 用户取消,停留在扫码界面继续盘点
|
||
return
|
||
}
|
||
ElMessage.error(e?.message || '操作失败')
|
||
} finally {
|
||
btnLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ★ 新增: 打开差异审核对话框
|
||
const openVarianceDialog = async () => {
|
||
varianceLoading.value = true
|
||
showVarianceDialog.value = true
|
||
// varianceList 已通过 computed 自动计算,无需额外 API 调用
|
||
varianceLoading.value = false
|
||
}
|
||
|
||
// ★ 新增: 跳转到差异审核页面
|
||
const goToVarianceReview = () => {
|
||
openVarianceDialog()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.app-container.mobile-optimized {
|
||
width: 100%;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0;
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.idle-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
text-align: center;
|
||
border: none;
|
||
border-radius: 0;
|
||
}
|
||
.idle-content { width: 100%; max-width: 400px; padding: 20px; }
|
||
.app-title { font-size: 24px; margin-bottom: 10px; color: #303133; }
|
||
.subtitle { color: #909399; margin-bottom: 40px; }
|
||
.icon-area { margin-bottom: 20px; }
|
||
|
||
.idle-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
margin-top: 20px;
|
||
width: 100%;
|
||
}
|
||
.action-btn-full {
|
||
width: 100%;
|
||
height: 50px;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
margin: 0 !important;
|
||
}
|
||
.sub-text { font-size: 12px; opacity: 0.8; margin-left: 5px; }
|
||
|
||
.safe-tip { margin-top: 40px; font-size: 12px; color: #67c23a; display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||
|
||
.active-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: none;
|
||
border-radius: 0;
|
||
overflow: hidden;
|
||
}
|
||
:deep(.el-card__body) {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;}
|
||
.title-box { display: flex; align-items: center; gap: 8px; font-weight: bold; font-size: 16px; }
|
||
|
||
.scanner-container { height: 35vh; background: #000; border-radius: 12px; overflow: hidden; margin-bottom: 15px; position: relative; flex-shrink: 0;}
|
||
.camera-active-box { width: 100%; height: 100%; }
|
||
.scan-overlay-tip { position: absolute; bottom: 15px; left: 0; width: 100%; text-align: center; color: rgba(255,255,255,0.9); font-size: 13px; text-shadow: 0 1px 3px rgba(0,0,0,0.8); }
|
||
.camera-paused-box { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; background: #f2f3f5; color: #909399; cursor: pointer; }
|
||
|
||
.stats-dashboard { display: flex; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 15px; margin-bottom: 20px; cursor: pointer; flex-shrink: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||
.stat-card { flex: 1; text-align: center; }
|
||
.stat-val { font-size: 20px; font-weight: 800; line-height: 1.2; }
|
||
.stat-label { font-size: 11px; color: #909399; }
|
||
.stat-card.success .stat-val { color: #67c23a; }
|
||
.stat-card.warning .stat-val { color: #e6a23c; }
|
||
.stat-card.error .stat-val { color: #f56c6c; }
|
||
.stat-arrow { width: 20px; color: #c0c4cc; }
|
||
|
||
.w-100 { width: 100%; }
|
||
.action-btn { font-weight: bold; height: 48px; }
|
||
|
||
.drawer-layout { height: 100%; display: flex; flex-direction: column; padding: 10px; background: #fff;}
|
||
.search-bar { display: flex; margin-bottom: 10px; flex-shrink: 0; }
|
||
.table-container {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 4px;
|
||
}
|
||
.drawer-footer { margin-top: 10px; flex-shrink: 0; }
|
||
|
||
.qty-content { padding: 10px 0; }
|
||
.item-info { background: #f5f7fa; padding: 10px; border-radius: 6px; margin-bottom: 20px; }
|
||
.info-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 14px; }
|
||
.info-row .label { color: #909399; }
|
||
.info-row .value { font-weight: bold; color: #303133; }
|
||
.input-area { text-align: center; margin-top: 10px; }
|
||
.unit-text { margin-top: 5px; font-size: 12px; color: #909399; }
|
||
|
||
.actual-qty-text { font-weight: bold; color: #409EFF; font-size: 16px; }
|
||
.text-gray { color: #ccc; }
|
||
.text-red { color: #f56c6c; font-weight: bold; }
|
||
|
||
.report-summary { background: #f5f7fa; padding: 15px; border-radius: 6px; margin-bottom: 15px; }
|
||
.summary-row { display: flex; justify-content: space-between; margin-bottom: 15px; color: #606266; font-size: 13px; }
|
||
.summary-stats { display: flex; justify-content: space-between; text-align: center; }
|
||
.s-item .num { font-size: 18px; font-weight: bold; }
|
||
.missing-list-header { font-weight: bold; margin-bottom: 8px; font-size: 13px; border-left: 3px solid #f56c6c; padding-left: 8px; }
|
||
.dialog-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
|
||
.footer-right { display: flex; gap: 10px; }
|
||
|
||
/* ★★★ 新增:专为平板优化的超大数字输入框样式 ★★★ */
|
||
.large-control-input {
|
||
height: 60px; /* 增加整体输入框高度 */
|
||
}
|
||
/* 放大左右两边的加减按钮,并增加点击区域 */
|
||
:deep(.large-control-input .el-input-number__decrease),
|
||
:deep(.large-control-input .el-input-number__increase) {
|
||
width: 70px !important; /* 显著加大按钮宽度 */
|
||
font-size: 28px !important; /* 放大加号和减号图标 */
|
||
background-color: #f0f2f5; /* 稍微加深背景色,让触控区更明显 */
|
||
}
|
||
/* 给中间的数字输入区留出左右按钮的空间 */
|
||
:deep(.large-control-input .el-input__wrapper) {
|
||
padding-left: 80px !important;
|
||
padding-right: 80px !important;
|
||
}
|
||
/* 放大中间数字部分的字体和高度,保持协调 */
|
||
:deep(.large-control-input .el-input__inner) {
|
||
font-size: 24px !important;
|
||
height: 58px !important;
|
||
}
|
||
|
||
/* ★★★ 新增:扫码区域样式 ★★★ */
|
||
.scan-section {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.camera-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 180px;
|
||
background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
|
||
border: 2px dashed #409EFF;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.camera-placeholder:hover {
|
||
background: linear-gradient(135deg, #d9ecff 0%, #b3d8ff 100%);
|
||
transform: scale(1.01);
|
||
}
|
||
|
||
.camera-placeholder .text {
|
||
margin-top: 10px;
|
||
color: #409EFF;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.input-box {
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.input-box :deep(.el-input__wrapper) {
|
||
box-shadow: 0 0 0 1px #dcdfe6 inset;
|
||
}
|
||
|
||
.input-box :deep(.el-input__wrapper:hover) {
|
||
box-shadow: 0 0 0 1px #c0c4cc inset;
|
||
}
|
||
|
||
.input-box :deep(.el-input__wrapper.is-focus) {
|
||
box-shadow: 0 0 0 1px #409EFF inset;
|
||
}
|
||
|
||
/* ★★★ 全屏扫码 Overlay 样式 ★★★ */
|
||
.fullscreen-scanner-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: #000;
|
||
z-index: 2000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.scanner-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 15px;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
color: #fff;
|
||
}
|
||
|
||
.scanner-title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.close-btn {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border: none;
|
||
color: #fff;
|
||
}
|
||
|
||
.scanner-placeholder {
|
||
width: 40px;
|
||
}
|
||
|
||
.scanner-body {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.scanner-footer {
|
||
padding: 20px;
|
||
text-align: center;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
color: #fff;
|
||
}
|
||
|
||
.scanner-footer p {
|
||
margin: 5px 0;
|
||
}
|
||
|
||
.current-count {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: #409EFF;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.app-container {
|
||
padding: 5px;
|
||
}
|
||
|
||
.title-box {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.camera-placeholder {
|
||
height: 120px;
|
||
}
|
||
|
||
.bottom-actions {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.bottom-actions .el-button {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.confirm-content {
|
||
text-align: center;
|
||
padding: 20px 0;
|
||
}
|
||
.confirm-content .confirm-text {
|
||
margin-top: 20px;
|
||
font-size: 16px;
|
||
color: #303133;
|
||
}
|
||
</style> |