@ -3,12 +3,14 @@
< el-card v-if = "!isSessionActive" class="main-card idle-card" shadow="never" >
< div class = "idle-content" >
< el-icon :size = "60" color = "#409EFF" > < VideoPlay / > < / el-icon >
< h2 > 库存盘点系统 < / h2 >
< p class = "subtitle" > 请确保已连接扫描枪或摄像头 < / p >
< div class = "icon-area" >
< el-icon :size = "80" color = "#409EFF" > < VideoPlay / > < / el-icon >
< / div >
< h2 class = "app-title" > 库存盲盘系统 < / h2 >
< p class = "subtitle" > 单件自动确认 , 多件弹窗录入 < / p >
< div class = "idle-actions" >
< el-button type = "primary" size = "large" class = "w-100 " @click ="startNewSession" :loading = "btnLoading" >
< el-button type = "primary" size = "large" class = "action-btn-full " @click ="startNewSession" :loading = "btnLoading" >
开始新盘点
< / el-button >
@ -17,18 +19,17 @@
type = "warning"
plain
size = "large"
class = "w-100 "
class = "action-btn-full "
@click ="resumeSession"
:loading = "btnLoading"
>
继续上次盘点
< div class = "btn-subtext" > ( 云端已存 : { { serverDraftCount } } 项 ) < / div >
继续上次盘点 < span class = "sub-text" > ( { { serverDraftCount } } 项 ) < / span >
< / el-button >
< / div >
< div class = "safe-tip" >
< el-icon > < Cloudy / > < / el-icon >
< span > 数据实时同步至服务器 , 防止意外丢失 < / span >
< span > 数据实时同步 , 支持断点续传 < / span >
< / div >
< / div >
< / el-card >
@ -37,52 +38,49 @@
< template # header >
< div class = "header-row" >
< div class = "title-box" >
< span class = "title-text" > 📷 盘点 作业中 < / span >
< 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 type = "info" text bg size = "small" @click ="pauseSession" :icon = "VideoPause" >
暂停 / 退出
暂停
< / el-button >
< / div >
< / template >
< div class = "scanner-container" >
< div v-if = "!showList" class="camera-active-box" >
< div v-if = "!showList && !showQtyDialog " class="camera-active-box" >
< QrScanner @decode ="onScanSuccess" / >
< div class = "scan-overlay-tip" > 请将条码对准取景框 < / div >
< div class = "scan-overlay-tip" > 扫描条码 < / div >
< / div >
< div v-else class = "camera-paused-box" @click ="showList = false " >
< el -icon :size = "50" color = "#909399" > < List / > < / el-icon >
< p > 正在查看清单列表 < br > 摄像头已暂停 < / p >
< el-button type = "primary" link > 点击返回继续扫描 < / el-button >
< div v-else class = "camera-paused-box" @click ="closeOverlays " >
< el -icon :size = "50" color = "#909399" > < EditPen / > < / el-icon >
< p > 操作中 ... < br > 点击返回扫描 < / p >
< / div >
< / div >
< div class = "stats-dashboard" @click ="openInventoryList" >
< div class = "stat-card" >
< div class = "stat-val" > { { stats . total } } < / div >
< div class = "stat-label" > 总数 < / div >
< div 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 error " >
< div class = "stat-val" > { { stats . missing } } < / div >
< div class = "stat-label" > 差异 < / div >
< / div >
< div class = "stat-arrow" >
< el-icon > < ArrowRight / > < / el-icon >
< 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 = "12" >
< el-button type = "primary" plain size = "large" class = "w-100 action-btn" @click ="openInventoryList" :icon = "Search" >
查看清单
盘点明细
< / el-button >
< / el-col >
< el-col :span = "12" >
@ -91,113 +89,155 @@
< / el-button >
< / el-col >
< / el-row >
< p class = "printer-tip" > 打印机 IP : 192.168 .9 .205 < / p >
< / div >
< / el-card >
< el-drawer
v-model = "showList"
title = "📦 在库物品清单"
direction = "btt"
size = "85%"
destroy -on -close
class = "inventory-drawer"
>
< div class = "drawer-content" >
< div class = "search-bar" >
< el-input
v-model = "searchKeyword"
placeholder = "搜索名称 / 规格 / 条码..."
prefix -icon = " Search "
clearable
/ >
< el-select v-model = "filterType" placeholder="状态" style="width: 110px; margin-left: 8px;" >
< el -option label = "全部" value = "all" / >
< el-option label = "已盘" value = "scanned" / >
< el-option label = "差异" value = "missing" / >
< / el-select >
< / div >
< el-table
:data = "filteredList"
height = "100%"
stripe
border
row -key = " uniqueKey "
style = "width: 100%"
>
< el-table-column prop = "name" label = "物品名称" min -width = " 130 " show -overflow -tooltip >
< template # default = "scope" >
< div > { { scope . row . name } } < / div >
< div style = "font-size: 12px; color: #999;" > { { scope . row . standard } } < / div >
< / template >
< / el-table-column >
< el-table-column prop = "uuid" label = "条码尾号" width = "90" align = "center" >
< template # default = "scope" >
{ { scope . row . uuid ? scope . row . uuid . slice ( - 6 ) : '-' } }
< / template >
< / el-table-column >
< el-table-column label = "库存" width = "70" align = "center" >
< template # default = "scope" >
< span style = "font-weight: bold;" > { { parseFloat ( scope . row . qty _stock ) } } < / span >
< / template >
< / el-table-column >
< el-table-column label = "状态" width = "70" align = "center" fixed = "right" >
< template # default = "scope" >
< el-tag v-if = "scope.row.scanned" type="success" size="small" effect="dark" > OK < / el -tag >
< el-tag v-else type = "danger" size = "small" effect = "plain" > 未扫 < / el-tag >
< / template >
< / el-table-column >
< / el-table >
< / div >
< / el-drawer >
< el-dialog
v-model = "showFinish Dialog"
title = "📊 盘点结算 "
v-model = "showQty Dialog"
title = "🔢 录入实盘数量 "
width = "90%"
align -center
:close-on-click-modal = "false"
class = "preview-dialog"
destroy -on -close
class = "qty-dialog"
>
< div class = "report-summary" >
< div class = "summary-row " >
< span > 盘点时间 : < / span >
< span > { { new Date ( ) . toLocaleTimeString ( ) } } < / span >
< div v-if = "currentItem" class="qty-content" >
< div class = "item-info " >
< div class = "info-row" >
< span class = "label" > 名称 : < / span >
< span class = "value" > { { currentItem . name } } < / span >
< / div >
< div class = "info-row" >
< span class = "label" > 规格 : < / span >
< span class = "value" > { { currentItem . standard || '-' } } < / span >
< / div >
< div class = "info-row" >
< span class = "label" > SKU : < / span >
< span class = "value" > { { currentItem . sku } } < / span >
< / div >
< div class = "info-row" >
< span class = "label" > 批号 : < / span >
< span class = "value" > { { currentItem . batch _no || '-' } } < / span >
< / div >
< / div >
< el-divider > < el-icon > < Edit / > < / el-icon > { { currentItem . scanned ? '修改实盘数' : '录入实盘数' } } < / el-divider >
< div class = "input-area" >
< el-input-number
v-model = "inputQty"
:min = "0"
:precision = "0"
size = "large"
style = "width: 100%"
ref = "qtyInputRef"
placeholder = "请输入实际点数"
/ >
< p class = "unit-text" > 单位 : { { currentItem . unit || '个' } } < / p >
< / div >
< / div >
< template # footer >
< div class = "dialog-footer" >
< el-button @click ="showQtyDialog = false" > 取消 < / el -button >
< el-button 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" >
< div class = "search-bar" >
< el-input v-model = "searchKeyword" placeholder="搜索 SKU/名称..." :prefix-icon="Search" clearable / >
< el-select v-model = "filterType" placeholder="状态" style="width: 100px; margin-left: 8px;" >
< el -option label = "全部" value = "all" / >
< el-option label = "已盘" value = "scanned" / >
< el-option label = "未盘" value = "missing" / >
< / el-select >
< / div >
< div class = "table-container" >
< el-table
:data = "filteredList"
height = "100%"
stripe
border
row -key = " uniqueKey "
style = "width: 100%"
>
< el-table-column prop = "name" label = "名称" min -width = " 90 " show -overflow -tooltip / >
< el-table-column prop = "sku" label = "SKU" width = "110" show -overflow -tooltip / >
< el-table-column prop = "batch_no" label = "批次" width = "85" show -overflow -tooltip >
< template # default = "scope" >
{ { scope . row . serial _number || scope . row . batch _no || '-' } }
< / template >
< / el-table-column >
< el-table-column label = "实盘" width = "60" align = "center" >
< template # default = "scope" >
< span v-if = "scope.row.scanned" class="actual-qty-text" > {{ scope.row.qty_actual }} < / span >
< span v-else class = "text-gray" > - < / span >
< / template >
< / el-table-column >
< el-table-column label = "操作" width = "90" align = "center" fixed = "right" >
< template # default = "scope" >
< el-button
type = "primary"
link
icon = "Edit"
@click.stop ="openQtyDialog(scope.row)"
>
修改
< / el-button >
< / template >
< / el-table-column >
< / el-table >
< / div >
< div class = "drawer-footer" >
< el-button @click ="showList = false" style = "width: 100%" > 关闭列表 < / el-button >
< / div >
< / div >
< / el-drawer >
< el-dialog v-model = "showFinishDialog" title="📊 盘点结算" width="90%" align-center :close-on-click-modal="false" class="preview-dialog" >
< div class = "report-summary" >
< div class = "summary-row" > < span > 截止时间 : < / span > < span > { { new Date ( ) . toLocaleString ( ) } } < / span > < / div >
< div class = "summary-stats" >
< div class = "s-item" >
< div class = "num" > { { stats . total } } < / div >
< div class = "txt" > 总数 < / div >
< / div >
< div class = "s-item success" >
< div class = "num" > { { stats . scanned } } < / div >
< div class = "txt" > 实盘 < / div >
< / div >
< div class = "s-item error" >
< div class = "num" > { { stats . missing } } < / div >
< div class = "txt" > 丢失 < / div >
< / div >
< div class = "s-item" > < div class = "num" > { { stats . total } } < / div > < div class = "txt" > 总数 < / div > < / div >
< div class = "s-item success" > < div class = "num" > { { stats . scanned } } < / div > < div class = "txt" > 已盘 < / div > < / div >
< div class = "s-item error" > < div class = "num" > { { stats . total - stats . scanned } } < / div > < div class = "txt" > 未盘 < / div > < / div >
< / div >
< / div >
< div class = "missing-list-header" > 差异物品 ( 丢失清单 ) < / div >
< el-table :data = "missing List" height = "25 0" border size = "small" style = "margin-bottom: 10px;" >
< div class = "missing-list-header" > 差异/ 未盘预览 < / div >
< el-table :data = "variance List" height = "30 0" border size = "small" style = "margin-bottom: 10px;" >
< el-table-column prop = "name" label = "名称" show -overflow -tooltip / >
< el-table-column prop = "qty_stock " label = "账存 " width = "6 0" align = "center" / >
< el-table-column prop = "batch_no " label = "批次 " width = "9 0" show -overflow -tooltip / >
< el-table-column label = "账/实" width = "80" align = "center" >
< template # default = "scope" >
{ { parseFloat ( scope . row . qty _stock ) } } /
< span : class = "{'text-red': scope.row.scanned && scope.row.qty_actual !== scope.row.qty_stock}" >
{ { scope . row . scanned ? scope . row . qty _actual : '未' } }
< / span >
< / template >
< / el-table-column >
< / el-table >
< template # footer >
< div class = "dialog-footer" >
< el-button @click ="showFinishDialog = false" > 返回 < / el -button >
< el-button @click ="showFinishDialog = false" > 返回修改 < / el -button >
< div class = "footer-right" >
< el-button type = "success" @click ="generatePDF " :icon = "Download" circle title = "下载PDF" / >
< el-button type = "primary " @click ="confirmPrint " :loading = "printing" :icon = "Printer" >
打印报告
< / el-button >
< el-button type = "success" @click ="exportToExcel " :icon = "Download" > 导出Excel < / el-button >
< el-button type = "danger " @click ="finishStocktake " :loading = "printing" :icon = "Checked" > 结束 < / el-button >
< / div >
< / div >
< / template >
@ -207,57 +247,65 @@
< / template >
< script setup lang = "ts" >
import { ref , computed , onMounted } from 'vue'
import { getAllStock , printStocktakeReport } from '@/api/inbound/stock'
import { ref , computed , onMounted , nextTick } from 'vue'
import { getAllStock } from '@/api/inbound/stock'
import QrScanner from '@/components/QrScanner/index.vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { Search , VideoPlay , VideoPause , List , Printer , Checked , Download , ArrowRight , Cloudy } from '@element-plus/icons-vue'
import jsPDF from 'jspdf'
import 'jspdf-autotable'
import { Search , VideoPlay , VideoPause , List , Checked , Download , ArrowRight , Cloudy , Edit , EditPen } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { useUserStore } from '@/stores/user' // 引入UserStore获取真实用户
import { useUserStore } from '@/stores/user'
import * as XLSX from 'xlsx'
const userStore = useUserStore ( )
const currentUser = userStore . username || 'admin' // 优先使用登录名
const currentUser = userStore . username || 'admin'
// --- 数据接口 ---
interface StockItem {
id : number
name : string
standard : string
sku : string
batch _no : string
serial _number ? : string
uuid : string
bar _code : string
qty _stock : number
qty _actual : number
scanned : boolean
uniqueKey : string
unit ? : string
type ? : string
category ? : string
price ? : number
[ key : string ] : any
}
// --- 状态变量 ---
const loading = ref ( false )
const btnLoading = ref ( false )
const printing = ref ( false )
const isSessionActive = ref ( false ) // 会话是否进行中
const serverDraftCount = ref ( 0 ) // 服务器上的草稿数量
const showList = ref ( false ) // 是否显示抽屉清单
const showFinishDialog = ref ( false ) // 是否显示结算弹窗
const isSessionActive = ref ( false )
const serverDraftCount = ref ( 0 )
const syncStatus = ref < 'success' | 'syncing' | 'failed' > ( 'success' )
const showList = ref ( false )
const showFinishDialog = ref ( false )
const showQtyDialog = ref ( false )
const allData = ref < StockItem [ ] > ( [ ] )
const scannedSet = ref < Set < string > > ( new Set ( ) )
const scannedMap = ref < Map < string , number > > ( new Map ( ) )
const filterType = ref ( 'all' )
const searchKeyword = ref ( '' )
// --- API 封装 ---
const currentItem = ref < StockItem | null > ( null )
const inputQty = ref < number | undefined > ( undefined )
const qtyInputRef = ref ( )
const api = {
getDrafts : ( ) => request ( { url : '/v1/inbound/stock/draft/list' , method : 'get' , params : { user _id : currentUser } } ) ,
addDraft : ( data : any ) => request ( { url : '/v1/inbound/stock/draft/add' , method : 'post' , data : { ... data , user _id : currentUser } } ) ,
clearDraft : ( ) => request ( { url : '/v1/inbound/stock/draft/clear' , method : 'post' , data : { user _id : currentUser } } )
}
// --- 初始化 ---
onMounted ( async ( ) => {
await checkServerDraft ( )
} )
@ -265,62 +313,46 @@ onMounted(async () => {
const checkServerDraft = async ( ) => {
try {
const res : any = await api . getDrafts ( )
if ( res && res . length > 0 ) {
serverDraftCount . value = res . length
} else {
serverDraftCount . value = 0
}
} catch ( e ) {
console . error ( '检查草稿失败' , e )
}
serverDraftCount . value = ( res && res . length ) || 0
} catch ( e ) { }
}
// --- 核心业务逻辑 ---
// 1. 开始新会话 (清空服务器草稿)
const startNewSession = async ( ) => {
try {
if ( serverDraftCount . value > 0 ) {
await ElMessageBox . confirm ( '服务器 存在未完成的盘点 记录,开始新盘点将清除它们,确定吗?' , '新盘点 ' , { type : 'warning' } )
await ElMessageBox . confirm ( '存在未完成记录,开始新盘点将清除它们,确定吗?' , '警告 ' , { type : 'warning' } )
}
btnLoading . value = true
await api . clearDraft ( ) // 清空后端
scannedSet . value . clear ( )
await loadData ( ) // 拉取库存
await api . clearDraft ( )
scannedMap . value . clear ( )
await loadData ( )
isSessionActive . value = true
} catch ( e ) {
// cancel
} finally {
btnLoading . value = false
}
} catch ( e ) { } finally { btnLoading . value = false }
}
// 2. 恢复旧会话 (从服务器拉取草稿)
const resumeSession = async ( ) => {
btnLoading . value = true
try {
// 先拉草稿
const drafts : any = await api . getDrafts ( )
const draftUUIDs = new Set ( drafts . map ( ( d : any ) => d . uuid ) )
scannedSet . value = draftUUIDs
await loadData ( ) // 再拉库存,并匹配状态
const map = new Map < string , number > ( )
drafts . forEach ( ( d : any ) => {
map . set ( d . uuid , d . quantity !== undefined ? parseFloat ( d . quantity ) : 1 )
} )
scannedMap . value = map
await loadData ( )
isSessionActive . value = true
} catch ( e ) {
ElMessage . error ( '恢复进度 失败' )
} finally {
btnLoading . value = false
}
ElMessage . error ( '恢复失败' )
} finally { btnLoading . value = false }
}
// 3. 暂停 (无需做任何事,因为数据是实时同步的)
const pauseSession = ( ) => {
isSessionActive . value = false
checkServerDraft ( ) // 更新一下待机界面的数量显示
ElMessage . success ( '已退出,进度已安全保存在云端 ' )
checkServerDraft ( )
ElMessage . success ( '进度已保存 ' )
}
// 4. 加载库存数据
// ★★★ 核心修改:数据加载映射修复 ★★★
const loadData = async ( ) => {
loading . value = true
try {
@ -328,22 +360,26 @@ const loadData = async () => {
const list : StockItem [ ] = [ ]
const processItem = ( item : any , type : string ) => {
// 逻辑:只要 qty_stock > 0 就算在库
const stock = parseFloat ( item . stock _quantity || item . qty _stock || 0 )
if ( stock <= 0 ) return
const name = item . material _name || item . product _name || item . name || '未知物品'
const uuid = item . uuid || item . sku || ''
const isScanned = scannedMap . value . has ( uuid )
list . push ( {
... item ,
name : name ,
standard : item . standard || '' ,
// ★ 修复点:优先读取 spec_model, 因为数据库是这个字段
standard : item . spec _model || item . standard || item . model || '' ,
sku : item . sku || '' ,
batch _no : item . batch _no || item . batch _number || '' ,
serial _number : item . serial _number || '' ,
uuid : uuid ,
bar _code : item . bar _code || item . barcode || '' ,
qty _stock : stock ,
s canned: scannedSet . value . has ( uuid ) , // 匹配草稿状态
qty _actual : isS canned ? scannedMap . value . get ( uuid ) ! : 0 ,
scanned : isScanned ,
uniqueKey : ` ${ type } _ ${ item . id } `
} )
}
@ -354,66 +390,152 @@ const loadData = async () => {
allData . value = list
} catch ( e ) {
ElMessage . error ( '库存 数据加载失败' )
} finally {
loading . value = false
}
ElMessage . error ( '数据加载失败' )
} finally { loading . value = false }
}
// 5. 扫码成功处理 (实时同步到服务器)
const onScanSuccess = async ( code : string ) => {
const onScanSuccess = ( code : string ) => {
if ( ! code ) return
const trimCode = code . trim ( )
if ( ! /^[A-Za-z0-9\-\.]+$/ . test ( trimCode ) ) {
ElMessage . warning ( ` 识别到异常字符: ${ trimCode } ` )
return
}
const item = allData . value . find ( i => i . uuid === trimCode || i . bar _code === trimCode )
if ( item ) {
if ( item . scanned ) {
ElMessage . warning ( '重复扫描' )
return
}
if ( navigator . vibrate ) navigator . vibrate ( 100 )
// 前端先响应,不阻塞 UI
item . scanned = true
scannedSet . value . add ( item . uuid )
const isBatchMultiple = ( item . batch _no && item . batch _no . length > 0 ) && ( item . qty _stock > 1 ) ;
if ( navigator . vibrate ) navigator . vibrate ( 100 ) ;
ElMessage . success ( ` 已确认: ${ item . name } ` )
// 后台静默同步
syncStatus . value = 'syncing'
try {
await api . addDraft ( {
id : item . id ,
uuid : item . uuid ,
name : item . name ,
standard : item . standard ,
batch _no : item . batch _no ,
qty _stock : item . qty _stock
} )
syncStatus . value = 'success'
} catch ( e ) {
console . error ( '同步失败' , e )
syncStatus . value = 'failed'
if ( isBatchMultiple ) {
openQtyDialog ( item )
} else {
if ( item . scanned ) {
openQtyDialog ( item )
} else {
updateAndSync ( item , 1 )
ElMessage . success ( ` 自动确认: ${ item . name } +1 ` )
}
}
} else {
ElMessage . error ( ` 未知 条码: ${ trimCode } ` )
if ( navigator . vibrate ) navigator . vibrate ( [ 200 , 50 , 200 ] ) ;
ElMessage . error ( ` 不在库 条码: ${ trimCode } ` )
if ( navigator . vibrate ) navigator . vibrate ( [ 200 , 50 , 200 ] )
}
}
const openQtyDialog = ( item : StockItem ) => {
currentItem . value = item
inputQty . value = item . scanned ? item . qty _actual : undefined
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
updateAndSync ( currentItem . value , val )
showQtyDialog . value = false
ElMessage . success ( ` 已记录实盘: ${ val } ` )
}
const updateAndSync = async ( item : StockItem , quantity : number ) => {
item . scanned = true
item . qty _actual = quantity
scannedMap . value . set ( item . uuid , quantity )
syncStatus . value = 'syncing'
try {
await api . addDraft ( { uuid : item . uuid , quantity : quantity } )
syncStatus . value = 'success'
} catch ( e ) {
syncStatus . value = 'failed'
}
}
const closeOverlays = ( ) => {
showList . value = false
showQtyDialog . value = false
}
// --- 导出 Excel 逻辑 ---
const exportToExcel = ( ) => {
try {
// 1. 已盘点 Sheet
const scannedData = allData . value . filter ( i => i . scanned ) . map ( item => ( {
'物品名称' : item . name ,
'类型' : item . type || item . material _type || '-' ,
'类别' : item . category || '-' ,
'规格型号' : item . spec _model || item . standard || '-' , // ★ 双重保险
'SKU' : item . sku ,
'批次/SN' : item . serial _number || item . batch _no || '-' ,
'单位' : item . unit || '个' ,
'单价' : item . price || item . unit _price || 0 ,
'账面库存' : parseFloat ( item . qty _stock as any ) ,
'实盘数量' : item . qty _actual ,
'盘点结果' : item . qty _stock === item . qty _actual ? '相符' : '差异' ,
'差异数' : item . qty _actual - item . qty _stock
} ) )
// 2. 未盘点 Sheet
const missingData = allData . value . filter ( i => ! i . scanned ) . map ( item => ( {
'物品名称' : item . name ,
'类型' : item . type || item . material _type || '-' ,
'类别' : item . category || '-' ,
'规格型号' : item . spec _model || item . standard || '-' , // ★ 双重保险
'SKU' : item . sku ,
'批次/SN' : item . serial _number || item . batch _no || '-' ,
'单位' : item . unit || '个' ,
'单价' : item . price || item . unit _price || 0 ,
'账面库存' : parseFloat ( item . qty _stock as any ) ,
'状态' : '未盘点'
} ) )
const wb = XLSX . utils . book _new ( )
const ws1 = XLSX . utils . json _to _sheet ( scannedData )
const ws2 = XLSX . utils . json _to _sheet ( missingData )
const wscols = [
{ wch : 20 } , { wch : 10 } , { wch : 10 } , { wch : 15 } ,
{ wch : 15 } , { wch : 15 } , { wch : 5 } , { wch : 8 } ,
{ wch : 8 } , { wch : 8 } , { wch : 8 } , { wch : 8 }
]
ws1 [ '!cols' ] = wscols
ws2 [ '!cols' ] = wscols
XLSX . utils . book _append _sheet ( wb , ws1 , "已盘点明细" )
XLSX . utils . book _append _sheet ( wb , ws2 , "未盘点明细" )
const fileName = ` 库存盘点报告_ ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .xlsx `
XLSX . writeFile ( wb , fileName )
ElMessage . success ( 'Excel 报表已生成' )
} catch ( e ) {
console . error ( e )
ElMessage . error ( '导出失败,请检查 xlsx 插件是否安装' )
}
}
// --- 计算属性 ---
const filteredList = computed ( ( ) => {
let result = allData . value
if ( filterType . value === 'scanned' ) result = result . filter ( i => i . scanned )
if ( filterType . value === 'missing' ) result = result . filter ( i => ! i . scanned )
else if ( filterType . value === 'missing' ) result = result . filter ( i => ! i . scanned )
if ( searchKeyword . value ) {
const kw = searchKeyword . value . toLowerCase ( )
result = result . filter ( i =>
i . name . toLowerCase ( ) . includes ( kw ) ||
i . uuid . includes ( kw ) ||
( i . standard && i . standard . toLowerCase ( ) . includes ( kw ) ) // 增加对规格的搜索
( i . sku && i . sku . toLowerCase ( ) . includes ( kw ) ) ||
( i . batch _no && i . batch _no . toLowerCase ( ) . includes ( kw ) )
)
}
return result
@ -422,159 +544,149 @@ const filteredList = computed(() => {
const stats = computed ( ( ) => {
const total = allData . value . length
const scanned = allData . value . filter ( i => i . scanned ) . length
return { to tal , scanned , missing : total - scanned }
const varianceItems = allDa ta. value . filter ( i =>
! i . scanned || ( i . scanned && i . qty _actual !== parseFloat ( i . qty _stock as any ) )
) . length
return { total , scanned , varianceItems }
} )
const missing List = computed ( ( ) => {
return allData . value . filter ( i => ! i . scanned )
const variance List = computed ( ( ) => {
return allData . value . filter ( i =>
! i . scanned || ( i . scanned && i . qty _actual !== parseFloat ( i . qty _stock as any ) )
)
} )
// --- 界面交互 ---
const openInventoryList = ( ) => { showList . value = true }
const openFinishDialog = ( ) => {
if ( stats . value . total === 0 ) return
showFinishDialog . value = true
}
// 生成 PDF (包含中文支持提示)
const generatePDF = ( ) => {
const doc = new jsPDF ( )
// 提示:默认 jspdf 不支持中文,需要 addFont。
// 这里使用英文表头以确保基础可用性,实际生产需自行引入中文字体文件
doc . setFontSize ( 18 )
doc . text ( "Inventory Stocktake Report" , 14 , 22 )
doc . setFontSize ( 11 )
doc . text ( ` Time: ${ new Date ( ) . toLocaleString ( ) } ` , 14 , 30 )
doc . text ( ` Total: ${ stats . value . total } | Scanned: ${ stats . value . scanned } | Missing: ${ stats . value . missing } ` , 14 , 38 )
const tableData = missingList . value . map ( item => [
item . uuid ,
item . name ,
item . standard ,
item . qty _stock . toString ( )
] )
; ( doc as any ) . autoTable ( {
head : [ [ 'UUID' , 'Name' , 'Spec' , 'Stock' ] ] ,
body : tableData ,
startY : 45 ,
theme : 'grid'
} )
doc . save ( ` inventory_report_ ${ Date . now ( ) } .pdf ` )
}
// 打印并清除服务器草稿
const confirmPrint = async ( ) => {
const finishStocktake = async ( ) => {
try {
const payload = {
total : stats . value . total ,
scanned : stats . value . scanned ,
missing : stats . value . missing ,
missing _items : missingList . value
}
await ElMessageBox . confirm ( '确定要结束本次盘点吗?结束后当前进度将清空。' , '结束确认' , {
type : 'warning' , confirmButtonText : '确定结束' , cancelButtonText : '取消'
} )
printing . value = true
await printStocktakeReport ( payload )
ElMessage . success ( '已发送至打印机' )
// 任务完成,清空服务器草稿
await api . clearDraft ( )
scannedSet . value . clear ( )
scannedMap . value . clear ( )
isSessionActive . value = false
showFinishDialog . value = false
checkServerDraft ( ) // 刷新计数
checkServerDraft ( )
ElMessage . success ( '盘点已完成,会话已结束' )
} catch ( e ) {
ElMessage . error ( '打印 失败' )
} finally {
printing . value = false
}
if ( e !== 'cancel' ) ElMessage . error ( '操作 失败' )
} finally { printing . value = false }
}
< / script >
< style scoped >
/* 移动端容器适配 */
. app - container . mobile - optimized {
padding : 10px ;
max - width : 6 00px ; /* 限制最大宽度,保持手机观感 */
margin : 0 auto ;
width : 100 % ;
height : 1 00vh ;
display : flex ;
flex - direction : column ;
padding : 0 ;
background - color : # f5f7fa ;
}
/* 待机卡片 */
. idle - card {
min - height : 400 px ;
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 % ; padding : 20 px ; }
. sub title { color : # 909399 ; margin - bottom : 3 0px ; }
. idle - actions { display : flex ; flex - direction : column ; gap : 15 px ; }
. btn - subtext { f ont - size : 12 px ; opacity : 0.8 ; margin - top : 2 px ; }
. safe - tip { margin - top : 30 px ; font - size : 12 px ; color : # 67 c23a ; display : flex ; align - items : center ; justify - content : center ; gap : 5 px ; }
. idle - content { width : 100 % ; max - width : 400 px ; padding : 20 px ; }
. app - title { font - size : 24 px ; margin - bottom : 1 0px ; color : # 303133 ; }
. subtitle { color : # 909399 ; margin - bottom : 40 px ; }
. ic on- area { margin - bot tom : 20 px ; }
/* 头部 */
. header - row { display : flex ; justify - content : space - between ; align - items : center ; }
. idle - actions {
display : flex ;
flex - direction : column ;
gap : 15 px ;
margin - top : 20 px ;
width : 100 % ;
}
. action - btn - full {
width : 100 % ;
height : 50 px ;
font - size : 16 px ;
font - weight : bold ;
margin : 0 ! important ;
}
. sub - text { font - size : 12 px ; opacity : 0.8 ; margin - left : 5 px ; }
. safe - tip { margin - top : 40 px ; font - size : 12 px ; color : # 67 c23a ; display : flex ; align - items : center ; justify - content : center ; gap : 5 px ; }
. 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 : 10 px ;
overflow : hidden ;
}
. header - row { display : flex ; justify - content : space - between ; align - items : center ; margin - bottom : 10 px ; }
. title - box { display : flex ; align - items : center ; gap : 8 px ; font - weight : bold ; font - size : 16 px ; }
/* 扫描区域 */
. scanner - container {
height : 35 vh ; /* 占据屏幕高度的 35% */
background : # 000 ;
border - radius : 12 px ;
overflow : hidden ;
margin - bottom : 15 px ;
position : relative ;
box - shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.15 ) ;
}
. scanner - container { height : 35 vh ; background : # 000 ; border - radius : 12 px ; overflow : hidden ; margin - bottom : 15 px ; position : relative ; flex - shrink : 0 ; }
. camera - active - box { width : 100 % ; height : 100 % ; }
. scan - overlay - tip {
posi tion : abs olute ; bottom : 15 px ; left : 0 ; width : 100 % ;
text - align : center ; color : rgba ( 255 , 255 , 255 , 0.9 ) ; font - size : 13 px ; pointer - events : none ;
text - shadow : 0 1 px 3 px 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 ;
}
. scan - overlay - tip { position : absolute ; bottom : 15 px ; left : 0 ; width : 100 % ; text - align : center ; color : rgba ( 255 , 255 , 255 , 0.9 ) ; font - size : 13 px ; text - shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.8 ) ; }
. camera - paused - box { width : 100 % ; height : 100 % ; display : flex ; flex - direc tion: c olumn ; justify - content : center ; align - items : center ; background : # f2f3f5 ; color : # 909399 ; cursor : pointer ; }
/* 统计仪表盘 */
. stats - dashboard {
display : flex ; align - items : center ;
background : # f8f9fa ; border : 1 px solid # ebeef5 ;
border - radius : 8 px ; padding : 10 px ; margin - bottom : 20 px ;
cursor : pointer ; transition : all 0.2 s ; position : relative ;
}
. stats - dashboard : active { background : # f0f2f5 ; }
. stats - dashboard { display : flex ; align - items : center ; background : # fff ; border : 1 px solid # ebeef5 ; border - radius : 8 px ; padding : 15 px ; margin - bottom : 20 px ; cursor : pointer ; flex - shrink : 0 ; box - shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.05 ) ; }
. stat - card { flex : 1 ; text - align : center ; }
. stat - val { font - size : 20 px ; font - weight : 800 ; line - height : 1.2 ; }
. stat - label { font - size : 11 px ; color : # 909399 ; }
. stat - card . success . stat - val { color : # 67 c23a ; }
. stat - card . warning . stat - val { color : # e6a23c ; }
. stat - card . error . stat - val { color : # f56c6c ; }
. stat - arrow { width : 20 px ; color : # c0c4cc ; }
/* 底部操作 */
. w - 100 { width : 100 % ; }
. action - btn { font - weight : bold ; height : 48 px ; }
. printer - tip { text - align : center ; color : # c0c4cc ; font - size : 12 px ; margin - top : 15 px ; }
/* 抽屉样式 */
. drawer - content { height : 100 % ; display : flex ; flex - direction : column ; padd ing : 10 px ; }
. search - bar { display : flex ; margin - bottom : 10 px ; }
/* 结算弹窗 */
. report - summary {
background : # f5f7fa ; padding : 15 px ; border - radius : 6 px ; margin - bottom : 15 px ;
. drawer - layout { height : 100 % ; display : flex ; flex - direction : column ; padding : 10 px ; background : # fff ; }
. search - bar { display : flex ; margin - bottom : 10 px ; flex - shr ink : 0 ; }
. table - container {
flex : 1 ;
overflow : hidden ;
border : 1 px solid # ebeef5 ;
border - radius : 4 px ;
}
. drawer - footer { margin - top : 10 px ; flex - shrink : 0 ; }
. qty - content { padding : 10 px 0 ; }
. item - info { background : # f5f7fa ; padding : 10 px ; border - radius : 6 px ; margin - bottom : 20 px ; }
. info - row { display : flex ; justify - content : space - between ; margin - bottom : 8 px ; font - size : 14 px ; }
. info - row . label { color : # 909399 ; }
. info - row . value { font - weight : bold ; color : # 303133 ; }
. input - area { text - align : center ; margin - top : 10 px ; }
. unit - text { margin - top : 5 px ; font - size : 12 px ; color : # 909399 ; }
. actual - qty - text { font - weight : bold ; color : # 409 EFF ; font - size : 16 px ; }
. text - gray { color : # ccc ; }
. text - red { color : # f56c6c ; font - weight : bold ; }
. report - summary { background : # f5f7fa ; padding : 15 px ; border - radius : 6 px ; margin - bottom : 15 px ; }
. summary - row { display : flex ; justify - content : space - between ; margin - bottom : 15 px ; color : # 606266 ; font - size : 13 px ; }
. summary - stats { display : flex ; justify - content : space - between ; text - align : center ; }
. s - item . num { font - size : 18 px ; font - weight : bold ; }
. s - item . txt { font - size : 12 px ; color : # 909399 ; }
. s - item . success . num { color : # 67 c23a ; }
. s - item . error . num { color : # f56c6c ; }
. missing - list - header { font - weight : bold ; margin - bottom : 8 px ; font - size : 13 px ; border - left : 3 px solid # f56c6c ; padding - left : 8 px ; }
. dialog - footer { display : flex ; justify - content : space - between ; align - items : center ; margin - top : 10 px ; }
. footer - right { display : flex ; gap : 10 px ; }