@@ -0,0 +1,604 @@
< template >
< div class = "page-shell module-page" >
< BrandTopBar active = "shop-data-crawl" / >
< div class = "main-content" >
< aside class = "left-panel" >
< div class = "section-title" > 店铺输入 < / div >
< div class = "input-zone" >
< div class = "input-row" >
< el-input v-model = "shopInput" clearable placeholder="请输入店铺名" @keyup.enter="confirmAdd" / >
< button type = "button" class = "opt-btn" :disabled = "adding" @click ="confirmAdd" >
{{ adding ? ' 添加中... ' : ' 添加 ' }}
< / button >
< / div >
< / div >
< div class = "section-title" > 备选区 < / div >
< div v-if = "!candidates.length" class="empty-candidates" > 暂无备选店铺 < / div >
< div v-else class = "candidate-table-scroll" >
< el-table :data = "candidates" row -key = " id " height = "250" class = "candidate-table"
@ selection -change = " onSelectionChange " >
< el-table-column type = "selection" width = "42" / >
< el-table-column prop = "shop_name" label = "店铺名" min -width = " 140 " show -overflow -tooltip / >
< el-table-column label = "操作" width = "64" align = "center" >
< template # default = "{ row }" >
< button type = "button" class = "link-danger" @click ="removeCandidate(row.id)" > 删除 < / button >
< / template >
< / el -table -column >
< / el-table >
< / div >
< div class = "section-title" > 抓取国家与顺序 < / div >
< div class = "country-pref-checks" >
< label v-for = "row in countryCheckboxRows" :key="row.code" class="country-check-row" >
< input type = "checkbox" class = "country-check-input" :checked = "isCountrySelected(row.code)"
:disabled = "isCountrySelectionLocked(row.code)" @change ="onCountryNativeChange(row.code, $event)" / >
< span > { { row . label } } ( { { row . code } } ) < / span >
< / label >
< / div >
< div class = "country-order-panel" >
< div class = "country-order-caption" > 已选顺序 < / div >
< div class = "country-order-list" >
< div v-for = "(code, index) in orderedCountryCodes" :key="code" class="country-drag-row"
: class = "{ dragging: dragCountryIndex === index }" draggable = "true"
@dragstart ="dragCountryIndex = index" @dragend ="dragCountryIndex = null"
@dragover.prevent @drop.prevent ="onCountryDrop(index)" >
< span class = "drag-handle" title = "拖动排序" > ⋮ ⋮ < / span >
< span > { { countryLabel ( code ) } } ( { { code } } ) < / span >
< / div >
< / div >
< / div >
< div v-if = "countryPrefSaving" class="country-pref-status" > 保存中... < / div >
< ZiniaoVersionSetting v-model = "ziniaoVersion" / >
< div class = "run-row" >
< button type = "button" class = "btn-run" :disabled = "matching" @click ="runMatch" >
{{ matching ? ' 匹配中... ' : ' 匹配店铺 ' }}
< / button >
< button type = "button" class = "btn-run btn-queue" : disabled = "isQueueBusy || !matchedRunnableItems.length"
@click ="startQueue" >
{{ isQueueBusy ? ' 串行抓取中... ' : ' 开始串行抓取 ' }}
< / button >
< / div >
< p v-if = "queueStatus" class="queue-status" > {{ queueStatus }} < / p >
< / aside >
< section class = "right-panel" >
< div class = "panel-header" > 匹配与任务 < / div >
< div class = "task-list-wrap" >
< div class = "clean-result-summary" >
< div class = "summary-card" > < span class = "summary-label" > 备选店铺 < / span > < strong > { { dashboard . candidateCount } } < / strong > < / div >
< div class = "summary-card" > < span class = "summary-label" > 已处理任务 < / span > < strong > { { dashboard . processedTaskCount } } < / strong > < / div >
< div class = "summary-card" > < span class = "summary-label" > 成功任务 < / span > < strong > { { dashboard . successTaskCount } } < / strong > < / div >
< div class = "summary-card" > < span class = "summary-label" > 失败任务 < / span > < strong > { { dashboard . failedTaskCount } } < / strong > < / div >
< / div >
< div class = "subsection-title" > 匹配结果 < / div >
< div v-if = "!matchedItems.length" class="empty-tasks narrow" > 匹配后将在这里显示结果 < / div >
< el -table v-else :data = "matchedItems" :row-key = "rowKey" :highlight-current-row = "false"
class = "result-table match-table" >
< el-table-column prop = "shopName" label = "店铺名" min -width = " 120 " / >
< el-table-column label = "匹配" width = "64" align = "center" >
< template # default = "{ row }" > < span : class = "row.matched ? 'ok' : 'fail'" > { { row . matched ? '是' : '否' } } < / span > < / template >
< / el-table-column >
< el-table-column prop = "shopId" label = "店铺 ID" min -width = " 120 " show -overflow -tooltip / >
< el-table-column prop = "platform" label = "平台" width = "88" show -overflow -tooltip / >
< el-table-column prop = "companyName" label = "公司" min -width = " 120 " show -overflow -tooltip / >
< el-table-column label = "状态" width = "100" > < template # default = "{ row }" > { { formatMatchStatus ( row . matchStatus ) } } < / template > < / el-table-column >
< el-table-column label = "说明" min -width = " 150 " show -overflow -tooltip > < template # default = "{ row }" > { { row . matchMessage || '-' } } < / template > < / el-table-column >
< el-table-column label = "操作" width = "64" align = "center" > < template # default = "{ row }" >
< button type = "button" class = "link-danger" @click ="removeMatchedRow(row)" > 删除 < / button >
< / template > < / el -table -column >
< / el-table >
< div class = "result-list-wrap" >
< div class = "result-list-header" > < span > 任务记录 < / span > < / div >
< div v-if = "!currentItems.length && !historySectionItems.length" class="empty-tasks" > 暂无任务记录 < / div >
< div v-if = "currentItems.length" class="result-subsection" >
< div class = "result-subsection-title" > 当前任务 < / div >
< ul class = "task-list" >
< TaskRow v-for = "item in currentItems" :key="historyKey(item)" :item="item"
@download ="downloadResult" @delete ="deleteTaskRecord" / >
< / ul >
< / div >
< div v-if = "historySectionItems.length" class="result-subsection" >
< div class = "result-subsection-title" > 历史记录 < / div >
< ul class = "task-list" >
< TaskRow v-for = "item in historySectionItems" :key="historyKey(item)" :item="item"
@download ="downloadResult" @delete ="deleteTaskRecord" / >
< / ul >
< / div >
< / div >
< / div >
< / section >
< / div >
< / div >
< / template >
< script setup lang = "ts" >
import { computed , defineComponent , h , onMounted , onUnmounted , ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
addShopDataCrawlCandidate ,
createShopDataCrawlTask ,
deleteShopDataCrawlCandidate ,
deleteShopDataCrawlHistory ,
deleteShopDataCrawlTask ,
getShopDataCrawlCountryPreference ,
getShopDataCrawlDashboard ,
getShopDataCrawlHistory ,
getShopDataCrawlResultDownloadUrl ,
getShopDataCrawlTaskProgressBatch ,
listShopDataCrawlCandidates ,
matchShopDataCrawlShops ,
putShopDataCrawlCountryPreference ,
type ShopDataCrawlCandidateVo ,
type ShopDataCrawlDashboardVo ,
type ShopDataCrawlHistoryItem ,
type ShopDataCrawlShopItem ,
type ShopDataCrawlTaskDetailVo ,
} from '@/shared/api/java-modules'
const COUNTRY _OPTIONS = [
{ code : 'UK' , label : '英国' } ,
{ code : 'DE' , label : '德国' } ,
{ code : 'FR' , label : '法国' } ,
{ code : 'ES' , label : '西班牙' } ,
{ code : 'IT' , label : '意大利' } ,
] as const
const TaskRow = defineComponent ( {
props : { item : { type : Object as ( ) => ShopDataCrawlHistoryItem , required : true } } ,
emits : [ 'download' , 'delete' ] ,
setup ( props , { emit } ) {
return ( ) => h ( 'li' , { class : 'task-item' } , [
h ( 'div' , { class : 'left split-result-main' } , [
h ( 'span' , { class : 'id' , title : props . item . shopName || '' } , props . item . shopName || '-' ) ,
h ( 'div' , { class : 'files' } , ` 任务 ID: ${ props . item . taskId ? ? '-' } ` ) ,
props . item . platform ? h ( 'div' , { class : 'files' } , ` 平台: ${ props . item . platform } ` ) : null ,
props . item . outputFilename ? h ( 'div' , { class : 'files' } , ` 文件: ${ props . item . outputFilename } ` ) : null ,
props . item . createdAt ? h ( 'div' , { class : 'files' } , ` 创建时间: ${ formatDateTime ( props . item . createdAt ) } ` ) : null ,
props . item . error ? h ( 'div' , { class : 'files error-text' } , ` 错误: ${ props . item . error } ` ) : null ,
] ) ,
h ( 'div' , { class : 'task-right' } , [
h ( 'span' , { class : [ 'status' , statusClass ( props . item . taskStatus ) ] } , statusText ( props . item . taskStatus ) ) ,
canDownload ( props . item ) ? h ( 'button' , { type : 'button' , class : 'download' , onClick : ( ) => emit ( 'download' , props . item ) } , '下载' ) : null ,
h ( 'button' , { type : 'button' , class : 'btn-delete' , onClick : ( ) => emit ( 'delete' , props . item ) } , '删除' ) ,
] ) ,
] )
} ,
} )
const timers = createCategorizedTimers ( 'shop-data-crawl' )
const ziniaoVersion = useZiniaoVersion ( )
const shopInput = ref ( '' )
const candidates = ref < ShopDataCrawlCandidateVo [ ] > ( [ ] )
const selectedCandidates = ref < ShopDataCrawlCandidateVo [ ] > ( [ ] )
const matchedItems = ref < ShopDataCrawlShopItem [ ] > ( [ ] )
const historyItems = ref < ShopDataCrawlHistoryItem [ ] > ( [ ] )
const dashboard = ref < ShopDataCrawlDashboardVo > ( { candidateCount : 0 , processedTaskCount : 0 , successTaskCount : 0 , failedTaskCount : 0 } )
const orderedCountryCodes = ref < string [ ] > ( COUNTRY _OPTIONS . map ( ( row ) => row . code ) )
const dragCountryIndex = ref < number | null > ( null )
const countryPrefSaving = ref ( false )
const countryPrefUserTouched = ref ( false )
const adding = ref ( false )
const matching = ref ( false )
const queueWorkerRunning = ref ( false )
const queueStatus = ref ( '' )
const pendingQueue = ref < ShopDataCrawlShopItem [ ] > ( [ ] )
const activeTaskId = ref < number | null > ( null )
const activeQueueItem = ref < ShopDataCrawlShopItem | null > ( null )
const activeDispatched = ref ( false )
const activeCountryCodes = ref < string [ ] > ( [ ] )
const activeZiniaoVersion = ref < 'new' | 'old' > ( 'new' )
const pollingTaskIds = ref < number [ ] > ( [ ] )
const taskSnapshots = ref < Record < number , ShopDataCrawlTaskDetailVo > > ( { } )
const autoQueueEnabled = ref ( false )
let countryPrefSaveTimer : number | null = null
let disposed = false
const matchedRunnableItems = computed ( ( ) => matchedItems . value . filter ( ( item ) => item . matched ) )
const currentItems = computed ( ( ) => historyItems . value . filter ( ( item ) => ! isTerminal ( item . taskStatus ) ) )
const historySectionItems = computed ( ( ) => historyItems . value . filter ( ( item ) => isTerminal ( item . taskStatus ) ) )
const isQueueBusy = computed ( ( ) => queueWorkerRunning . value || ! ! activeTaskId . value || pendingQueue . value . length > 0 )
const countryCheckboxRows = computed ( ( ) => {
const selected = new Set ( orderedCountryCodes . value )
return [
... orderedCountryCodes . value . map ( ( code ) => ( { code , label : countryLabel ( code ) } ) ) ,
... COUNTRY _OPTIONS . filter ( ( row ) => ! selected . has ( row . code ) ) ,
]
} )
function uid ( ) { return typeof window === 'undefined' ? '0' : window . localStorage . getItem ( 'uid' ) || '0' }
function storageKey ( name : string ) { return ` shop-data-crawl: ${ name } : ${ uid ( ) } ` }
function rowKey ( item : ShopDataCrawlShopItem ) { return ` ${ ( item . shopName || '' ) . trim ( ) } :: ${ item . shopId || '' } ` }
function historyKey ( item : ShopDataCrawlHistoryItem ) { return ` ${ item . taskId || 0 } : ${ item . resultId || 0 } : ${ rowKey ( item ) } ` }
function countryLabel ( code : string ) { return COUNTRY _OPTIONS . find ( ( row ) => row . code === code ) ? . label || code }
function isCountrySelected ( code : string ) { return orderedCountryCodes . value . includes ( code ) }
function isCountrySelectionLocked ( code : string ) { return orderedCountryCodes . value . length === 1 && orderedCountryCodes . value [ 0 ] === code }
function isTerminal ( status ? : string ) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' }
function statusText ( status ? : string ) { return status === 'SUCCESS' || status === 'COMPLETED' ? '已完成' : status === 'FAILED' ? '失败' : '执行中' }
function statusClass ( status ? : string ) { return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
function canDownload ( item : ShopDataCrawlHistoryItem ) { return Boolean ( item . resultId && ( item . fileReady || item . downloadUrl ) ) }
function formatDateTime ( value ? : string ) { if ( ! value ) return '-' ; const date = new Date ( value ) ; return Number . isNaN ( date . getTime ( ) ) ? value : date . toLocaleString ( 'zh-CN' , { hour12 : false } ) }
function formatMatchStatus ( status ? : string ) { return ( { MATCHED : '已匹配' , PENDING : '待匹配' , CONFLICT : '需确认' , INDEX _STALE : '索引过期' } as Record < string , string > ) [ status || '' ] || status || '-' }
function setStorage ( name : string , value : unknown ) {
if ( typeof window !== 'undefined' ) window . localStorage . setItem ( storageKey ( name ) , JSON . stringify ( value ) )
}
function loadLocalState ( ) {
try { matchedItems . value = JSON . parse ( window . localStorage . getItem ( storageKey ( 'matched' ) ) || '[]' ) } catch { matchedItems . value = [ ] }
try {
const state = JSON . parse ( window . localStorage . getItem ( storageKey ( 'queue' ) ) || '{}' ) as { pendingQueue ? : ShopDataCrawlShopItem [ ] ; activeTaskId ? : number | null ; activeQueueItem ? : ShopDataCrawlShopItem | null ; activeDispatched ? : boolean ; activeCountryCodes ? : string [ ] ; activeZiniaoVersion ? : 'new' | 'old' ; autoQueueEnabled ? : boolean }
pendingQueue . value = state . pendingQueue || [ ]
activeTaskId . value = state . activeTaskId || null
activeQueueItem . value = state . activeQueueItem || null
activeDispatched . value = state . activeDispatched || false
activeCountryCodes . value = state . activeCountryCodes || [ ]
activeZiniaoVersion . value = state . activeZiniaoVersion || 'new'
autoQueueEnabled . value = state . autoQueueEnabled || false
} catch { pendingQueue . value = [ ] ; activeTaskId . value = null ; activeQueueItem . value = null }
try { pollingTaskIds . value = JSON . parse ( window . localStorage . getItem ( storageKey ( 'polling-task-ids' ) ) || '[]' ) } catch { pollingTaskIds . value = [ ] }
try { taskSnapshots . value = JSON . parse ( window . localStorage . getItem ( storageKey ( 'task-snapshots' ) ) || '{}' ) } catch { taskSnapshots . value = { } }
if ( activeDispatched . value && activeQueueItem . value ) removeMatchedRow ( activeQueueItem . value )
}
function saveQueueState ( ) {
setStorage ( 'queue' , { pendingQueue : pendingQueue . value , activeTaskId : activeTaskId . value , activeQueueItem : activeQueueItem . value , activeDispatched : activeDispatched . value , activeCountryCodes : activeCountryCodes . value , activeZiniaoVersion : activeZiniaoVersion . value , autoQueueEnabled : autoQueueEnabled . value } )
setStorage ( 'polling-task-ids' , pollingTaskIds . value )
setStorage ( 'task-snapshots' , taskSnapshots . value )
}
function mergeItems ( base : ShopDataCrawlShopItem [ ] , incoming : ShopDataCrawlShopItem [ ] ) {
const map = new Map ( base . map ( ( item ) => [ rowKey ( item ) , item ] ) )
for ( const item of incoming ) map . set ( rowKey ( item ) , item )
return [ ... map . values ( ) ]
}
function saveMatched ( ) { setStorage ( 'matched' , matchedItems . value ) }
function onSelectionChange ( rows : ShopDataCrawlCandidateVo [ ] ) { selectedCandidates . value = rows || [ ] }
function onCountryNativeChange ( code : string , event : Event ) {
const input = event . target as HTMLInputElement
if ( ! input . checked && isCountrySelectionLocked ( code ) ) { input . checked = true ; ElMessage . warning ( '至少保留 1 个国家' ) ; return }
countryPrefUserTouched . value = true
orderedCountryCodes . value = input . checked ? [ ... orderedCountryCodes . value , code ] : orderedCountryCodes . value . filter ( ( item ) => item !== code )
scheduleCountryPreferenceSave ( )
}
function onCountryDrop ( toIndex : number ) {
const from = dragCountryIndex . value
dragCountryIndex . value = null
if ( from == null || from === toIndex ) return
const next = [ ... orderedCountryCodes . value ]
const [ item ] = next . splice ( from , 1 )
next . splice ( toIndex , 0 , item )
orderedCountryCodes . value = next
countryPrefUserTouched . value = true
scheduleCountryPreferenceSave ( )
}
function scheduleCountryPreferenceSave ( ) {
if ( countryPrefSaveTimer ) timers . clearTimer ( 'preference-save' , countryPrefSaveTimer )
countryPrefSaveTimer = timers . setTimeout ( 'preference-save' , ( ) => { countryPrefSaveTimer = null ; void persistCountryPreference ( ) } , 450 )
}
async function persistCountryPreference ( ) {
countryPrefSaving . value = true
try { await putShopDataCrawlCountryPreference ( orderedCountryCodes . value ) }
catch ( error ) { ElMessage . error ( error instanceof Error ? error . message : '保存国家顺序失败' ) }
finally { countryPrefSaving . value = false }
}
async function loadCandidates ( ) { candidates . value = await listShopDataCrawlCandidates ( ) }
async function loadDashboard ( ) { dashboard . value = await getShopDataCrawlDashboard ( ) }
async function loadHistory ( ) {
const localFailures = historyItems . value . filter ( ( item ) => ! item . taskId && ! item . resultId && item . taskStatus === 'FAILED' )
historyItems . value = [ ... localFailures , ... ( ( await getShopDataCrawlHistory ( ) ) . items || [ ] ) ]
}
async function refreshViews ( ) { await Promise . all ( [ loadDashboard ( ) , loadHistory ( ) ] ) }
async function confirmAdd ( ) {
const name = shopInput . value . trim ( )
if ( ! name ) { ElMessage . warning ( '请输入店铺名' ) ; return }
adding . value = true
try { await addShopDataCrawlCandidate ( name ) ; shopInput . value = '' ; await Promise . all ( [ loadCandidates ( ) , loadDashboard ( ) ] ) ; ElMessage . success ( '已加入备选区' ) }
catch ( error ) { ElMessage . error ( error instanceof Error ? error . message : '添加失败' ) }
finally { adding . value = false }
}
async function removeCandidate ( id : number ) {
try { await deleteShopDataCrawlCandidate ( id ) ; selectedCandidates . value = selectedCandidates . value . filter ( ( item ) => item . id !== id ) ; await Promise . all ( [ loadCandidates ( ) , loadDashboard ( ) ] ) ; ElMessage . success ( '已删除' ) }
catch ( error ) { ElMessage . error ( error instanceof Error ? error . message : '删除失败' ) }
}
async function runMatch ( ) {
const names = selectedCandidates . value . map ( ( item ) => item . shop _name ) . filter ( Boolean )
if ( ! names . length ) { ElMessage . warning ( '请先勾选备选店铺' ) ; return }
matching . value = true
try {
const incoming = ( await matchShopDataCrawlShops ( names ) ) . items || [ ]
matchedItems . value = mergeItems ( matchedItems . value , incoming )
saveMatched ( )
if ( autoQueueEnabled . value ) {
const activeKey = activeQueueItem . value ? rowKey ( activeQueueItem . value ) : ''
pendingQueue . value = mergeItems (
pendingQueue . value ,
incoming . filter ( ( item ) => item . matched && rowKey ( item ) !== activeKey ) ,
)
saveQueueState ( )
void processQueue ( )
}
ElMessage . success ( ` 匹配完成,共 ${ incoming . length } 条 ` )
} catch ( error ) { ElMessage . error ( error instanceof Error ? error . message : '匹配失败' ) }
finally { matching . value = false }
}
function removeMatchedRow ( item : ShopDataCrawlShopItem ) {
const key = rowKey ( item )
matchedItems . value = matchedItems . value . filter ( ( row ) => rowKey ( row ) !== key )
pendingQueue . value = pendingQueue . value . filter ( ( row ) => rowKey ( row ) !== key )
saveMatched ( ) ; saveQueueState ( )
}
function buildTaskItem ( item : ShopDataCrawlShopItem ) : ShopDataCrawlShopItem { return { ... item } }
function mergeProgress ( detail : ShopDataCrawlTaskDetailVo ) {
const taskId = detail . task ? . id || detail . items ? . [ 0 ] ? . taskId
if ( ! taskId ) return
taskSnapshots . value = { ... taskSnapshots . value , [ taskId ] : detail }
const incoming = ( detail . items || [ ] ) . map ( ( item ) => ( { ... item , taskId : item . taskId || taskId , taskStatus : item . taskStatus || detail . task ? . status , error : item . error || detail . task ? . errorMessage } ) )
const map = new Map ( historyItems . value . map ( ( item ) => [ historyKey ( item ) , item ] ) )
for ( const item of incoming ) {
const existing = [ ... map . values ( ) ] . find ( ( row ) => row . taskId === item . taskId && ( row . resultId === item . resultId || ! item . resultId ) )
if ( existing ) map . set ( historyKey ( existing ) , { ... existing , ... item } )
else map . set ( historyKey ( item ) , item )
}
historyItems . value = [ ... map . values ( ) ]
saveQueueState ( )
}
function isTaskDetail ( row : ShopDataCrawlTaskDetailVo | ShopDataCrawlHistoryItem ) : row is ShopDataCrawlTaskDetailVo {
return 'task' in row || 'items' in row
}
async function refreshTaskProgress ( taskId : number ) {
const batch = await getShopDataCrawlTaskProgressBatch ( [ taskId ] )
for ( const row of batch . items || [ ] ) {
if ( isTaskDetail ( row ) ) {
mergeProgress ( row )
} else {
mergeProgress ( { task : { id : row . taskId , status : row . taskStatus } , items : [ row ] } )
}
}
return ! ( batch . missingTaskIds || [ ] ) . includes ( taskId )
}
function activeTaskStatus ( taskId : number ) {
return taskSnapshots . value [ taskId ] ? . task ? . status || historyItems . value . find ( ( item ) => item . taskId === taskId ) ? . taskStatus || ''
}
function sleep ( ms : number ) { return disposed ? Promise . resolve ( ) : timers . sleep ( 'queue-wait' , ms ) }
function isTransientError ( error : unknown ) { return /network|fetch|timeout|50[0234]|load failed|connection refused|econnrefused|无法连接.*后端|后端服务.*(?:不可用|连接)|网络错误/i . test ( error instanceof Error ? error . message : String ( error || '' ) ) }
async function retry < T > ( action : ( ) => Promise < T > ) {
let attempt = 0
while ( ! disposed ) {
try { return await action ( ) } catch ( error ) {
if ( ! isTransientError ( error ) ) throw error
attempt += 1 ; queueStatus . value = ` 服务暂时不可用,正在重试( ${ attempt } ) ` ; await sleep ( getTaskPollIntervalMs ( ) )
}
}
throw new Error ( '页面已关闭' )
}
async function waitForTerminal ( taskId : number ) {
while ( ! disposed ) {
try {
const exists = await refreshTaskProgress ( taskId )
if ( ! exists ) return 'FAILED'
const status = activeTaskStatus ( taskId )
if ( isTerminal ( status ) ) return status
} catch ( error ) {
if ( ! isTransientError ( error ) ) throw error
queueStatus . value = ` 任务 ${ taskId } 执行中,等待服务恢复... `
}
await sleep ( getTaskPollIntervalMs ( ) )
}
return 'STOPPED'
}
function clearActiveTask ( ) {
const taskId = activeTaskId . value
activeTaskId . value = null ; activeQueueItem . value = null ; activeDispatched . value = false
activeCountryCodes . value = [ ] ; activeZiniaoVersion . value = 'new'
if ( taskId ) pollingTaskIds . value = pollingTaskIds . value . filter ( ( id ) => id !== taskId )
saveQueueState ( )
}
function recordLocalFailure ( item : ShopDataCrawlShopItem , error : unknown ) {
historyItems . value = [ { ... item , taskStatus : 'FAILED' , success : false , error : error instanceof Error ? error . message : String ( error || '任务失败' ) , createdAt : new Date ( ) . toISOString ( ) } , ... historyItems . value ]
}
async function dispatchActiveTask ( api : NonNullable < ReturnType < typeof getPywebviewApi > > ) {
if ( ! activeTaskId . value || ! activeQueueItem . value || ! api . enqueue _json ) return false
const payload = {
type : 'shop-data-crawl-run' ,
ts : Date . now ( ) ,
data : {
taskId : activeTaskId . value ,
ziniao _version : activeZiniaoVersion . value ,
items : [ buildTaskItem ( activeQueueItem . value ) ] ,
country _codes : [ ... activeCountryCodes . value ] ,
} ,
}
const pushed = await api . enqueue _json ( payload )
if ( ! pushed ? . success ) throw new Error ( pushed ? . error || ` 任务 ${ activeTaskId . value } 入队失败 ` )
activeDispatched . value = true
saveQueueState ( )
return true
}
async function processQueue ( ) {
if ( disposed || queueWorkerRunning . value ) return
queueWorkerRunning . value = true
try {
const api = getPywebviewApi ( )
if ( ! api ? . enqueue _json ) throw new Error ( '当前客户端不支持任务队列' )
while ( ! disposed && ( activeTaskId . value || pendingQueue . value . length ) ) {
if ( activeTaskId . value ) {
if ( ! activeDispatched . value ) {
const taskId = activeTaskId . value
const item = activeQueueItem . value
try {
await dispatchActiveTask ( api )
if ( item ) removeMatchedRow ( item )
queueStatus . value = ` 任务 ${ taskId } 执行中,剩余 ${ pendingQueue . value . length } 条 `
} catch ( error ) {
await deleteShopDataCrawlTask ( taskId ) . catch ( ( ) => undefined )
if ( item ) recordLocalFailure ( item , error )
clearActiveTask ( )
queueStatus . value = ` 任务 ${ taskId } 入队失败,继续下一店 `
continue
}
}
const taskId = activeTaskId . value
const status = await waitForTerminal ( taskId )
clearActiveTask ( )
queueStatus . value = ` 任务 ${ taskId } ${ status === 'SUCCESS' || status === 'COMPLETED' ? '已完成' : '失败' } ,剩余 ${ pendingQueue . value . length } 条 `
continue
}
const next = pendingQueue . value . shift ( )
saveQueueState ( )
if ( ! next ) break
const countries = [ ... orderedCountryCodes . value ]
let created
try {
created = await retry ( ( ) => createShopDataCrawlTask ( [ buildTaskItem ( next ) ] , countries ) )
if ( ! created . taskId ) throw new Error ( '后端未返回任务 ID' )
} catch ( error ) {
if ( disposed ) return
recordLocalFailure ( next , error )
queueStatus . value = ` ${ next . shopName } 创建任务失败,继续下一店 `
continue
}
activeTaskId . value = created . taskId
activeQueueItem . value = next
activeDispatched . value = false
activeCountryCodes . value = countries
activeZiniaoVersion . value = ziniaoVersion . value
pollingTaskIds . value = [ ... new Set ( [ ... pollingTaskIds . value , created . taskId ] ) ]
const createdItems = ( created . items || [ ] ) . map ( ( item ) => ( { ... item , taskId : item . taskId || created . taskId } ) )
taskSnapshots . value = { ... taskSnapshots . value , [ created . taskId ] : { task : { id : created . taskId , status : createdItems [ 0 ] ? . taskStatus } , items : createdItems } }
historyItems . value = [ ... createdItems , ... historyItems . value . filter ( ( item ) => item . taskId !== created . taskId ) ]
saveQueueState ( )
continue
}
if ( ! disposed ) {
autoQueueEnabled . value = false
saveQueueState ( )
queueStatus . value = '串行抓取已完成'
await refreshViews ( )
ElMessage . success ( '店铺数据抓取队列已完成' )
}
} catch ( error ) {
if ( ! disposed ) { queueStatus . value = error instanceof Error ? error . message : '队列执行失败' ; ElMessage . error ( queueStatus . value ) }
} finally { queueWorkerRunning . value = false ; saveQueueState ( ) }
}
function startQueue ( ) {
const runnable = matchedRunnableItems . value
if ( ! runnable . length ) { ElMessage . warning ( '请先匹配可用店铺' ) ; return }
autoQueueEnabled . value = true
pendingQueue . value = mergeItems ( pendingQueue . value , runnable )
saveQueueState ( )
queueStatus . value = ` 已加入 ${ runnable . length } 条店铺 `
void processQueue ( )
}
async function downloadResult ( item : ShopDataCrawlHistoryItem ) {
if ( ! item . resultId ) return
const filename = item . outputFilename || ` ${ item . shopName || 'shop-data' } .xlsx `
const result = await saveUrlWithProgress ( getShopDataCrawlResultDownloadUrl ( item . resultId ) , filename , ` shop-data-crawl: ${ item . resultId } ` )
if ( result . success ) ElMessage . success ( ` 已保存: ${ result . path || filename } ` )
else if ( result . error && result . error !== '用户取消' ) ElMessage . error ( result . error )
}
async function deleteTaskRecord ( item : ShopDataCrawlHistoryItem ) {
try {
if ( ! item . taskId && ! item . resultId ) {
historyItems . value = historyItems . value . filter ( ( row ) => row !== item )
ElMessage . success ( '已删除' )
return
}
if ( item . taskId && ! isTerminal ( item . taskStatus ) ) await deleteShopDataCrawlTask ( item . taskId )
else if ( item . resultId ) await deleteShopDataCrawlHistory ( item . resultId )
else if ( item . taskId ) await deleteShopDataCrawlTask ( item . taskId )
else throw new Error ( '缺少记录标识' )
if ( item . taskId === activeTaskId . value ) clearActiveTask ( )
historyItems . value = historyItems . value . filter ( ( row ) => historyKey ( row ) !== historyKey ( item ) )
await loadDashboard ( ) ; ElMessage . success ( '已删除' )
} catch ( error ) { ElMessage . error ( error instanceof Error ? error . message : '删除失败' ) }
}
onMounted ( async ( ) => {
loadLocalState ( )
await Promise . allSettled ( [
loadCandidates ( ) , loadDashboard ( ) , loadHistory ( ) ,
getShopDataCrawlCountryPreference ( ) . then ( ( preference ) => {
if ( ! countryPrefUserTouched . value && preference . country _codes ? . length ) orderedCountryCodes . value = preference . country _codes
} ) . catch ( ( ) => undefined ) ,
] )
if ( activeTaskId . value || pendingQueue . value . length ) {
autoQueueEnabled . value = true
queueStatus . value = '检测到未完成队列,正在恢复...'
void processQueue ( )
}
} )
onUnmounted ( ( ) => {
disposed = true
if ( countryPrefSaveTimer ) timers . clearTimer ( 'preference-save' , countryPrefSaveTimer )
timers . clearScope ( )
} )
< / script >
< style scoped >
. module - page { min - height : 100 vh ; background : # 1 a1a1a ; }
. main - content { display : flex ; height : calc ( 100 vh - 56 px ) ; min - height : calc ( 100 vh - 56 px ) ; }
. left - panel { width : 400 px ; padding : 20 px ; overflow - y : auto ; border - right : 1 px solid # 2 a2a2a ; background : # 1 e1e1e ; }
. right - panel { flex : 1 ; min - width : 0 ; background : # 1 a1a1a ; }
. section - title { margin - bottom : 10 px ; color : # bbb ; font - size : 13 px ; }
. input - zone { margin - bottom : 18 px ; padding : 14 px ; border : 1 px dashed # 3 a3a3a ; border - radius : 8 px ; background : # 252525 ; }
. input - row { display : flex ; gap : 10 px ; }
. opt - btn , . btn - run { min - height : 36 px ; padding : 0 16 px ; border : 0 ; border - radius : 5 px ; background : # 409 eff ; color : # fff ; white - space : nowrap ; cursor : pointer ; }
. opt - btn { flex : 0 0 auto ; }
. opt - btn : disabled , . btn - run : disabled { opacity : .5 ; cursor : not - allowed ; }
. empty - candidates , . empty - tasks { padding : 16 px ; border : 1 px dashed # 333 ; border - radius : 6 px ; color : # 777 ; font - size : 13 px ; }
. candidate - table - scroll { margin - bottom : 18 px ; border : 1 px solid # 2 a2a2a ; border - radius : 6 px ; overflow : hidden ; }
. candidate - table { -- el - table - bg - color : # 252525 ; -- el - table - tr - bg - color : # 252525 ; -- el - table - header - bg - color : # 2 a2a2a ; -- el - table - text - color : # ccc ; -- el - table - border - color : # 333 ; }
. link - danger { border : 0 ; background : transparent ; color : # f56c6c ; cursor : pointer ; }
. country - pref - checks { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 8 px ; margin - bottom : 10 px ; }
. country - check - row { display : flex ; align - items : center ; gap : 7 px ; min - height : 32 px ; color : # ccc ; font - size : 13 px ; }
. country - check - input { width : 15 px ; height : 15 px ; }
. country - order - panel { margin - bottom : 8 px ; padding : 10 px ; border : 1 px solid # 333 ; border - radius : 6 px ; background : # 242424 ; }
. country - order - caption { margin - bottom : 8 px ; color : # 888 ; font - size : 12 px ; }
. country - order - list { display : flex ; flex - direction : column ; gap : 6 px ; }
. country - drag - row { display : flex ; align - items : center ; gap : 8 px ; min - height : 30 px ; padding : 0 9 px ; border : 1 px solid # 383838 ; border - radius : 4 px ; color : # ccc ; font - size : 13 px ; cursor : grab ; }
. country - drag - row . dragging { opacity : .5 ; }
. drag - handle { color : # 777 ; }
. country - pref - status , . queue - status { color : # 8 dc4ff ; font - size : 12 px ; line - height : 1.5 ; }
. run - row { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 10 px ; margin - top : 16 px ; }
. btn - queue { background : # 67 c23a ; }
. panel - header { height : 52 px ; padding : 16 px 22 px ; border - bottom : 1 px solid # 2 a2a2a ; color : # eee ; font - size : 15 px ; font - weight : 700 ; }
. task - list - wrap { height : calc ( 100 % - 52 px ) ; padding : 18 px 22 px 28 px ; overflow - y : auto ; }
. clean - result - summary { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 100 px , 1 fr ) ) ; gap : 12 px ; margin - bottom : 20 px ; }
. summary - card { display : flex ; min - height : 70 px ; flex - direction : column ; justify - content : center ; padding : 12 px 16 px ; border : 1 px solid # 303030 ; border - radius : 6 px ; background : # 222 ; }
. summary - label { margin - bottom : 4 px ; color : # 888 ; font - size : 12 px ; }
. summary - card strong { color : # fff ; font - size : 22 px ; }
. subsection - title , . result - subsection - title { margin : 18 px 0 10 px ; color : # bbb ; font - size : 13 px ; font - weight : 700 ; }
. match - table { margin - bottom : 20 px ; -- el - table - bg - color : # 222 ; -- el - table - tr - bg - color : # 222 ; -- el - table - header - bg - color : # 292929 ; -- el - table - text - color : # ccc ; -- el - table - border - color : # 333 ; }
. ok { color : # 67 c23a ; } . fail , . error - text { color : # f56c6c ; }
. result - list - header { padding : 13 px 0 ; border - bottom : 1 px solid # 303030 ; color : # eee ; font - weight : 700 ; }
. task - list { margin : 0 ; padding : 0 ; list - style : none ; }
: deep ( . task - item ) { display : flex ; align - items : center ; justify - content : space - between ; gap : 18 px ; min - height : 84 px ; padding : 14 px 0 ; border - bottom : 1 px solid # 2 b2b2b ; }
: deep ( . split - result - main ) { min - width : 0 ; }
: deep ( . id ) { display : block ; margin - bottom : 5 px ; color : # eee ; font - weight : 700 ; }
: deep ( . files ) { margin - top : 3 px ; color : # 888 ; font - size : 12 px ; }
: deep ( . task - right ) { display : flex ; align - items : center ; gap : 10 px ; }
: deep ( . status ) { min - width : 52 px ; font - size : 12 px ; text - align : center ; }
: deep ( . status . success ) { color : # 67 c23a ; } : deep ( . status . failed ) { color : # f56c6c ; } : deep ( . status . running ) { color : # e6a23c ; }
: deep ( . download ) , : deep ( . btn - delete ) { padding : 5 px 10 px ; border : 1 px solid # 444 ; border - radius : 4 px ; background : transparent ; color : # ccc ; cursor : pointer ; }
: deep ( . download ) { border - color : # 409 eff ; color : # 8 dc4ff ; } : deep ( . btn - delete ) { color : # f56c6c ; }
@ media ( max - width : 900 px ) { . main - content { height : auto ; flex - direction : column ; } . left - panel { width : 100 % ; border - right : 0 ; } . clean - result - summary { grid - template - columns : repeat ( 2 , 1 fr ) ; } }
< / style >