【按钮层级】场景 A(已有进行中的盘点) 原实现主按钮是「加入当前盘点」,次入口是个不起眼的文字链接 「开启新一轮盘点」—— 二者视觉权重接近,而后者会终结本司其他 PDA 正在使用的会话,误触代价高。 现改为: - 主按钮:绿色「继续当前盘点」,高度放到 66px、字号 19px (新增 .action-btn-hero),平板大屏上一眼可辨、一按即中 - 次按钮:红色镂空「结束当前并开启新盘点」,点击走 10 秒倒计时强确认 - 确认弹窗措辞由「原有数据将保留」改为明确告知**会话将被终结**: 「该会话将标记为已结束,其他设备不能再加入」 - 倒计时确认通过后展开配置表单(而非直接开会话),让用户仍能选模式/范围; 表单提交不再二次弹窗,避免重复确认 【库位列】明细抽屉新增「库位」列,紧跟规格之后 —— 找货刚需。 后端 merged-list 已下发该字段,实测单行返回含 warehouse_location='Y2/4/3/4', 无需改动。
1944 lines
66 KiB
Vue
1944 lines
66 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>
|
||
|
||
<!-- ★ 公司选择:多人协同的隔离维度。选同一公司的 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> |