Files
KCGL/inventory-web/src/views/stock/stocktake/index.vue
yueli 325cc44469 fix(stocktake): 拉开主副按钮层级防误触,明细增加库位列
【按钮层级】场景 A(已有进行中的盘点)
原实现主按钮是「加入当前盘点」,次入口是个不起眼的文字链接
「开启新一轮盘点」—— 二者视觉权重接近,而后者会终结本司其他 PDA
正在使用的会话,误触代价高。

现改为:
- 主按钮:绿色「继续当前盘点」,高度放到 66px、字号 19px
  (新增 .action-btn-hero),平板大屏上一眼可辨、一按即中
- 次按钮:红色镂空「结束当前并开启新盘点」,点击走 10 秒倒计时强确认
- 确认弹窗措辞由「原有数据将保留」改为明确告知**会话将被终结**:
  「该会话将标记为已结束,其他设备不能再加入」
- 倒计时确认通过后展开配置表单(而非直接开会话),让用户仍能选模式/范围;
  表单提交不再二次弹窗,避免重复确认

【库位列】明细抽屉新增「库位」列,紧跟规格之后 —— 找货刚需。
后端 merged-list 已下发该字段,实测单行返回含 warehouse_location='Y2/4/3/4',
无需改动。
2026-09-11 13:53:53 +08:00

