Files
KCGL/inventory-web/src/views/stock/stocktake/index.vue
yueli 32cb5469c4 feat(stocktake): 欢迎页改为公司维度状态驱动,多设备共享盘点会话
原入口只有一个「开始新盘点」,多台 PDA 各开各的会话,无法协同。

- 新增公司选择器:普通用户锁定并默认本公司(后端本就强制隔离),
  超管必须显式选择,未选时入口按钮禁用;本公司若不在下拉列表中
  则补进去,避免 el-select 显示为空。
- 选定公司后调用 /draft/active-session 探测该公司是否已有进行中的
  盘点。服务端为唯一事实来源,localStorage 仅作缓存。
- 状态驱动渲染入口按钮:
  场景 A(已有活跃会话)→ 绿色「加入正在进行的盘点」,并提示当前
    进度项数;同时保留次要入口「开启新一轮盘点」,否则公司一旦有过
    草稿就永远开不了新一轮,点击走 10 秒确认弹窗。
  场景 B(无活跃会话)→ 蓝色「开始新盘点」。
- 切换公司时清空本地会话并重新探测,避免跨公司串会话。
- 移除此前「刷新后请求 /draft/list 取第一条草稿猜 session_id」的逻辑:
  该列表无会话/用户过滤,取到的是全库 SKU 字典序最小的那条草稿,
  与当前用户和公司都无关。
- 所有盘点请求携带当前公司;导出、生成漏盘、改实盘数等 POST 请求
  亦经 query string 传递公司。
2026-09-11 11:33:26 +08:00

