@@ -0,0 +1,101 @@
< template >
< BasicLayout >
< template # wrapper >
< el-card class = "page-card" shadow = "never" >
< div class = "page-heading" > < div > < h1 > 档口入库码 < / h1 > < p > 导入 Excel 后自动匹配 SYB 商品 ; 确认后逐件回写 , 结果不明确时只读复核 。 < / p > < / div > < / div >
< div class = "toolbar" role = "search" aria -label = " 档口入库码筛选与批量操作 " >
< el-upload ref = "upload" :auto-upload = "false" :limit = "1" accept = ".xlsx" :show-file-list = "false" :on-change = "selectFile" :on-exceed = "replaceFile" > < el-button :icon = "FolderOpened" > 选择 Excel < / el-button > < / el-upload >
< el-button type = "primary" :icon = "Upload" :loading = "importing" : disabled = "!selectedFile || importing" @click ="submitImport" > 导入 < / el -button >
< el-date-picker v-model = "dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="date-range" / >
< el-input v-model = "query.keyword" clearable placeholder="订单号或入库码" class="keyword" @keyup.enter="search" / >
< el-button :icon = "RefreshLeft" @click ="clearFilters" > 清除 < / el -button >
< el-button type = "primary" :icon = "Search" @click ="search" > 搜索 < / el -button >
< span class = "toolbar-spacer" / >
< el-button type = "primary" : disabled = "selected.length === 0 || busy" @click ="openApply" > 回写 ( {{ applyEligibleCount }} ) < / el -button >
< el-button type = "danger" plain : disabled = "selected.length === 0 || busy" @click ="openDelete" > 删除 ( {{ deleteEligibleCount }} ) < / el -button >
< / div >
< div class = "file-state" aria -live = " polite " > < template v-if = "selectedFile" > 已选择 : < strong > { { selectedFile . name } } < / strong > ( { { fileSize ( selectedFile . size ) } } ) < / template > < template v-else > 请选择 “ 标签入库码映射 ” Excel 文件 , 最大 10MB 。 < / template > < / div >
< el -alert v-if = "loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button type="primary" link @click="load" > 重新加载 < / el -button > < / template > < / el-alert >
< el-alert v-if = "activeJob" :title="jobTitle" :type="activeJob.status === 'failed' ? 'error' : 'info'" show-icon :closable="false" class="notice"><template #default><el-progress v-if="activeJob.status === 'running'" :percentage="jobPercent" :stroke-width="8" /><span v-if="activeJob.errorMessage" > {{ activeJob.errorMessage }} < / span > < span v-else > 关闭浏览器不会中断自动匹配 。 < / span > < / template > < / el -alert >
< el-alert v-if = "activeBatch" :title="batchTitle" :type="activeBatch.status === 'interrupted' ? 'warning' : 'info'" show-icon :closable="false" class="notice"><template #default><el-progress :percentage="batchPercent" :stroke-width="8" / > < span > 远端写入全局串行 , 结果不明确不会自动重试 。 < / span > < / template > < / el-alert >
< el-table ref = "table" v-loading = "loading" :data="items" row-key="id" border stripe empty-text="暂无档口入库码数据,请先选择 Excel 并导入" @selection-change="selectionChanged" >
< el -table -column type = "selection" width = "48" / >
< el-table-column label = "营业日期" prop = "businessDate" width = "112" / >
< el-table-column label = "订单号" prop = "orderNumber" min -width = " 160 " / >
< el-table-column label = "入库码" min -width = " 190 " > < template # default = "{ row }" > < div v-for = "code in visibleCodes(row)" :key="code" class="code">{{ code }}</div><el-button v-if="row.items.length > 2" type="primary" link @click="openDetail(row.id)" > 共 {{ row.items.length }} 个 < / el -button > < / template > < / el-table-column >
< el-table-column label = "档口" prop = "stall" min -width = " 130 " > < template # default = "{ row }" > { { row . stall || '—' } } < / template > < / el-table-column >
< el-table-column label = "本地规格" prop = "specRaw" min -width = " 170 " / >
< el-table-column label = "SYB 规格" min -width = " 170 " > < template # default = "{ row }" > < span v-if = "row.plan">{{ row.plan.sybSpec || '已读取,规格为空' }}</span><span v-else class="muted" > {{ sybSpecPlaceholder ( row.status ) }} < / span > < / template > < / el -table -column >
< el-table-column label = "状态" min -width = " 180 " > < template # default = "{ row }" > < el-tag :type = "statusMeta(row.status).type" > { { statusMeta ( row . status ) . label } } < / el-tag > < div v-if = "row.resultMessage" class="status-note" > {{ row.resultMessage }} < / div > < / template > < / el -table -column >
< el-table-column label = "操作" width = "190" fixed = "right" > < template # default = "{ row }" > < el-button type = "primary" link @click ="openDetail(row.id)" > 详情 < / el -button > < el-button v-if = "['failed', 'skipped'].includes(row.status)" type="primary" link :loading="rowActionId === row.id" @click="rematch(row)">重新匹配</el-button><el-button v-if="row.status === 'needs_check'" type="warning" link :loading="rowActionId === row.id" @click="recheck(row)" > 只读复核 < / el -button > < / template > < / el-table-column >
< / el-table >
< pagination v-show = "total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200]" :total="total" @pagination="load" / >
< / el-card >
< el-dialog v-model = "applyDialog.open" :title="`确认回写已选择的 ${selected.length} 条业务记录吗?`" width="640px" :close-on-click-modal="false" >
< div v-loading = "applyDialog.loading" >
< el -alert title = "远端写入将全局串行执行;超时或结果不明确时不会自动重试,只能只读复核。" type = "warning" show -icon :closable = "false" class = "notice" / >
< div class = "metric-grid" aria -live = " polite " > < div > < span > 业务记录数 < / span > < strong > { { applyDialog . preview . records || 0 } } < / strong > < / div > < div > < span > 入库码总数 < / span > < strong > { { applyDialog . preview . inboundCodes || 0 } } < / strong > < / div > < div > < span > 预计占位明细数 < / span > < strong > { { applyDialog . preview . placeholderDetails || 0 } } < / strong > < / div > < div > < span > 替换旧码数 < / span > < strong > { { applyDialog . preview . replaceOldCodes || 0 } } < / strong > < / div > < / div >
< el-alert v-if = "applyDialog.preview.blocked?.length" :title="`${applyDialog.preview.blocked.length} 条当前不可回写,请返回列表重新选择。`" type="error" show-icon :closable="false" / >
< / div >
< template # footer > < el-button :disabled = "applyDialog.saving" @click ="applyDialog.open = false" > 取消 < / el -button > < el-button type = "primary" :loading = "applyDialog.saving" : disabled = "applyDialog.loading || !!applyDialog.preview.blocked?.length" @click ="confirmApply" > 回写 {{ applyDialog.preview.records | | 0 }} 条 < / el -button > < / template >
< / el-dialog >
< el-dialog v-model = "deleteDialog.open" :title="`确定删除已选择的 ${selected.length} 条数据吗?`" width="600px" :close-on-click-modal="false" >
< el -alert title = "删除后不可恢复,可重新导入 Excel。此操作不会撤销已经写入 SYB 的入库码。" type = "error" show -icon :closable = "false" class = "notice" / >
< div class = "metric-grid three" > < div > < span > 选中数量 < / span > < strong > { { selected . length } } < / strong > < / div > < div > < span > 可删除数量 < / span > < strong > { { deleteEligibleCount } } < / strong > < / div > < div > < span > 阻塞数量 < / span > < strong > { { deleteBlocked . length } } < / strong > < / div > < / div >
< el-alert v-if = "deleteBlocked.length" :title="`记录 ${deleteBlocked.map(item => item.id).join('、')} 处于排队中、回写中或需复核;本次不能部分删除。`" type="warning" show-icon :closable="false" / >
< template # footer > < el-button :disabled = "deleteDialog.saving" @click ="deleteDialog.open = false" > 取消 < / el -button > < el-button type = "danger" :loading = "deleteDialog.saving" : disabled = "deleteBlocked.length > 0" @click ="confirmDelete" > 删除 {{ deleteEligibleCount }} 条 < / el -button > < / template >
< / el-dialog >
< el-drawer v-model = "detail.open" title="档口入库码详情" size="720px" >
< div v-loading = "detail.loading" class="drawer-body"><template v-if="detail.item"><el-descriptions :column="1" border><el-descriptions-item label="营业日期">{{ detail.item.businessDate }}</el-descriptions-item><el-descriptions-item label="订单号">{{ detail.item.orderNumber }}</el-descriptions-item><el-descriptions-item label="档口">{{ detail.item.stall || '—' }}</el-descriptions-item><el-descriptions-item label="店铺(匹配证据)">{{ detail.item.shopName || '—' }}</el-descriptions-item><el-descriptions-item label="原始 SKU">{{ detail.item.sourceSkuRaw || '—' }}</el-descriptions-item><el-descriptions-item label="本地规格">{{ detail.item.specRaw }}</el-descriptions-item><el-descriptions-item label="SYB 规格">{{ detail.item.plan?.sybSpec || '未形成计划' }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusMeta(detail.item.status).type">{{ statusMeta(detail.item.status).label }}</el-tag></el-descriptions-item><el-descriptions-item label="说明">{{ detail.item.resultMessage || '—' }}</el-descriptions-item></el-descriptions><h3>全部入库码</h3><el-table :data="detail.item.items" border size="small"><el-table-column label="顺序" prop="ordinal" width="80" /><el-table-column label="Excel 行" prop="sourceRow" width="100" /><el-table-column label="入库码" prop="code" / > < / el-table > < / template > < / div >
< / el-drawer >
< / template >
< / BasicLayout >
< / template >
< script >
import { FolderOpened , RefreshLeft , Search , Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { applySybInnerCodes , deleteSybInnerCodes , getSybInnerCode , getSybInnerCodeApplyBatch , getSybInnerCodeMatchJob , importSybInnerCodes , listSybInnerCodes , previewSybInnerCodeApply , recheckSybInnerCode , rematchSybInnerCodes } from '@/api/goauto/syb-inner-codes'
export default {
name : 'GoAutoSybInnerCodes' ,
setup ( ) { return { FolderOpened , RefreshLeft , Search , Upload } } ,
data ( ) { return { loading : false , importing : false , busy : false , loadError : '' , items : [ ] , total : 0 , selected : [ ] , selectedFile : null , dateRange : null , rowActionId : 0 , pollTimer : null , activeJob : null , activeBatch : null , query : { page : 1 , pageSize : 100 , dateFrom : '' , dateTo : '' , keyword : '' } , applyDialog : { open : false , loading : false , saving : false , preview : { } } , deleteDialog : { open : false , saving : false } , detail : { open : false , loading : false , item : null } } } ,
computed : {
applyEligibleCount ( ) { return this . selected . filter ( item => item . status === 'ready' ) . length } ,
deleteBlocked ( ) { return this . selected . filter ( item => [ 'queued' , 'applying' , 'needs_check' ] . includes ( item . status ) ) } ,
deleteEligibleCount ( ) { return this . deleteBlocked . length ? 0 : this . selected . length } ,
jobPercent ( ) { return this . activeJob ? . total ? Math . round ( this . activeJob . processed * 100 / this . activeJob . total ) : 0 } ,
jobTitle ( ) { const job = this . activeJob ; return job ? . status === 'failed' ? '自动匹配失败' : ` 自动匹配: ${ job ? . processed || 0 } / ${ job ? . total || 0 } ,可回写 ${ job ? . ready || 0 } ,异常 ${ job ? . failed || 0 } ` } ,
batchPercent ( ) { return this . activeBatch ? . requested ? Math . round ( this . activeBatch . processed * 100 / this . activeBatch . requested ) : 0 } ,
batchTitle ( ) { return ` 回写进度: ${ this . activeBatch ? . processed || 0 } / ${ this . activeBatch ? . requested || 0 } ( ${ this . activeBatch ? . status || 'queued' } ) ` }
} ,
created ( ) { this . load ( ) } , beforeUnmount ( ) { this . stopPolling ( ) } ,
methods : {
statusMeta ( status ) { return { pending : { label : '等待匹配' , type : 'info' } , matching : { label : '正在匹配' , type : 'primary' } , ready : { label : '可回写' , type : 'success' } , already _filled : { label : '远端已存在' , type : 'success' } , skipped : { label : '匹配受限' , type : 'warning' } , failed : { label : '失败' , type : 'danger' } , queued : { label : '排队中' , type : 'primary' } , applying : { label : '回写中' , type : 'primary' } , updated : { label : '已回写' , type : 'success' } , needs _check : { label : '需复核' , type : 'warning' } } [ status ] || { label : status || '未知' , type : 'info' } } ,
sybSpecPlaceholder ( status ) { return status === 'pending' ? '尚未匹配' : status === 'matching' ? '正在读取' : status === 'needs_check' ? '需重新读取核对' : '未形成匹配计划' } ,
visibleCodes ( row ) { return ( row . items || [ ] ) . slice ( 0 , 2 ) . map ( item => item . code ) } , fileSize ( size ) { return ` ${ ( size / 1024 ) . toFixed ( size > 1024 * 1024 ? 0 : 1 ) } KB ` } ,
selectFile ( file ) { this . selectedFile = file . raw } , replaceFile ( files ) { this . $refs . upload . clearFiles ( ) ; this . $refs . upload . handleStart ( files [ 0 ] ) } , selectionChanged ( rows ) { this . selected = rows } ,
async load ( ) { this . loading = true ; this . loadError = '' ; try { const r = await listSybInnerCodes ( this . query ) ; this . items = r . data . items ; this . total = r . data . total } catch ( error ) { this . loadError = error ? . response ? . data ? . message || error ? . message || '列表加载失败' } finally { this . loading = false } } ,
search ( ) { [ this . query . dateFrom , this . query . dateTo ] = this . dateRange || [ '' , '' ] ; this . query . page = 1 ; this . load ( ) } , clearFilters ( ) { this . dateRange = null ; Object . assign ( this . query , { page : 1 , dateFrom : '' , dateTo : '' , keyword : '' } ) ; this . load ( ) } ,
async submitImport ( ) { if ( ! this . selectedFile ) return ; this . importing = true ; try { const r = await importSybInnerCodes ( this . selectedFile , crypto . randomUUID ( ) ) ; ElMessage . success ( ` 导入 ${ r . data . recordCount } 条业务记录,已自动开始匹配 ` ) ; this . activeJob = { id : r . data . matchJobId , status : 'pending' , total : r . data . recordCount , processed : 0 , ready : 0 , failed : 0 } ; this . startPolling ( ) ; await this . load ( ) } finally { this . importing = false } } ,
async openApply ( ) { this . applyDialog = { open : true , loading : true , saving : false , preview : { } } ; try { const r = await previewSybInnerCodeApply ( this . selected . map ( item => item . id ) ) ; this . applyDialog . preview = r . data } finally { this . applyDialog . loading = false } } ,
async confirmApply ( ) { this . applyDialog . saving = true ; this . busy = true ; try { const r = await applySybInnerCodes ( { requestId : crypto . randomUUID ( ) , ids : this . selected . map ( item => item . id ) } ) ; this . activeBatch = { id : r . data . batchId , status : 'queued' , requested : r . data . queued , processed : 0 } ; this . applyDialog . open = false ; ElMessage . success ( '回写已提交,页面可以继续使用' ) ; this . startPolling ( ) ; await this . load ( ) } finally { this . applyDialog . saving = false ; this . busy = false } } ,
openDelete ( ) { this . deleteDialog . open = true } , async confirmDelete ( ) { this . deleteDialog . saving = true ; this . busy = true ; try { const r = await deleteSybInnerCodes ( { requestId : crypto . randomUUID ( ) , ids : this . selected . map ( item => item . id ) } ) ; ElMessage . success ( ` 已物理删除 ${ r . data . deleted } 条数据 ` ) ; this . deleteDialog . open = false ; await this . load ( ) } finally { this . deleteDialog . saving = false ; this . busy = false } } ,
async recheck ( row ) { this . rowActionId = row . id ; try { await recheckSybInnerCode ( row . id ) ; ElMessage . success ( '只读复核完成' ) ; await this . load ( ) } finally { this . rowActionId = 0 } } ,
async rematch ( row ) { this . rowActionId = row . id ; try { const r = await rematchSybInnerCodes ( { requestId : crypto . randomUUID ( ) , ids : [ row . id ] } ) ; this . activeJob = { id : r . data . matchJobId , status : 'pending' , total : 1 , processed : 0 , ready : 0 , failed : 0 } ; ElMessage . success ( '已开始重新匹配' ) ; this . startPolling ( ) ; await this . load ( ) } finally { this . rowActionId = 0 } } ,
async openDetail ( id ) { this . detail = { open : true , loading : true , item : null } ; try { const r = await getSybInnerCode ( id ) ; this . detail . item = r . data . item } finally { this . detail . loading = false } } ,
startPolling ( ) { if ( ! this . pollTimer ) this . pollTimer = window . setInterval ( this . poll , 2000 ) ; this . poll ( ) } , stopPolling ( ) { if ( this . pollTimer ) window . clearInterval ( this . pollTimer ) ; this . pollTimer = null } ,
async poll ( ) { let active = false ; if ( this . activeJob && [ 'pending' , 'running' ] . includes ( this . activeJob . status ) ) { const r = await getSybInnerCodeMatchJob ( this . activeJob . id ) ; this . activeJob = r . data . item ; active = [ 'pending' , 'running' ] . includes ( this . activeJob . status ) || active } if ( this . activeBatch && [ 'queued' , 'running' ] . includes ( this . activeBatch . status ) ) { const r = await getSybInnerCodeApplyBatch ( this . activeBatch . id ) ; this . activeBatch = r . data . batch ; active = [ 'queued' , 'running' ] . includes ( this . activeBatch . status ) || active } await this . load ( ) ; if ( ! active ) this . stopPolling ( ) }
}
}
< / script >
< style lang = "scss" scoped >
. page - card { min - height : calc ( 100 vh - 124 px ) } . page - heading { margin - bottom : 16 px } . page - heading h1 { margin : 0 0 6 px ; font - size : 24 px ; color : # 1 f2937 } . page - heading p { margin : 0 ; color : # 606266 ; line - height : 1.5 } . toolbar { display : flex ; align - items : center ; gap : 8 px ; padding : 12 px 16 px ; border : 1 px solid # e5e7eb ; border - radius : 8 px ; background : # f8fafc } . toolbar - spacer { flex : 1 } . date - range { width : 250 px } . keyword { width : 210 px } . file - state { min - height : 38 px ; padding : 8 px 16 px ; color : # 606266 ; font - size : 13 px } . file - state strong { color : # 1 f2937 } . notice { margin - bottom : 12 px } . code { font - variant - numeric : tabular - nums ; word - break : break - all } . muted { color : # 909399 } . status - note { margin - top : 4 px ; color : # 606266 ; font - size : 12 px ; line - height : 1.4 } . metric - grid { display : grid ; grid - template - columns : repeat ( 4 , 1 fr ) ; gap : 12 px ; margin : 16 px 0 } . metric - grid . three { grid - template - columns : repeat ( 3 , 1 fr ) } . metric - grid > div { padding : 16 px ; border : 1 px solid # dbeafe ; border - radius : 8 px ; background : # f8fafc } . metric - grid span { display : block ; color : # 606266 ; font - size : 12 px } . metric - grid strong { display : block ; margin - top : 8 px ; color : # 1 e40af ; font - size : 24 px ; font - variant - numeric : tabular - nums } . drawer - body { padding : 0 4 px 24 px } . drawer - body h3 { margin : 24 px 0 12 px } @ media ( max - width : 1280 px ) { . toolbar { flex - wrap : wrap } . toolbar - spacer { display : none } } @ media ( max - width : 760 px ) { . date - range , . keyword { width : 100 % } . metric - grid , . metric - grid . three { grid - template - columns : 1 fr 1 fr } } @ media ( prefers - reduced - motion : reduce ) { : deep ( * ) { scroll - behavior : auto ! important ; transition - duration : .01 ms ! important ; animation - duration : .01 ms ! important } }
< / style >