1944 lines
66 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<!-- ★ 公司选择:多人协同的隔离维度。选同一公司的 PDA 共享同一个 session_id -->
<div class="company-selector">
<el-select
v-model="selectedCompany"
placeholder="请选择盘点公司"
size="large"
class="company-select"
:disabled="!hasCrossDomain"
:loading="companyLoading"
@change="handleCompanyChange"
>
<el-option v-for="c in companyOptions" :key="c" :label="c" :value="c" />
</el-select>
<div class="company-hint">{{ companyHint }}</div>
</div>
<div class="idle-actions">
<!-- 超管尚未选公司:不允许进入 -->
<el-button v-if="!selectedCompany" type="info" size="large" class="action-btn-full" disabled>
请先选择公司
</el-button>
<!-- ★ 探测失败:不提供进入按钮,避免误开新会话 -->
<template v-else-if="probeFailed">
<div class="probe-failed">
⚠️ 无法确认是否有进行中的盘点,请检查网络后重试
</div>
<el-button
type="primary"
size="large"
class="action-btn-full"
@click="checkServerDraft"
>
重新检测
</el-button>
</template>
<!-- 场景 A:已有进行中的盘点
主按钮 = 继续当前盘点(绿、加大),次按钮 = 结束当前并开启新盘点(红、镂空)。
两者视觉层级必须拉开 —— 次按钮会终结本司其他人正在用的会话,误触代价高。 -->
<template v-else-if="activeSession && !creatingNew">
<el-button
type="success"
size="large"
class="action-btn-full action-btn-hero"
@click="joinActiveSession"
:loading="btnLoading"
>
继续当前盘点
</el-button>
<div class="active-session-tip">
{{ modeLabel(activeSession.mode) }} · {{ scopeLabel(activeSession.scope_type) }},已扫 {{ activeSession.scanned }} 件<template v-if="activeSession.initiator">,发起人:{{ activeSession.initiator }}</template>
</div>
<el-button
type="danger"
plain
size="large"
class="action-btn-full"
@click="endCurrentAndCreate"
:loading="btnLoading"
>
结束当前并开启新盘点
</el-button>
</template>
<!-- 场景 B:创建盘点任务(无活跃会话,或主动开启新一轮) -->
<template v-else>
<div class="task-form">
<div class="task-form-row">
<span class="task-form-label">盘点模式</span>
<el-radio-group v-model="newMode" size="large">
<el-radio-button value="open">明盘</el-radio-button>
<el-radio-button value="blind">盲盘</el-radio-button>
</el-radio-group>
</div>
<div class="task-form-row">
<span class="task-form-label">盘点范围</span>
<el-radio-group v-model="newScopeType" size="large">
<el-radio-button value="full">全仓盘点</el-radio-button>
<el-radio-button value="active">活跃库位抽盘</el-radio-button>
</el-radio-group>
</div>
<div class="task-form-hint">
{{ newMode === 'blind' ? '盲盘:作业时不显示账面数与差异' : '明盘:作业时可查看账面数与差异' }}
</div>
<div v-if="newScopeType === 'active'" class="task-form-hint">
抽盘:仅盘点近 30 天有出入库/借还记录的前 50 个活跃库位
</div>
<div v-if="creatingNew" class="task-form-warn">
⚠️ 开启新一轮将<strong>终结当前会话</strong>:已扫数据完整保留,但其他设备不能再加入该会话
</div>
</div>
<el-button type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
开始新盘点
</el-button>
<el-button v-if="creatingNew" link class="secondary-entry" @click="creatingNew = false">
返回(继续当前盘点)
</el-button>
</template>
</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" v-loading="listLoading">
<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 v-if="recentScans.length" class="recent-scans">
<div class="recent-scans-title">
<span>最近扫码</span>
<span class="recent-scans-hint">最新 {{ recentScans.length }} 条</span>
</div>
<ul class="recent-scans-list">
<li
v-for="(r, i) in recentScans"
:key="`${r.uuid}-${i}`"
:class="{ 'is-latest': i === 0 }"
>
<span class="rs-time">{{ r.time }}</span>
<span class="rs-name">{{ r.name }}</span>
<span class="rs-spec">{{ r.spec }}</span>
<span class="rs-qty">×{{ r.quantity }}</span>
</li>
</ul>
</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"
destroy-on-close
: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">
即将<strong>结束当前进行中的盘点</strong>。<br />
已扫数据会完整保留,但该会话将标记为已结束,<strong>其他设备不能再加入</strong>。<br />
随后你可以配置并开启新一轮。确定继续吗?
</p>
</div>
<template #footer>
<el-button @click="cancelConfirm">取消</el-button>
<el-button
type="danger"
:disabled="countdown > 0"
@click="confirmStartNew"
>
{{ 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">序列号(SN):</span>
<span class="value">{{ currentItem.sn || 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>
<el-tag v-if="listMode" :type="listMode === 'blind' ? 'warning' : 'success'" size="small" effect="plain">
{{ modeLabel(listMode) }}
</el-tag>
<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="600"
stripe
border
row-key="uniqueKey"
style="width: 100%"
:row-class-name="(row) => row._justScanned ? 'just-scanned-row' : ''"
>
<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 />
<!-- ★ 库位:找货刚需,放在名称/规格后面。
后端 merged-list 已下发(SELECT cs.warehouse_location → 组装进 JSON) -->
<el-table-column prop="warehouse_location" label="库位" min-width="120" show-overflow-tooltip />
<!-- ★ 账面数与差异由**后端**按会话 mode 决定是否下发:
盲盘时这两个字段返回 null,前端不再做任何屏蔽。
注意 null 必须显示为「—」而不是 0 —— 用 `|| 0` 会把「未下发」
误渲染成「无差异」,工人会以为账实相符。 -->
<el-table-column label="账面数" width="80" align="center">
<template #default="{ row }">
<span v-if="row.stock_qty === null || row.stock_qty === undefined" class="cell-blinded">—</span>
<span v-else>{{ row.stock_qty }}</span>
</template>
</el-table-column>
<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 v-if="row.diff_qty === null || row.diff_qty === undefined" class="cell-blinded">—</span>
<span v-else :style="{ color: row.diff_qty > 0 ? '#67C23A' : row.diff_qty < 0 ? '#F56C6C' : '#909399' }">
{{ row.diff_qty > 0 ? '+' : '' }}{{ row.diff_qty }}
</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 { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode, getStocktakeCompanies } 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.userInfo?.display_name || userStore.userInfo?.username || userStore.username || '未知用户'
interface StockItem {
id: number
name: string
standard: string
sku: string
batch_no: string
sn?: 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 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 listKeyword = ref('')
const listLoading = ref(false)
const listData = ref<any[]>([])
// 当前列表的盘点模式(由后端下发),用于表头提示;数据屏蔽已在后端完成
const listMode = ref<string | null>(null)
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
const totalStockCount = ref(0) // ★ 全量应盘物资总数(不受limit限制)
// ★ 后端去重的真实已盘数量。唯一写入点是 fetchInventoryList(merged-list),
// 见下方注释 —— 不要再从 all-items 写入,避免两个接口各自为政
const totalScannedCount = ref(0)
const listTotalFiltered = ref(0) // 过滤后的总数
// ★ 最近扫码记录:平板大屏给工人的即时反馈,扫完瞥一眼就能确认有没有扫错/漏扫
const RECENT_SCAN_LIMIT = 10
const recentScans = ref<Array<{
uuid: string
name: string
spec: string
quantity: number
time: string
}>>([])
// ★ 新增: 会话ID
const currentSessionId = ref<string>('')
// ★ 会话 / 公司持久化
// session_id 由服务端的「活跃会话」接口给出:同一公司的多台 PDA 拿到的是同一个值,
// 这是多人协同的基础。localStorage 只是缓存,服务端才是唯一事实来源。
const SESSION_STORAGE_KEY = 'stocktake_session_id'
const COMPANY_STORAGE_KEY = 'stocktake_company'
// 当前选中的公司 —— 所有盘点请求都必须携带它
const selectedCompany = ref<string>('')
const companyOptions = ref<string[]>([])
const companyLoading = ref(false)
// 该公司的活跃会话;无进行中的盘点则为 null
const activeSession = ref<{
session_id: string
mode: string | null // open(明盘) / blind(盲盘)
scope_type: string | null // full(全仓) / active(抽盘)
total: number // 该会话草稿行数
scanned: number // 实际扫到的件数(按物料去重,不含系统自动漏盘)
last_scan_time: string | null
initiator: string | null
} | null>(null)
// ★ 探测失败标记:网络抖动时绝不能把「没查到」当成「没有盘点」——
// 否则界面会退回「开始新盘点」,库管一点就新建会话,把多人协同拆开
const probeFailed = ref(false)
// ★ 跨域角色:超管,或持有 crossDomain 权限的角色(当前为 WAREHOUSE_MGR)。
// 后端 get_current_company_filter() 对它们返回 None(不隔离),所以盘点范围
// 完全取决于用户选哪个公司 —— 这类用户必须显式选择,不能有默认值。
// 注意:SUPER_ADMIN 的权限是通配符 ['*'],permissions.includes('crossDomain')
// 对它恒为 false,因此角色与权限码两个条件都要判。
const hasCrossDomain = computed(() =>
userStore.role === 'SUPER_ADMIN' || userStore.permissions.includes('crossDomain')
)
// 选择器下方的说明文字
const companyHint = computed(() => {
if (hasCrossDomain.value) {
return selectedCompany.value ? `当前盘点范围:${selectedCompany.value}` : '请先选择要盘点的公司'
}
if (!selectedCompany.value) return '您的账号未绑定公司,请联系管理员'
return `已锁定为您所属公司:${selectedCompany.value}`
})
// ★ 创建盘点任务的配置项(仅在无活跃会话、或主动选择「开启新一轮」时展示)
const creatingNew = ref(false) // 已有活跃会话时,是否展开创建表单
const newMode = ref<'open' | 'blind'>('open') // 盘点模式,默认明盘
const newScopeType = ref<'full' | 'active'>('full') // 盘点范围,默认全仓
const MODE_LABELS: Record<string, string> = { open: '明盘', blind: '盲盘' }
const SCOPE_LABELS: Record<string, string> = { full: '全仓盘点', active: '活跃库位抽盘' }
const modeLabel = (m?: string | null) => (m ? MODE_LABELS[m] || m : '')
const scopeLabel = (s?: string | null) => (s ? SCOPE_LABELS[s] || s : '')
const persistSession = (sessionId: string) => {
currentSessionId.value = sessionId || ''
if (sessionId) {
localStorage.setItem(SESSION_STORAGE_KEY, sessionId)
} else {
localStorage.removeItem(SESSION_STORAGE_KEY)
}
}
const clearPersistedSession = () => {
localStorage.removeItem(SESSION_STORAGE_KEY)
currentSessionId.value = ''
}
const persistCompany = (company: string) => {
selectedCompany.value = company || ''
if (company) {
localStorage.setItem(COMPANY_STORAGE_KEY, company)
} else {
localStorage.removeItem(COMPANY_STORAGE_KEY)
}
}
// 统一构造带公司标识的查询参数。
// ★ 必须走 query string:后端 get_current_company_filter() 只读 request.args,
// 对 POST 请求同样如此 —— body 里的 company_name 会被直接忽略。
const withCompany = (extra: Record<string, any> = {}) => {
const params: Record<string, any> = { ...extra }
if (selectedCompany.value) params.company_name = selectedCompany.value
return params
}
// 获取应盘物资清单(盘点基数)
const fetchAllStockItems = async (page = 1) => {
try {
// ★ 分页拉取:默认每页 200 条,避免全量加载卡顿
const res: any = await getAllStocktakeItems({
session_id: currentSessionId.value,
page,
pageSize: 200,
company_name: selectedCompany.value || undefined
})
if (res && res.code === 200) {
allStockItems.value = res.data.items || []
// ★ 使用返回的 total 获取真实总数,而不是数组长度
totalStockCount.value = res.data.total || allStockItems.value.length
// 注意:这里刻意不再写 totalScannedCount。
// 该值统一以 merged-list(fetchInventoryList)为准,避免两个接口双写。
}
} catch (e) {
console.error('获取应盘物资清单失败', e)
}
}
// 过滤后的列表数据(直接使用已过滤的 listData)
const filteredListData = computed(() => listData.value)
// 统计信息:用后端返回的真实总数(不依赖全量数组长度)
const stats = computed(() => {
const total = totalStockCount.value || allStockItems.value.length
return {
total,
scanned: totalScannedCount.value,
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: withCompany({ session_id: sessionId })
}),
// ★ 该公司最近一次活跃的盘点会话(多人协同:用于「加入当前盘点」)
activeSession: () => request({
url: '/v1/inbound/stock/draft/active-session',
method: 'get',
params: withCompany()
}),
addDraft: (data: any) => request({
url: '/v1/inbound/stock/draft/add',
method: 'post',
params: withCompany(), // ★ 公司走 query string,body 里的会被后端忽略
data: { ...data, session_id: currentSessionId.value }
}),
// ★ 新增: 开始新会话(携带盘点模式与范围配置,由后端落库到 stocktake_session)
startNewSession: (payload: { mode: string; scope_type: string }) => request({
url: '/v1/inbound/stock/draft/start-new',
method: 'post',
params: withCompany(),
data: payload
}),
// ★ 新增: 结束盘点
finishStocktake: () => request({
url: '/v1/inbound/stock/finish',
method: 'post',
params: withCompany(),
data: { session_id: currentSessionId.value }
}),
// ★ 新增: 获取差异报告
getVarianceReport: () => request({
url: '/v1/inbound/stock/variance-report',
method: 'get',
params: withCompany()
}),
// ★ 保留清除功能(用于兼容性)
// 后端已强制要求 session_id:不传会被 400 拒绝(防止误清空他司/他人的草稿)。
// 清除成功后同步丢弃本地持久化的 session_id。
clearDraft: async () => {
const res = await request({
url: '/v1/inbound/stock/draft/clear',
method: 'post',
params: withCompany(),
data: { session_id: currentSessionId.value }
})
clearPersistedSession()
isSessionActive.value = false
return res
}
}
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 initEntry()
})
// ★ 入口初始化:加载公司列表 → 确定默认公司 → 查该公司的活跃会话
const initEntry = async () => {
await loadCompanies()
if (!hasCrossDomain.value) {
// 非跨域用户:后端会强制隔离到本公司,这里只是让选择器显示正确
const myCompany = userStore.companyName
if (myCompany) {
// 本公司可能尚无物料、不在列表中,补进去否则 el-select 显示为空
if (!companyOptions.value.includes(myCompany)) companyOptions.value.unshift(myCompany)
persistCompany(myCompany)
}
} else {
// 跨域角色沿用上次选择;必须显式选一个公司,否则盘点范围无从确定
const saved = localStorage.getItem(COMPANY_STORAGE_KEY)
if (saved && companyOptions.value.includes(saved)) persistCompany(saved)
}
if (selectedCompany.value) await checkServerDraft()
}
const loadCompanies = async () => {
companyLoading.value = true
try {
const res: any = await getStocktakeCompanies()
companyOptions.value = res?.data?.companies || []
} catch (e) {
console.error('获取公司列表失败', e)
companyOptions.value = []
} finally {
companyLoading.value = false
}
}
// 切换公司 = 切换盘点范围 → 必须丢弃上一个公司的会话,再重新探测
const handleCompanyChange = async (val: string) => {
clearPersistedSession()
activeSession.value = null
probeFailed.value = false
persistCompany(val)
await checkServerDraft()
}
// ★ 探测该公司是否有进行中的盘点 —— 服务端是唯一事实来源,
// 这样第二台 PDA 选同一公司后就能看到「加入当前盘点」。
const checkServerDraft = async () => {
if (!selectedCompany.value) {
activeSession.value = null
return
}
try {
const res: any = await api.activeSession()
const data = res?.data || {}
activeSession.value = data.session_id
? {
session_id: data.session_id,
mode: data.mode || null,
scope_type: data.scope_type || null,
total: data.total || 0,
scanned: data.scanned || 0,
last_scan_time: data.last_scan_time || null,
initiator: data.initiator || null
}
: null
probeFailed.value = false
} catch (e) {
// ★ 探测失败 ≠ 没有盘点。保持 activeSession 不变并把界面切到「重试」,
// 宁可挡住也不能让库管误开新会话。
probeFailed.value = true
}
}
// 进入扫码页的公共收尾:落到 scanning 阶段并拉取盘点基数
const enterScanning = async (message: string) => {
isSessionActive.value = true
creatingNew.value = false
localStorage.setItem('stocktake_phase', 'scanning')
// 最近扫码流水属于「本次作业」,换会话/重新加入都要清空,避免看到上一个会话的残留
recentScans.value = []
await fetchAllStockItems()
await fetchInventoryList()
ElMessage.success(message)
}
// ★ 场景 A 次按钮「结束当前并开启新盘点」。
// 它会终结本司其他 PDA 正在使用的会话,代价高,必须走 10 秒倒计时强确认。
const endCurrentAndCreate = () => {
if (!activeSession.value) {
// 没有活跃会话(理论到不了这里),直接展开表单
creatingNew.value = true
return
}
showConfirmDialog.value = true
countdown.value = 10
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(countdownTimer!)
countdownTimer = null
}
}, 1000)
}
// 倒计时确认通过:先展开配置表单选模式/范围,不直接开会话
const confirmStartNew = () => {
if (countdown.value > 0) return
showConfirmDialog.value = false
creatingNew.value = true
}
// 创建表单提交。是否终结当前会话已在 endCurrentAndCreate 处确认过,这里不再二次弹窗
const startNewSession = async () => {
if (!selectedCompany.value) {
ElMessage.warning('请先选择公司')
return
}
await doStartNewSession()
}
const doStartNewSession = async () => {
showConfirmDialog.value = false
btnLoading.value = true
try {
// ★ 先清掉旧会话,避免签发失败时残留上一个 session_id
clearPersistedSession()
// 后端签发新 session_id 并把盘点模式/范围落库到 stocktake_session,不删任何历史数据
const res: any = await api.startNewSession({
mode: newMode.value,
scope_type: newScopeType.value
})
persistSession(res?.session_id || res?.data?.session_id || '')
await enterScanning('新盘点会话已开始')
} catch (e: any) {
if (e !== 'cancel') {
// 后端对尚未实现的范围会返回 400,把原因透传给用户而不是笼统说“操作失败”
ElMessage.error(e?.response?.data?.message || e?.msg || e?.message || '操作失败')
}
} finally { btnLoading.value = false }
}
// ★ 场景 A:加入同事已开启的盘点 —— 同一公司共享同一个 session_id
const joinActiveSession = async () => {
if (!activeSession.value?.session_id) {
ElMessage.warning('没有找到进行中的盘点')
await checkServerDraft()
return
}
btnLoading.value = true
try {
persistSession(activeSession.value.session_id)
await enterScanning('已加入当前盘点')
} catch (e: any) {
ElMessage.error(e?.message || '加入失败')
} finally { btnLoading.value = false }
}
const cancelConfirm = () => {
showConfirmDialog.value = false
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
countdown.value = 0
}
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()
// 将扫到的条码同步显示在输入框中
barcodeInput.value = trimCode
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
ElMessage.warning(`识别到异常字符:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find(),避免漏匹配)
loading.value = true
try {
const res: any = await scanStockByBarcode(trimCode, selectedCompany.value || undefined)
if (!res || !res.data) {
ElMessage.error(`未找到该物料库存: ${trimCode}`)
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
return
}
const foundItem = res.data
if (navigator.vibrate) navigator.vibrate(100)
// 关闭全屏扫码,弹出填数对话框
showCamera.value = false
// 处理数据格式(后端已返回 source_table / stock_id / name / standard)
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
const type = foundItem.stock_type || foundItem.type || 'material'
const sourceTable = foundItem.source_table || typeToSourceTable(type)
const stockId = foundItem.stock_id || foundItem.id
const item: StockItem = {
...foundItem,
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
standard: foundItem.standard || foundItem.spec_model || 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}_${stockId}`,
source_table: sourceTable,
stock_id: stockId
}
openQtyDialog(item)
} catch (e: any) {
// ★ 友好错误提示:404=条码不在系统中,其他=网络/服务器错误
const status = e?.response?.status || e?.status
if (status === 404) {
ElMessage.warning(`条码 [${trimCode}] 未在库存系统中找到,请确认条码是否正确`)
} else {
const msg = e?.msg || e?.message || '查询库存失败'
ElMessage.error(`查询失败: ${msg}`)
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
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 {
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find())
const res: any = await scanStockByBarcode(code, selectedCompany.value || undefined)
if (!res || !res.data) {
ElMessage.error(`未找到该物料库存: ${code}`)
return
}
const foundItem = res.data
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 sourceTable = foundItem.source_table || typeToSourceTable(type)
const stockId = foundItem.stock_id || foundItem.id
const item: StockItem = {
...foundItem,
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
standard: foundItem.standard || foundItem.spec_model || 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}_${stockId}`,
source_table: sourceTable,
stock_id: stockId
}
openQtyDialog(item)
} catch (e: any) {
// ★ 友好错误提示:404=条码不在系统中,其他=网络/服务器错误
const status = e?.response?.status || e?.status
if (status === 404) {
ElMessage.warning(`条码 [${code}] 未在库存系统中找到,请确认条码是否正确`)
} else {
const msg = e?.msg || e?.message || '查询库存失败'
ElMessage.error(`查询失败: ${msg}`)
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
console.error(e)
} finally {
loading.value = false
}
}
const openQtyDialog = async (item: StockItem) => {
// ★ 重复扫码检测:精确查该物料是否已在本 session 盘过
let existingQty: number | null = null
try {
const uuidForQuery = item.uuid || item.sku || ''
const dupRes: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: withCompany({ session_id: currentSessionId.value, uuid: uuidForQuery, limit: 1 })
})
const dupItems = dupRes?.items || dupRes?.data?.items || []
if (dupItems.length > 0 && dupItems[0].quantity !== undefined) {
existingQty = Number(dupItems[0].quantity)
}
} catch (e) {
// 查询失败不阻断,视为未盘过
}
if (existingQty !== null) {
// 已盘过 → 提示当前值,询问是否修改
try {
await ElMessageBox.confirm(
`⚠️ 【${item.name} × ${item.standard}】已盘过,当前实盘为 ${existingQty}。\n\n是否修改本次盘点的数量?`,
'重复扫码提示',
{
confirmButtonText: '修改数量',
cancelButtonText: '取消',
type: 'warning'
}
)
// 用户确认修改 → 预填当前值,弹输入框
currentItem.value = item
inputQty.value = existingQty || 1
showQtyDialog.value = true
nextTick(() => {
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
if(inputEl) inputEl.focus()
})
} catch (e) {
// 用户取消,不修改
return
}
} else {
// 未盘过 → 正常弹输入框
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
const item = currentItem.value
// 先收起弹窗;成功提示延后到落库确认之后(不再乐观提示)
showQtyDialog.value = false
inputRemark.value = ''
syncToBackend(item, val, remark)
}
// ★ 落库成功后才记入最近流水
const pushRecentScan = (item: StockItem, quantity: number) => {
recentScans.value.unshift({
uuid: item.uuid,
name: item.name,
spec: item.standard || '',
quantity,
time: new Date().toLocaleTimeString('zh-CN', { hour12: false })
})
if (recentScans.value.length > RECENT_SCAN_LIMIT) {
recentScans.value.length = RECENT_SCAN_LIMIT
}
}
// ★ 保存到后端并等待确认。
// 不再「乐观更新」:只有 addDraft 真正 resolve 后才提示成功、记流水、刷统计。
// 否则请求失败时工人已看到「已记录」,会以为扫进去了,而这条实盘记录其实丢了。
const syncToBackend = (item: StockItem, quantity: number, remark: string) => {
const uuid = item.uuid
syncStatus.value = 'syncing'
api.addDraft({ uuid, quantity, remark })
.then(() => {
syncStatus.value = 'success'
ElMessage.success(`已录入:${item.name} × ${quantity}`)
pushRecentScan(item, quantity)
// 静默刷新统计数字
fetchInventoryList(true)
// ★ 扫码成功:该物品置顶到当前视图第一行并高亮
pinScannedItem(uuid)
})
.catch((e: any) => {
syncStatus.value = 'failed'
// ★ 这一笔确实没进库,且系统不会自动重试 —— 必须让工人看到并手动重扫。
// 停留久一点 + 给关闭按钮,避免连续扫码时提示被后续消息挤掉。
ElMessage({
type: 'error',
duration: 10000,
showClose: true,
message: `网络异常,上一笔条码 [${uuid}] 录入失败,请重试!`
})
console.error('扫码落库失败', e)
})
}
// ★ 扫码成功置顶高亮:把刚扫的物品移到列表顶部,方便确认
const pinScannedItem = (uuid: string) => {
// 1. 置顶到主表格(merged-list 当前页 listData)
const listIdx = listData.value.findIndex(it =>
(it.uuid && it.uuid === uuid) || (it.sku && it.sku === uuid) ||
(it.barcode && it.barcode === uuid)
)
if (listIdx > -1) {
const item = listData.value.splice(listIdx, 1)[0]
item._justScanned = true
listData.value.unshift(item)
setTimeout(() => { item._justScanned = false }, 3000)
}
// 2. 同步置顶到 allStockItems(应盘基数)
const idx = allStockItems.value.findIndex(it => it.uuid === uuid || it.sku === uuid)
if (idx > -1) {
const item = allStockItems.value.splice(idx, 1)[0]
allStockItems.value.unshift(item)
}
}
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('正在生成盘点报告,请稍候...');
// ★ 传递 session_id + 公司,导出范围限定在当前会话与当前公司
const res: any = await request({
url: '/v1/inbound/stock/export-stocktake',
method: 'get',
params: withCompany({ session_id: currentSessionId.value || undefined }),
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))
)
})
// ★ 新增: 获取盘点清单数据(合并全量应盘物资 + 已盘点记录)
// ★ 使用服务端 merged-list 端点(替代 99999 全量 + 前端 find())
const fetchInventoryList = async (silent = false) => {
if (!silent) listLoading.value = true
try {
const res: any = await getDraftMergedList({
session_id: currentSessionId.value,
keyword: listKeyword.value || undefined,
status_filter: listStatusFilter.value || undefined,
page: listPage.value,
pageSize: listLimit.value,
company_name: selectedCompany.value || undefined
})
const data = res?.data || {}
listData.value = data.list || []
listTotalFiltered.value = data.total || 0
totalScannedCount.value = data.total_scanned || 0
listMode.value = data.mode || null
} catch (e) {
if (!silent) ElMessage.error('获取盘点清单失败')
} finally {
if (!silent) listLoading.value = false
}
}
// ★ 修改: 打开盘点清单弹窗
const openInventoryList = async () => {
showList.value = true
listPage.value = 1
listKeyword.value = ''
listStatusFilter.value = 'all'
// 如果基数未加载则先加载,否则只刷新已盘点记录
if (allStockItems.value.length === 0) {
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,
session_id: currentSessionId.value // ★ 传入会话ID,后端按会话隔离更新
}, selectedCompany.value || undefined)
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',
params: withCompany(), // ★ 公司走 query string
data: {
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
})
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;
}
/* 主按钮:平板大屏上要一眼可辨、一按即中,故比常规按钮再放大一档 */
.action-btn-hero {
height: 66px;
font-size: 19px;
letter-spacing: 1px;
}
.sub-text { font-size: 12px; opacity: 0.8; margin-left: 5px; }
/* ★ 公司选择器:多人协同的隔离维度 */
.company-selector {
width: 100%;
margin-bottom: 10px;
}
.company-select {
width: 100%;
}
.company-hint {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
/* 探测失败提示 */
.probe-failed {
font-size: 13px;
color: #e6a23c;
text-align: center;
line-height: 1.5;
}
/* 盲盘时后端不下发账面数/差异,用「—」表示未下发(区别于真实的 0) */
.cell-blinded { color: #c0c4cc; }
/* ★ 创建盘点任务表单(模式 / 范围) */
.task-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 4px;
}
.task-form-row {
display: flex;
align-items: center;
gap: 10px;
}
.task-form-label {
flex: 0 0 64px;
font-size: 13px;
color: #606266;
text-align: right;
}
.task-form-row :deep(.el-radio-group) { flex: 1 1 auto; }
.task-form-hint {
font-size: 12px;
color: #909399;
text-align: center;
}
.task-form-warn {
font-size: 12px;
color: #e6a23c;
text-align: center;
line-height: 1.5;
}
/* 场景 A 的进度提示与次要入口 */
.active-session-tip {
font-size: 12px;
color: #67c23a;
text-align: center;
margin-top: -5px;
}
.secondary-entry {
font-size: 13px;
align-self: center;
}
.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; }
/* ★ 最近扫码记录:平板大屏用,行高放大便于一瞥确认 */
.recent-scans {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 20px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.recent-scans-title {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.recent-scans-hint { font-size: 11px; font-weight: 400; color: #909399; }
.recent-scans-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
}
.recent-scans-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 14px;
line-height: 1.4;
}
.recent-scans-list li:nth-child(odd) { background: #fafafa; }
/* 最新一条高亮,扫完立刻能对上 */
.recent-scans-list li.is-latest {
background: #f0f9eb;
border-left: 3px solid #67c23a;
font-weight: 600;
}
.rs-time { flex: 0 0 auto; font-size: 12px; color: #909399; font-variant-numeric: tabular-nums; }
.rs-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
.rs-spec { flex: 0 1 auto; max-width: 30%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #909399; }
.rs-qty { flex: 0 0 auto; font-weight: 700; color: #67c23a; font-variant-numeric: tabular-nums; }
.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; }
/* ★ 扫码成功置顶行高亮(浅绿背景) */
:deep(.just-scanned-row) {
background: #f0f9eb !important;
}
:deep(.just-scanned-row td) {
background: #f0f9eb !important;
}
.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>