1678 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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="!isSuperAdmin"
: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>
<!-- 场景 A:该公司已有进行中的盘点 → 绿色「加入」 -->
<template v-else-if="activeSession">
<el-button
type="success"
size="large"
class="action-btn-full"
@click="joinActiveSession"
:loading="btnLoading"
>
加入正在进行的盘点
</el-button>
<div class="active-session-tip">
当前已有未完成的盘点进度({{ serverDraftCount }} 项)
</div>
<!-- 次要入口:缺了它,本司一旦有过草稿就永远开不了新一轮 -->
<el-button
link
type="primary"
class="secondary-entry"
@click="startNewSession"
:loading="btnLoading"
>
开启新一轮盘点
</el-button>
</template>
<!-- 场景 B:该公司当前无盘点 → 蓝色「开始新盘点」 -->
<el-button
v-else
type="primary"
size="large"
class="action-btn-full"
@click="startNewSession"
:loading="btnLoading"
>
开始新盘点
</el-button>
</div>
<div class="safe-tip">
<el-icon><Cloudy /></el-icon>
<span>数据实时同步,同公司多设备共享盘点会话</span>
</div>
</div>
</el-card>
<el-card v-else class="main-card active-card" shadow="never" v-loading="loading">
<template #header>
<div class="header-row">
<div class="title-box">
<span class="title-text">📷 盲盘作业中</span>
<el-tag v-if="syncStatus === 'success'" type="success" size="small" effect="dark" round>已同步</el-tag>
<el-tag v-else-if="syncStatus === 'syncing'" type="warning" size="small" effect="dark" round>同步中...</el-tag>
<el-tag v-else type="danger" size="small" effect="dark" round>同步失败</el-tag>
</div>
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="info" text bg size="small" @click="pauseSession" :icon="VideoPause">
暂停
</el-button>
</div>
</template>
<div class="scan-section">
<div v-if="hasPermission" class="camera-placeholder" @click="openFullscreenScanner">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!hasPermission"
>
<template #prefix>
<el-icon><EditPen /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
</template>
</el-input>
</div>
</div>
<div class="stats-dashboard" @click="openInventoryList" 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 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>,之后仍可通过「继续上次盘点」回到之前的会话。确定开始吗?</p>
</div>
<template #footer>
<el-button @click="cancelConfirm">取消</el-button>
<el-button
type="primary"
: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>
<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 />
<!-- 盲盘:隐藏账面数和差异列 -->
<!-- <el-table-column prop="stock_qty" label="账面数" width="80" align="center" /> -->
<el-table-column label="实盘数" width="100" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.quantity"
:min="0"
:step="1"
size="small"
controls-position="right"
@change="(val) => handleQuantityChange(row, val)"
:disabled="!userStore.hasPermission('inventory_stocktake:operation')"
/>
</template>
</el-table-column>
<!-- <el-table-column label="差异" width="80" align="center">
<template #default="{ row }">
<span :style="{ color: row.diff_qty > 0 ? '#67C23A' : row.diff_qty < 0 ? '#F56C6C' : '#909399' }">
{{ row.diff_qty > 0 ? '+' : '' }}{{ row.diff_qty || 0 }}
</span>
</template>
</el-table-column> -->
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" size="small" @blur="handleRemarkChange(row)" />
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="drawer-footer" style="display: flex; justify-content: flex-end; padding: 10px;">
<el-pagination
v-model:current-page="listPage"
v-model:page-size="listLimit"
:page-sizes="[20, 50, 100, 200]"
:total="listTotalFiltered"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleListLimitChange"
@current-change="handleListPageChange"
/>
</div>
<div class="drawer-footer">
<el-button @click="showList = false" style="width: 100%">关闭列表</el-button>
</div>
</div>
</el-drawer>
<!-- 全屏扫码 Overlay -->
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="closeScanner" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div>
</div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="stats.scanned > 0" class="current-count">已盘点: {{ stats.scanned }} 项</p>
</div>
</div>
<!-- ★ 新增: 盘点差异审核对话框 -->
<el-dialog
v-model="showVarianceDialog"
title="📋 盘点差异审核"
width="95%"
align-center
:close-on-click-modal="false"
destroy-on-close
class="variance-dialog"
>
<div v-loading="varianceLoading">
<el-alert
title="差异审核说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 15px;"
>
<template #default>
以下列表显示所有已结束盘点但尚未平账的差异记录。请核实后进行后续处理。
</template>
</el-alert>
<!-- ★ 新增: 差异列表搜索 -->
<el-input
v-model="searchSku"
placeholder="输入 SKU/条码/名称快速定位"
clearable
style="width: 250px; margin-bottom: 15px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-table
:data="filteredVarianceList"
height="500"
border
stripe
style="width: 100%"
>
<el-table-column prop="uuid" label="SKU/条码" width="140" show-overflow-tooltip />
<el-table-column prop="stock_name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="stock_spec" label="规格型号" width="120" show-overflow-tooltip />
<el-table-column prop="stock_location" label="库位" width="100" />
<el-table-column label="账面库存" width="80" align="center">
<template #default="scope">
{{ scope.row.stock_qty }}
</template>
</el-table-column>
<el-table-column label="实盘数量" width="80" align="center">
<template #default="scope">
{{ scope.row.quantity }}
</template>
</el-table-column>
<el-table-column label="差异" width="100" align="center">
<template #default="scope">
<el-tag
:type="scope.row.diff_qty > 0 ? 'success' : 'danger'"
size="large"
effect="dark"
>
{{ scope.row.diff_qty > 0 ? '+' : '' }}{{ scope.row.diff_qty }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="差异类型" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.diff_qty > 0 ? 'success' : 'danger'">
{{ scope.row.diff_qty > 0 ? '盘盈' : '盘亏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.is_processed" type="info">已处理</el-tag>
<el-tag v-else type="warning">待审核</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="filteredVarianceList.length === 0" :description="searchSku ? '未找到匹配的差异记录' : '暂无待审核的差异记录'" />
</div>
<template #footer>
<div class="dialog-footer">
<!-- ★ 后悔药:返回继续扫码 -->
<el-button type="primary" plain @click="returnToScan">
🔄 返回继续扫码
</el-button>
<el-button @click="showVarianceDialog = false">关闭</el-button>
<el-button type="success" @click="exportToExcel" :icon="Download">导出差异报告</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { 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 serverDraftCount = ref(0)
const syncStatus = ref<'success' | 'syncing' | 'failed'>('success')
// 新增:扫码相关状态
const showCamera = ref(false)
const barcodeInput = ref('')
const barcodeRef = ref()
const hasPermission = userStore.hasPermission('inventory_stocktake:operation')
const showList = ref(false)
const showFinishDialog = ref(false)
const showQtyDialog = ref(false)
// ★ 新增: 防呆确认弹窗
const showConfirmDialog = ref(false)
// ★ 新增: 盘点开始防呆倒计时
const countdown = ref(0)
let countdownTimer: any = null
// ★ 新增: 差异审核对话框
const showVarianceDialog = ref(false)
// ★ 新增: 差异列表搜索
const searchSku = ref('')
// ★ 新增: 盘点清单弹窗分页
const listPage = ref(1)
const listLimit = ref(20)
const listTotal = ref(0)
const listKeyword = ref('')
const listLoading = ref(false)
const listData = ref<any[]>([])
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
const totalStockCount = ref(0) // ★ 全量应盘物资总数(不受limit限制)
const totalScannedCount = ref(0) // ★ 后端去重的真实已盘数量
const allScannedDrafts = ref<any[]>([]) // 全量草稿记录(脱离分页和过滤)
const listTotalFiltered = ref(0) // 过滤后的总数
// ★ 新增: 会话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)
// 该公司的活跃会话:{ session_id, total, last_scan_time };无进行中的盘点则为 null
const activeSession = ref<{ session_id: string; total: number; last_scan_time: string | null } | null>(null)
const isSuperAdmin = computed(() => userStore.role === 'SUPER_ADMIN')
// 选择器下方的说明文字
const companyHint = computed(() => {
if (isSuperAdmin.value) {
return selectedCompany.value ? `当前盘点范围:${selectedCompany.value}` : '请先选择要盘点的公司'
}
if (!selectedCompany.value) return '您的账号未绑定公司,请联系管理员'
return `已锁定为您所属公司:${selectedCompany.value}`
})
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.value = res.data.total_scanned || 0
}
} 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 }
}),
// ★ 新增: 开始新会话
startNewSession: () => request({
url: '/v1/inbound/stock/draft/start-new',
method: 'post',
params: withCompany(),
data: {}
}),
// ★ 新增: 结束盘点
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 (!isSuperAdmin.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
serverDraftCount.value = 0
persistCompany(val)
await checkServerDraft()
}
// ★ 探测该公司是否有进行中的盘点 —— 服务端是唯一事实来源,
// 这样第二台 PDA 选同一公司后就能看到「加入正在进行的盘点」。
const checkServerDraft = async () => {
if (!selectedCompany.value) {
activeSession.value = null
serverDraftCount.value = 0
return
}
try {
const res: any = await api.activeSession()
const data = res?.data || {}
activeSession.value = data.session_id
? { session_id: data.session_id, total: data.total || 0, last_scan_time: data.last_scan_time || null }
: null
serverDraftCount.value = data.total || 0
} catch (e) {
activeSession.value = null
serverDraftCount.value = 0
}
}
// 进入扫码页的公共收尾:落到 scanning 阶段并拉取盘点基数
const enterScanning = async (message: string) => {
isSessionActive.value = true
localStorage.setItem('stocktake_phase', 'scanning')
await fetchAllStockItems()
await fetchInventoryList()
ElMessage.success(message)
}
// 点击「开始新盘点」或次要入口「开启新一轮盘点」
const startNewSession = async () => {
if (!selectedCompany.value) {
ElMessage.warning('请先选择公司')
return
}
// ★ 该公司已有进行中的盘点 → 二次确认(不删数据,只签发新会话)
if (activeSession.value) {
showConfirmDialog.value = true
countdown.value = 10
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(countdownTimer!)
countdownTimer = null
}
}, 1000)
return
}
await doStartNewSession()
}
const doStartNewSession = async () => {
showConfirmDialog.value = false
btnLoading.value = true
try {
// ★ 先清掉旧会话,避免签发失败时残留上一个 session_id
clearPersistedSession()
// 后端只签发新 session_id,不删除任何历史数据
const res: any = await api.startNewSession()
persistSession(res?.session_id || res?.data?.session_id || '')
await enterScanning('新盘点会话已开始')
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(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 }
}
// 弹窗确认后开启新一轮盘点(不再删除任何数据,故不叫 confirmClear)
const confirmStartNew = () => {
if (countdown.value > 0) return
doStartNewSession()
}
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
// ★★★ 直接保存到后端,不使用本地缓存 ★★★
showQtyDialog.value = false
inputRemark.value = ''
ElMessage.success(`已记录实盘: ${val}`)
// ★★★ 异步保存到后端,不阻塞 UI(fire-and-forget)★★★
syncToBackend(currentItem.value.uuid, val, remark)
}
// ★★★ 乐观更新:异步保存到后端,不阻塞 UI ★★★
const syncToBackend = (uuid: string, quantity: number, remark: string) => {
syncStatus.value = 'syncing'
api.addDraft({ uuid, quantity, remark })
.then(() => {
syncStatus.value = 'success'
// 静默刷新统计数字
fetchInventoryList(true)
// ★ 扫码成功:该物品置顶到当前视图第一行并高亮
pinScannedItem(uuid)
})
.catch(() => {
syncStatus.value = 'failed'
})
}
// ★ 扫码成功置顶高亮:把刚扫的物品移到列表顶部,方便确认
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
} 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;
}
.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;
}
/* 场景 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; }
.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>