@@ -0,0 +1,694 @@
< script setup lang = "ts" >
/**
* 店铺数据重复检查 · 撞款控制台(reference asin-store-console 后台化,自家样式)。
* 数据面:/duplicate-check-console(指标/店铺/撞款全集/分组统计) + /duplicate-check-ledger(台账分页) + /duplicate-check-export(CSV)。
*/
import { computed , onMounted , reactive , ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { DuplicateItem , DuplicateOccurrence , DuplicateMetrics } from './duplicate-model.ts'
import { fetchDuplicateConsole , fetchDuplicateLedger , requestDuplicateCsvBlob , type LedgerSortKey } from './duplicate-console-api.ts'
import { aggregateDrawerRows , drawerSummaryOf , dupWithinGroup , indexShopGroups , type DrawerAggRow , type DrawerSummary , type ShopGroups } from './duplicate-console-logic.ts'
import type { DuplicateConsole , LedgerPage , LedgerRow } from './duplicate-console-model.ts'
import { ASIN _COUNTRY _CODES , asinCountryLabel } from '../asin/asin-country.ts'
type Tab = 'dup' | 'ledger'
type Scope = 'global' | 'group'
const consoleData = ref < DuplicateConsole | null > ( null )
const loading = ref ( false )
const loadError = ref ( '' )
const reanalyzing = ref ( false )
const filter = reactive ( { asin : '' , shop : '' , group : '' , country : '' , dateRange : [ ] as string [ ] } )
const activeTab = ref < Tab > ( 'dup' )
const scope = ref < Scope > ( 'global' )
const selectedGroup = ref ( '' )
const shopGroups = ref < ShopGroups > ( { members : { } , shopGroupNames : { } } )
const ledgerItems = ref < LedgerRow [ ] > ( [ ] )
const ledgerTotal = ref ( 0 )
const ledgerPage = ref ( 1 )
const ledgerLoading = ref ( false )
const sortState = reactive < { key : LedgerSortKey | '' ; dir : 'asc' | 'desc' } > ( { key : 'earliest' , dir : 'asc' } )
const drawerVisible = ref ( false )
const drawerTitle = ref ( '' )
const drawerBrand = ref ( '' )
const drawerDup = ref ( false )
const drawerOccurrences = ref < DuplicateOccurrence [ ] > ( [ ] )
/** 明细抽屉:店铺×站点聚合 + 汇总徽章(对齐 reference 抽屉的 次数/国家chips/最早上架)。 */
const drawerAggRows = computed < DrawerAggRow [ ] > ( ( ) => aggregateDrawerRows ( drawerOccurrences . value ) )
const drawerSummary = computed < DrawerSummary > ( ( ) => drawerSummaryOf ( drawerOccurrences . value ) )
const groupInfoVisible = ref ( false )
const overview = computed ( ( ) => consoleData . value ? . overview ? ? null )
const summary = computed < DuplicateMetrics | null > ( ( ) => overview . value ? . summary ? ? null )
const shops = computed ( ( ) => overview . value ? . shops ? ? [ ] )
const dupItems = computed ( ( ) => consoleData . value ? . dup ? ? [ ] )
const groupStats = computed ( ( ) => consoleData . value ? . groups ? ? [ ] )
const countryOptions = computed < string [ ] > ( ( ) => {
const codes = new Set < string > ( ASIN _COUNTRY _CODES as readonly string [ ] )
for ( const shop of shops . value ) for ( const code of shop . countryCodes || [ ] ) codes . add ( code . toUpperCase ( ) )
for ( const item of dupItems . value ) for ( const occ of item . occurrences ) if ( occ . country ) codes . add ( occ . country . toUpperCase ( ) )
return [ ... codes ]
} )
const kpis = computed ( ( ) => {
const s = summary . value
const totalDup = dupItems . value . length
const groupDupTotal = groupStats . value . reduce ( ( sum , group ) => sum + group . dupCount , 0 )
const siteCount = s ? . siteCount ? ? 0
return [
{ label : '唯一ASIN' , value : ( s ? . asinTotal ? ? 0 ) . toLocaleString ( ) , sub : '去重后' , danger : false } ,
{ label : '上架记录' , value : ( s ? . recordTotal ? ? 0 ) . toLocaleString ( ) , sub : '含重复扫描' , danger : false } ,
{ label : '在售店铺' , value : ( s ? . shopCount ? ? 0 ) . toLocaleString ( ) , sub : '本批数据' , danger : false } ,
{ label : '分组' , value : groupStats . value . length . toLocaleString ( ) , sub : groupStats . value . map ( ( group ) => group . name ) . join ( ' / ' ) || '未配置' , danger : false } ,
{ label : '全店撞款' , value : ( s ? . duplicateAsinTotal ? ? totalDup ) . toLocaleString ( ) , sub : '≥2店在售' , danger : true } ,
{ label : '组内撞款' , value : groupDupTotal . toLocaleString ( ) , sub : '分组内≥2店' , danger : groupDupTotal > 0 } ,
{ label : '覆盖站点' , value : siteCount . toLocaleString ( ) , sub : countryOptions . value . map ( asinCountryLabel ) . join ( ' / ' ) , danger : false } ,
]
} )
const storeDistribution = computed ( ( ) => {
const list = shops . value . map ( ( shop ) => ( { name : shop . shopName , group : shop . groupName , records : shop . recordCount , asins : shop . asinCount } ) )
const max = Math . max ( 1 , ... list . map ( ( s ) => s . records ) )
return { max , list }
} )
function dateFrom ( ) : string {
return filter . dateRange ? . [ 0 ] || ''
}
function dateTo ( ) : string {
return filter . dateRange ? . [ 1 ] || ''
}
function activeFilterParams ( ) {
return { asin : filter . asin . trim ( ) , shopName : filter . shop . trim ( ) , group : filter . group . trim ( ) , country : filter . country , site : '' , dateFrom : dateFrom ( ) , dateTo : dateTo ( ) }
}
function selectedGroupMembers ( ) : string [ ] {
if ( ! selectedGroup . value ) return [ ]
return shopGroups . value . members [ selectedGroup . value ] || [ ]
}
const groupChips = computed ( ( ) => groupStats . value . filter ( ( group ) => group . shopCount >= 2 ) )
/** 当前撞款行集:全局=撞款全集;分组=组内成员撞款子集。 */
const dupRows = computed < DuplicateItem [ ] > ( ( ) => {
if ( scope . value === 'global' ) return dupItems . value
return dupWithinGroup ( dupItems . value , selectedGroupMembers ( ) )
} )
/** 矩阵列:分组范围=该组成员店;全店=全部可见店铺。 */
const matrixShops = computed < string [ ] > ( ( ) => {
if ( scope . value === 'group' ) return selectedGroupMembers ( )
return shops . value . map ( ( shop ) => shop . shopName )
} )
const activeGroupStat = computed ( ( ) => groupStats . value . find ( ( group ) => group . name === selectedGroup . value ) || null )
/** 撞款 Tab 计数:全店范围取后端权威 total_dup(防后端截断计数偏小);分组范围取组内子集长度。 */
const totalDupCount = computed ( ( ) => ( scope . value === 'global' ? ( consoleData . value ? . totalDup ? ? dupRows . value . length ) : dupRows . value . length ) )
function itemBrand ( item : DuplicateItem ) : string {
for ( const occ of item . occurrences || [ ] ) if ( occ . brand ) return occ . brand
return ''
}
/* ----- 矩阵单元格辅助 ----- */
function shopKey ( name : string ) : string {
return ( name || '' ) . trim ( ) . toLowerCase ( )
}
function cellOccurrences ( item : DuplicateItem , shop : string ) : DuplicateOccurrence [ ] {
const key = shopKey ( shop )
return ( item . occurrences || [ ] ) . filter ( ( occ ) => shopKey ( occ . shopName ) === key )
}
function cellCount ( item : DuplicateItem , shop : string ) : number {
return cellOccurrences ( item , shop ) . length
}
function cellCountryDots ( item : DuplicateItem , shop : string ) : string [ ] {
const codes = new Set < string > ( )
for ( const occ of cellOccurrences ( item , shop ) ) if ( occ . country ) codes . add ( occ . country . toUpperCase ( ) )
return [ ... codes ]
}
function cellCountryCount ( item : DuplicateItem , shop : string , code : string ) : number {
return cellOccurrences ( item , shop ) . filter ( ( occ ) => ( occ . country || '' ) . toUpperCase ( ) === code ) . length
}
function cellTitle ( item : DuplicateItem , shop : string ) : string {
const count = cellCount ( item , shop )
if ( ! count ) return ` ${ shop } 无此 ASIN `
const sites = cellCountryDots ( item , shop ) . map ( ( code ) => ` ${ asinCountryLabel ( code ) } ${ cellCountryCount ( item , shop , code ) } 条 ` ) . join ( '、' )
return ` ${ shop } 在售 ${ count } 条 · ${ sites } `
}
async function loadConsole ( ) {
loading . value = true
loadError . value = ''
try {
consoleData . value = await fetchDuplicateConsole ( { view : '' , ... activeFilterParams ( ) } )
shopGroups . value = indexShopGroups ( shops . value )
if ( groupStats . value . length && ! groupStats . value . some ( ( group ) => group . name === selectedGroup . value ) ) {
selectedGroup . value = groupChips . value [ 0 ] ? groupChips . value [ 0 ] . name : ''
}
} catch ( error ) {
loadError . value = error instanceof Error ? error . message : '撞款数据加载失败'
ElMessage . error ( loadError . value )
} finally {
loading . value = false
}
}
async function loadLedger ( page : number , resetSort = false ) {
ledgerLoading . value = true
try {
if ( resetSort ) {
sortState . key = 'earliest'
sortState . dir = 'asc'
}
const result : LedgerPage = await fetchDuplicateLedger ( { view : '' , ... activeFilterParams ( ) } , page , 100 , sortState . key , sortState . dir )
ledgerItems . value = result . items
ledgerTotal . value = result . total
ledgerPage . value = result . page
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '台账加载失败' )
} finally {
ledgerLoading . value = false
}
}
function applyQuery ( ) {
if ( dateFrom ( ) && dateTo ( ) && dateFrom ( ) > dateTo ( ) ) {
ElMessage . warning ( '开始日期不能晚于结束日期' )
return
}
loadConsole ( )
if ( activeTab . value === 'ledger' ) loadLedger ( 1 )
}
function resetQuery ( ) {
filter . asin = ''
filter . shop = ''
filter . group = ''
filter . country = ''
filter . dateRange = [ ]
scope . value = 'global'
selectedGroup . value = ''
activeTab . value = 'dup'
applyQuery ( )
}
function switchTab ( tab : Tab ) {
activeTab . value = tab
if ( tab === 'ledger' ) loadLedger ( 1 , true )
}
function switchScope ( next : Scope ) {
scope . value = next
if ( next === 'group' && ! selectedGroup . value && groupChips . value . length ) selectedGroup . value = groupChips . value [ 0 ] . name
}
async function reAnalyze ( ) {
reanalyzing . value = true
try {
await fetchDuplicateConsole ( { view : '' , asin : '' , shopName : '' , country : '' , site : '' , dateFrom : '' , dateTo : '' } )
await loadConsole ( )
ElMessage . success ( '重新分析完成,已同步最新扫描结果' )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '重新分析失败' )
} finally {
reanalyzing . value = false
}
}
interface ShopSiteBlock {
shopName : string
groupName : string
sites : { country : string ; times : string [ ] } [ ]
count : number
}
/** occurrence 按 店铺 → 站点 → 时间 汇总成撞款卡结构。 */
function shopBlocks ( item : DuplicateItem , restrictShops : string [ ] = [ ] ) : ShopSiteBlock [ ] {
const restrict = new Set ( restrictShops . map ( shopKey ) )
const order : string [ ] = [ ]
const blocks = new Map < string , ShopSiteBlock > ( )
for ( const occ of item . occurrences || [ ] ) {
const shop = occ . shopName || ''
if ( restrict . size && ! restrict . has ( shopKey ( shop ) ) ) continue
let block = blocks . get ( shop )
if ( ! block ) {
block = { shopName : shop , groupName : occ . groupName || '' , sites : [ ] , count : 0 }
blocks . set ( shop , block )
order . push ( shop )
}
const country = occ . country || ''
let site = block . sites . find ( ( s ) => s . country === country )
if ( ! site ) {
site = { country , times : [ ] }
block . sites . push ( site )
}
if ( occ . date ) site . times . push ( occ . date )
block . count ++
}
return order . map ( ( shop ) => blocks . get ( shop ) as ShopSiteBlock )
}
function openDrawer ( asin : string , brand : string , dupFlag : boolean , occurrences : DuplicateOccurrence [ ] ) {
drawerTitle . value = asin
drawerBrand . value = brand
drawerDup . value = dupFlag
drawerOccurrences . value = [ ... occurrences ]
drawerVisible . value = true
}
function openDupDrawer ( item : DuplicateItem ) {
openDrawer ( item . asin , itemBrand ( item ) , item . shopCount >= 2 , item . occurrences )
}
function openLedgerDrawer ( row : LedgerRow ) {
openDrawer ( row . asin , row . brand , row . storeCount >= 2 , row . occurrences )
}
function onLedgerSort ( change : { prop : string ; order : string | null } ) {
const key = change . prop as LedgerSortKey
if ( ! change . order || ! key ) return
sortState . key = key
sortState . dir = change . order === 'descending' ? 'desc' : 'asc'
loadLedger ( 1 )
}
async function exportCsv ( ) {
let view = 'monitor'
const params = activeFilterParams ( )
if ( activeTab . value === 'ledger' ) {
view = 'all'
} else if ( scope . value === 'group' && selectedGroup . value ) {
params . shopName = selectedGroup . value
}
try {
const blob = await requestDuplicateCsvBlob ( { view , ... params } , view )
saveBlob ( blob , ` 店铺数据_ ${ activeTab . value === 'ledger' ? '台账' : '撞款' } _ ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .csv ` )
ElMessage . success ( '已导出 CSV' )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '导出失败' )
}
}
function saveBlob ( blob : Blob , filename : string ) {
const url = URL . createObjectURL ( blob )
const anchor = document . createElement ( 'a' )
anchor . href = url
anchor . download = filename
anchor . click ( )
URL . revokeObjectURL ( url )
}
function siteColor ( code : string ) : string {
const map : Record < string , string > = { UK : '#2E5BE6' , DE : '#D9912B' , FR : '#7C5CD6' , ES : '#1F9D62' , IT : '#D63A4A' }
return map [ code . toUpperCase ( ) ] || '#98A1B1'
}
function displayDate ( value : string ) : string {
return ( value || '' ) . slice ( 0 , 16 )
}
onMounted ( ( ) => {
loadConsole ( )
// 台账 Tab 计数打开即显示:挂载时预取第一页拿 total(对齐 reference 渲染即见台账条数)。
void loadLedger ( 1 )
} )
< / script >
< template >
< div class = "page-stack dup-console" >
< div class = "page-heading" >
< div >
< h2 > 店铺数据撞款监控 < / h2 >
< p > 按 ASIN × 店铺 × 站点分析同一 ASIN 在多家自营店铺 / 分组内的重复上架 ; 分组归属来自店铺管理 , 只读展示 。 < / p >
< / div >
< div class = "actions" >
< span v-if = "overview" class="updated" > 数据更新时间 {{ overview.scannedAt | | ' — ' }} < / span >
< el -button :loading = "reanalyzing" @click ="reAnalyze" > 重新分析 < / el -button >
< el-button @click ="exportCsv" > 导出CSV < / el -button >
< / div >
< / div >
< el-alert v-if = "loadError" type="error" :title="loadError" :closable="false" show-icon style="margin-bottom: 12px" / >
< el-card shadow = "never" style = "margin-bottom: 14px" >
< div class = "filter-grid" >
< div class = "f-item" >
< label > ASIN < / label >
< el-input v-model = "filter.asin" placeholder="模糊搜索ASIN" clearable @keyup.enter="applyQuery" / >
< / div >
< div class = "f-item" >
< label > 店铺 < / label >
< el-select v-model = "filter.shop" placeholder="全部店铺" clearable filterable >
< el-option v-for = "shop in shops" :key="shop.shopName" :label="shop.shopName" :value="shop.shopName" / >
< / el-select >
< / div >
< div class = "f-item" >
< label > 分组 < / label >
< el-select v-model = "filter.group" placeholder="全部分组" clearable filterable >
< el-option v-for = "group in groupStats" :key="group.name" :label="group.name" :value="group.name" / >
< / el-select >
< / div >
< div class = "f-item" >
< label > 国家 / 站点 < / label >
< el-select v-model = "filter.country" placeholder="全部国家" clearable >
< el-option v-for = "code in countryOptions" :key="code" :label="asinCountryLabel(code)" :value="code" / >
< / el-select >
< / div >
< div class = "f-item wide" >
< label > 上架时间 < / label >
< el-date-picker v-model = "filter.dateRange" type="daterange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD" unlink -panels / >
< / div >
< div class = "f-item btn-row" >
< el-button type = "primary" @click ="applyQuery" > 查询 < / el -button >
< el-button @click ="resetQuery" > 重置 < / el -button >
< / div >
< / div >
< / el-card >
< template v-if = "consoleData && !loading" >
< ! - - KPI - - >
< div class = "kpi-row" >
< div v-for = "kpi in kpis" :key="kpi.label" class="kpi-card" :class="{ danger: kpi.danger }" >
< div class = "k-label" > { { kpi . label } } < / div >
< div class = "k-value" > { { kpi . value } } < / div >
< div class = "k-sub" > { { kpi . sub } } < / div >
< / div >
< / div >
<!-- 店铺上架分布 -- >
< el-card shadow = "never" style = "margin-bottom: 14px" >
< div class = "card-title" > 店铺上架分布 < span class = "hint" > 各店铺在本批站点中的上架记录量 < / span > < / div >
< div class = "store-bars" >
< div v-for = "bar in storeDistribution.list" :key="bar.name" class="s-bar" >
< div class = "s-name" >
< span > { { bar . name } } < span v-if = "bar.group" class="s-group" > · {{ bar.group }} < / span > < / span >
< b > { { bar . records . toLocaleString ( ) } } < / b >
< / div >
< div class = "s-track" > < div class = "s-fill" : style = "{ width: Math.round((bar.records / storeDistribution.max) * 100) + '%' }" > < / div > < / div >
< div class = "s-meta" > { { bar . asins . toLocaleString ( ) } } 个 ASIN < / div >
< / div >
< / div >
< / el-card >
<!-- 主区 Tabs -- >
< div class = "tab-bar" >
< div class = "tabs" >
< button class = "tab" : class = "{ active: activeTab === 'dup' }" @click ="switchTab('dup')" > 撞款监控 < span class = "t-count" > { { totalDupCount } } < / span > < / button >
< button class = "tab" : class = "{ active: activeTab === 'ledger' }" @click ="switchTab('ledger')" > 全部ASIN台账 < span class = "t-count" > { { ledgerTotal } } < / span > < / button >
< / div >
< el-button text type = "primary" @click ="groupInfoVisible = true" > 分组说明 < / el -button >
< / div >
<!-- === === === === 撞款监控 === === === === -- >
< template v-if = "activeTab === 'dup'" >
< div class = "scope-bar" >
< div class = "seg" >
< button class = "seg-btn" : class = "{ active: scope === 'global' }" @click ="switchScope('global')" > 全店撞款 < / button >
< button class = "seg-btn" : class = "{ active: scope === 'group' }" @click ="switchScope('group')" > 分组撞款 < / button >
< / div >
< div v-if = "scope === 'group'" class="group-chips" >
< template v-if = "groupChips.length" >
< button v-for = "group in groupChips" :key="group.name" class="g-chip" :class="{ active: selectedGroup === group.name, dup: group.dupCount > 0 }" @click="selectedGroup = group.name" >
{{ group.name }} < span class = "g-count" > { { group . shopCount } } 店 · { { group . dupCount } } 撞款 < / span >
< / button >
< / template >
< span v-else class = "dim" > 暂无 ≥ 2 家店铺的分组 , 无法做分组撞款分析 < / span >
< / div >
< / div >
< div v-if = "scope === 'group' && activeGroupStat" class="group-summary" >
< div class = "gs-item" > < div class = "gs-v" > { { activeGroupStat . shopCount } } < / div > < div class = "gs-l" > 组员店铺 < / div > < / div >
< div class = "gs-item" > < div class = "gs-v" > { { activeGroupStat . asinUnique . toLocaleString ( ) } } < / div > < div class = "gs-l" > 组内唯一ASIN < / div > < / div >
< div class = "gs-item" > < div class = "gs-v" > { { activeGroupStat . recordCount . toLocaleString ( ) } } < / div > < div class = "gs-l" > 组内上架记录 < / div > < / div >
< div class = "gs-item" : class = "{ warn: activeGroupStat.dupCount > 0 }" > < div class = "gs-v" > { { activeGroupStat . dupCount } } < / div > < div class = "gs-l" > 组内撞款ASIN < / div > < / div >
< / div >
< div class = "section-title" > 撞款覆盖矩阵 < span class = "hint" > 行 = ASIN , 列 = 店铺 ; 数字为该店在售此 ASIN 的记录量 , 点击行看明细 < / span > < / div >
< div class = "matrix-wrap" >
< table v-if = "dupRows.length" class="matrix" >
< thead >
< tr >
< th > ASIN < / th >
< th > 品牌 < / th >
< th v-for = "shop in matrixShops" :key="shop" style="text-align: center" > {{ shop }} < / th >
< / tr >
< / thead >
< tbody >
< tr v-for = "item in dupRows" :key="item.asin" @click="openDupDrawer(item)" >
< td class = "asin-cell" > { { item . asin } } < / td >
< td class = "brand-cell" > { { itemBrand ( item ) || '—' } } < / td >
< td v-for = "shop in matrixShops" :key="shop" style="text-align: center" >
< div class = "m-cell" : class = "{ empty: cellCount(item, shop) === 0 }" : title = "cellTitle(item, shop)" >
{ { cellCount ( item , shop ) || '' } }
< span class = "dots" > < span v-for = "code in cellCountryDots(item, shop)" :key="code" class="m-dot" :style="{ background: siteColor(code) }" > < / span > < / span >
< / div >
< / td >
< / tr >
< / tbody >
< / table >
< el -empty v-else description = "当前范围下没有撞款ASIN" / >
< / div >
< div class = "section-title" style = "margin-top: 22px" > 撞款详情 < span class = "hint" > 同一 ASIN 在多家店铺的上架时间明细 < / span > < / div >
< div v-if = "dupRows.length" class="dup-cards" >
< div v-for = "item in dupRows" :key="item.asin" class="dup-card" >
< div class = "dup-head" >
< span class = "dup-asin" > { { item . asin } } < / span >
< span class = "badge dup" > { { item . shopCount } } 店在售 < / span >
< span class = "dup-brand" > { { itemBrand ( item ) || '未知品牌' } } < / span >
< el-button text type = "primary" size = "small" @click ="openDupDrawer(item)" > 查看明细 < / el -button >
< / div >
< div class = "dup-body" >
< div v-for = "block in shopBlocks(item)" :key="block.shopName" class="store-col" >
< div class = "st-name" > { { block . shopName } } < span class = "st-count" > { { block . count } } 条 < / span > < / div >
< div v-if = "block.groupName" class="st-grp" > 分组 : {{ block.groupName }} < / div >
< div v-for = "site in block.sites" :key="site.country" class="c-site" >
< div class = "c-site-head" >
< span class = "m-dot" : style = "{ background: siteColor(site.country) }" > < / span > { { asinCountryLabel ( site . country ) } } · { { site . times . length } } 次
< / div >
< div class = "c-times" >
< span v-for = "(time, index) in site.times" :key="index" class="c-time" > {{ time }} < / span >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< el -empty v-else description = "当前范围下没有撞款ASIN" / >
< / template >
<!-- === === === === 全部ASIN台账 === === === === -- >
< template v-else >
< el -card shadow = "never" >
< el-table v-loading = "ledgerLoading" :data="ledgerItems" stripe border @sort-change="onLedgerSort" >
< el -table -column prop = "asin" label = "ASIN" min -width = " 140 " sortable = "custom" >
< template # default = "{ row }" >
< el-link type = "primary" @click ="openLedgerDrawer(row as LedgerRow)" > {{ row.asin }} < / el -link >
< / template >
< / el-table-column >
< el-table-column prop = "brand" label = "品牌" min -width = " 120 " sortable = "custom" / >
< el-table-column prop = "storeCount" label = "上架店铺" width = "110" sortable = "custom" align = "center" >
< template # default = "{ row }" >
< el-tag : type = "(row as LedgerRow).storeCount >= 2 ? 'danger' : 'success'" size = "small" > { { ( row as LedgerRow ) . storeCount } } 店 < / el-tag >
< / template >
< / el-table-column >
< el-table-column label = "涉及店铺" min -width = " 190 " >
< template # default = "{ row }" >
< el-tag v-for = "store in (row as LedgerRow).stores" :key="store" size="small" class="tag-gap" type="info" effect="plain" > {{ store }} < / el -tag >
< / template >
< / el-table-column >
< el-table-column label = "分组" min -width = " 120 " >
< template # default = "{ row }" >
< template v-if = "(row as LedgerRow).groups.length" >
< el -tag v-for = "group in (row as LedgerRow).groups" :key="group" size="small" class="tag-gap" > {{ group }} < / el -tag >
< / template >
< span v-else class = "dim" > — < / span >
< / template >
< / el-table-column >
< el-table-column label = "站点" min -width = " 130 " >
< template # default = "{ row }" >
< span v-for = "code in (row as LedgerRow).countries" :key="code" class="site-chip" :style="{ background: siteColor(code) + '1F', color: siteColor(code) }" > {{ asinCountryLabel ( code ) }} < / span >
< / template >
< / el -table -column >
< el-table-column prop = "recordCount" label = "上架次数" width = "100" sortable = "custom" align = "center" / >
< el-table-column prop = "earliest" label = "最早上架" min -width = " 165 " sortable = "custom" >
< template # default = "{ row }" > { { displayDate ( ( row as LedgerRow ) . earliest ) } } < / template >
< / el-table-column >
< el-table-column prop = "latest" label = "最近上架" min -width = " 165 " sortable = "custom" >
< template # default = "{ row }" > { { displayDate ( ( row as LedgerRow ) . latest ) } } < / template >
< / el-table-column >
< el-table-column label = "操作" width = "90" fixed = "right" >
< template # default = "{ row }" >
< el-button text type = "primary" size = "small" @click ="openLedgerDrawer(row as LedgerRow)" > 明细 < / el -button >
< / template >
< / el-table-column >
< / el-table >
< div class = "table-footer" >
< span > 共 < b > { { ledgerTotal . toLocaleString ( ) } } < / b > 条 < / span >
< el-pagination background layout = "prev, pager, next, jumper" :total = "ledgerTotal" :page-size = "100" :current-page = "ledgerPage" @ current -change = " ( page : number ) = > loadLedger ( page ) " />
</div>
</el-card>
</template>
</template>
<div v-else-if=" ! loadError && ! loading " class=" empty - state " v-loading=" loading ">
<el-empty description=" 暂无扫描结果 , 请点击右上角 「 重新分析 」 触发撞款扫描 " />
</div>
</div>
<!-- 明细抽屉 -->
<el-drawer v-model=" drawerVisible " :title=" drawerTitle " size=" 600 px ">
<div class=" drawer - head ">
<span v-if=" drawerDup " class=" badge dup ">撞款</span>
<span class=" d - brand ">{{ drawerTitle }} · 品牌:{{ drawerBrand || '未知' }}</span>
</div>
<div v-if=" drawerOccurrences . length " class=" drawer - badges ">
<span class=" badge store ">{{ drawerSummary.shopCount }} 家店铺</span>
<span v-for=" code in drawerSummary . countries " :key=" code " class=" chip " :style=" { background : siteColor ( code ) } ">{{ asinCountryLabel(code) }}</span>
<span class=" badge count ">{{ drawerSummary.totalRecords }} 次上架</span>
<span class=" badge time ">最早上架 {{ drawerSummary.earliest }}</span>
</div>
<el-table v-if=" drawerAggRows . length " :data=" drawerAggRows " border size=" small ">
<el-table-column prop=" shopName " label=" 店铺 " min-width=" 130 " />
<el-table-column label=" 分组 " min-width=" 90 ">
<template #default=" { row } ">{{ (row as DrawerAggRow).groupName || '—' }}</template>
</el-table-column>
<el-table-column label=" 站点 " min-width=" 80 ">
<template #default=" { row } ">{{ asinCountryLabel((row as DrawerAggRow).country || '') }}</template>
</el-table-column>
<el-table-column label=" 上架时间 ( 升序 ) " min-width=" 170 ">
<template #default=" { row } ">
<div v-for=" ( time , index ) in ( row as DrawerAggRow ) . times " :key=" index " class=" drawer - time ">{{ time }}</div>
</template>
</el-table-column>
<el-table-column label=" 次数 " min-width=" 70 " align=" center ">
<template #default=" { row } ">{{ (row as DrawerAggRow).count }}</template>
</el-table-column>
</el-table>
<el-empty v-else description=" 暂无上架时间明细 " />
</el-drawer>
<!-- 分组说明(只读) -->
<el-drawer v-model=" groupInfoVisible " title=" 分组撞款说明 " size=" 520 px ">
<el-alert type=" info " :closable=" false " show-icon title=" 分组归属来自 「 店铺管理 」 的真实店铺分组 , 仅用于本页分组撞款范围切换 ; 本页不做分组编辑 。 如需调整店铺所属分组 , 请到店铺管理维护 ( 会影响对应负责人的数据查看范围 ) 。 " />
<el-table :data=" groupStats " border style=" margin - top : 14 px ">
<el-table-column prop=" name " label=" 分组 " min-width=" 110 " />
<el-table-column label=" 店铺数 " width=" 80 " align=" center ">
<template #default=" { row } ">{{ (row as { shopCount: number }).shopCount }}</template>
</el-table-column>
<el-table-column label=" 成员店铺 " min-width=" 200 ">
<template #default=" { row } ">
<el-tag v-for=" shop in ( shopGroups . members [ ( row as { name : string } ) . name ] || [ ] ) " :key=" shop " size=" small " class=" tag - gap " effect=" plain ">{{ shop }}</el-tag>
</template>
</el-table-column>
<el-table-column label=" 组内撞款 " width=" 90 " align=" center ">
<template #default=" { row } " > { { ( row as { dupCount : number } ) . dupCount } } < / template >
< / el-table-column >
< / el-table >
< / el-drawer >
< / template >
< style scoped >
. dup - console { max - width : 1560 px ; }
. page - heading { display : flex ; justify - content : space - between ; align - items : flex - start ; gap : 12 px ; flex - wrap : wrap ; }
. page - heading . actions { display : flex ; align - items : center ; gap : 10 px ; }
. updated { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. filter - grid { display : flex ; flex - wrap : wrap ; gap : 12 px 18 px ; }
. f - item { display : flex ; flex - direction : column ; gap : 6 px ; width : 170 px ; }
. f - item label { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. f - item . wide { width : 320 px ; }
. f - item . btn - row { flex - direction : row ; align - items : flex - end ; gap : 8 px ; width : auto ; }
. kpi - row { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 150 px , 1 fr ) ) ; gap : 12 px ; margin - bottom : 14 px ; }
. kpi - card { background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - lighter ) ; border - radius : 10 px ; padding : 12 px 16 px ; box - shadow : var ( -- el - box - shadow - lighter ) ; }
. k - label { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; margin - bottom : 6 px ; }
. k - value { font - size : 26 px ; font - weight : 700 ; font - variant - numeric : tabular - nums ; }
. kpi - card . danger . k - value { color : var ( -- el - color - danger ) ; }
. k - sub { color : var ( -- el - text - color - secondary ) ; font - size : 11 px ; margin - top : 3 px ; }
. card - title { font - size : 13 px ; font - weight : 600 ; margin - bottom : 12 px ; display : flex ; gap : 8 px ; align - items : baseline ; }
. hint { font - size : 11 px ; font - weight : 400 ; color : var ( -- el - text - color - secondary ) ; }
. store - bars { display : grid ; grid - template - columns : repeat ( auto - fill , minmax ( 210 px , 1 fr ) ) ; gap : 16 px ; }
. s - name { display : flex ; justify - content : space - between ; font - size : 12 px ; color : var ( -- el - text - color - secondary ) ; margin - bottom : 5 px ; }
. s - name b { color : var ( -- el - text - color - primary ) ; font - weight : 600 ; }
. s - group { color : var ( -- el - text - color - secondary ) ; }
. s - track { height : 8 px ; background : var ( -- el - fill - color - light ) ; border - radius : 5 px ; overflow : hidden ; }
. s - fill { height : 100 % ; border - radius : 5 px ; background : linear - gradient ( 90 deg , # 6366 f1 , # 8 b9cf6 ) ; }
. s - meta { font - size : 11 px ; color : var ( -- el - text - color - secondary ) ; margin - top : 5 px ; }
. tab - bar { display : flex ; justify - content : space - between ; align - items : center ; margin : 4 px 0 14 px ; }
. tabs { display : flex ; gap : 4 px ; background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - lighter ) ; border - radius : 9 px ; padding : 4 px ; }
. tab { padding : 7 px 18 px ; border - radius : 7 px ; font - size : 13 px ; font - weight : 600 ; color : var ( -- el - text - color - regular ) ; cursor : pointer ; background : transparent ; border : none ; display : inline - flex ; gap : 7 px ; align - items : center ; }
. tab : hover { color : var ( -- el - color - primary ) ; }
. tab . active { background : var ( -- el - color - primary ) ; color : # fff ; }
. t - count { background : rgba ( 0 , 0 , 0 , 0.12 ) ; border - radius : 10 px ; padding : 0 7 px ; font - size : 11 px ; }
. tab . active . t - count { background : rgba ( 255 , 255 , 255 , 0.25 ) ; }
. scope - bar { display : flex ; align - items : center ; gap : 16 px ; margin - bottom : 14 px ; flex - wrap : wrap ; }
. seg { display : flex ; gap : 4 px ; background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - lighter ) ; border - radius : 9 px ; padding : 4 px ; }
. seg - btn { padding : 7 px 16 px ; border - radius : 7 px ; font - size : 13 px ; font - weight : 600 ; color : var ( -- el - text - color - regular ) ; border : none ; background : transparent ; cursor : pointer ; }
. seg - btn : hover { color : var ( -- el - color - primary ) ; }
. seg - btn . active { background : var ( -- el - color - primary ) ; color : # fff ; }
. group - chips { display : flex ; gap : 8 px ; flex - wrap : wrap ; align - items : center ; }
. g - chip { padding : 7 px 14 px ; border - radius : 20 px ; font - size : 12.5 px ; font - weight : 600 ; background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color ) ; color : var ( -- el - text - color - regular ) ; cursor : pointer ; transition : all 0.15 s ; }
. g - chip : hover { border - color : var ( -- el - color - primary ) ; color : var ( -- el - color - primary ) ; }
. g - chip . active { background : var ( -- el - color - primary ) ; border - color : var ( -- el - color - primary ) ; color : # fff ; }
. g - chip . dup : not ( . active ) { border - color : # f3c6cc ; background : # fdebed ; color : # d63a4a ; }
. g - count { font - size : 11 px ; opacity : 0.85 ; }
. dim { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. group - summary { display : grid ; grid - template - columns : repeat ( 4 , 1 fr ) ; background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - lighter ) ; border - radius : 10 px ; margin - bottom : 16 px ; overflow : hidden ; }
. gs - item { padding : 14 px 20 px ; border - right : 1 px solid var ( -- el - border - color - lighter ) ; }
. gs - item : last - child { border - right : none ; }
. gs - v { font - size : 24 px ; font - weight : 700 ; font - variant - numeric : tabular - nums ; }
. gs - item . warn . gs - v { color : var ( -- el - color - danger ) ; }
. gs - l { font - size : 12 px ; color : var ( -- el - text - color - secondary ) ; margin - top : 4 px ; }
. section - title { font - size : 14 px ; font - weight : 700 ; display : flex ; align - items : baseline ; gap : 8 px ; margin - bottom : 12 px ; }
. matrix - wrap { overflow - x : auto ; border - radius : 10 px ; background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - lighter ) ; }
table . matrix { border - collapse : separate ; border - spacing : 0 ; width : 100 % ; font - size : 12.5 px ; }
table . matrix th { background : var ( -- el - fill - color - lighter ) ; color : var ( -- el - text - color - regular ) ; font - size : 12 px ; font - weight : 600 ; padding : 10 px 12 px ; border - bottom : 1 px solid var ( -- el - border - color - lighter ) ; text - align : left ; white - space : nowrap ; }
table . matrix td { padding : 8 px 12 px ; border - bottom : 1 px solid var ( -- el - border - color - extra - light ) ; white - space : nowrap ; }
table . matrix tbody tr { cursor : pointer ; }
table . matrix tbody tr : hover td { background : var ( -- el - fill - color - light ) ; }
. asin - cell { font - family : 'Cascadia Mono' , Consolas , monospace ; font - weight : 700 ; color : var ( -- el - color - primary ) ; }
. brand - cell { color : var ( -- el - text - color - secondary ) ; }
. m - cell { width : 42 px ; height : 30 px ; border - radius : 7 px ; display : inline - flex ; align - items : center ; justify - content : center ; font - weight : 700 ; color : var ( -- el - color - primary ) ; background : var ( -- el - color - primary - light - 9 ) ; position : relative ; }
. m - cell . empty { background : var ( -- el - fill - color - light ) ; color : var ( -- el - text - color - placeholder ) ; font - weight : 400 ; }
. dots { position : absolute ; bottom : 2 px ; left : 0 ; right : 0 ; display : flex ; justify - content : center ; gap : 2 px ; }
. m - dot { width : 5 px ; height : 5 px ; border - radius : 50 % ; display : inline - block ; }
. dup - cards { display : grid ; grid - template - columns : repeat ( auto - fill , minmax ( 360 px , 1 fr ) ) ; gap : 14 px ; }
. dup - card { background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - lighter ) ; border - radius : 10 px ; overflow : hidden ; }
. dup - head { display : flex ; align - items : center ; gap : 10 px ; padding : 11 px 14 px ; border - bottom : 1 px solid var ( -- el - border - color - lighter ) ; background : var ( -- el - fill - color - lighter ) ; flex - wrap : wrap ; }
. dup - asin { font - family : 'Cascadia Mono' , Consolas , monospace ; font - weight : 700 ; color : var ( -- el - color - primary ) ; font - size : 13 px ; }
. badge { display : inline - flex ; align - items : center ; border - radius : 20 px ; padding : 2 px 9 px ; font - size : 11 px ; font - weight : 600 ; white - space : nowrap ; }
. badge . dup { background : # fdebed ; color : # d63a4a ; }
. dup - brand { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. dup - body { padding : 12 px 14 px ; display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 150 px , 1 fr ) ) ; gap : 16 px ; }
. store - col . st - name { font - size : 12.5 px ; font - weight : 700 ; margin - bottom : 6 px ; display : flex ; gap : 6 px ; align - items : center ; }
. st - count { font - size : 11 px ; color : var ( -- el - text - color - secondary ) ; font - weight : 400 ; }
. st - grp { font - size : 10.5 px ; color : var ( -- el - text - color - secondary ) ; margin - bottom : 4 px ; }
. c - site { font - size : 11 px ; color : var ( -- el - text - color - regular ) ; margin - top : 6 px ; }
. c - site - head { font - weight : 600 ; display : flex ; gap : 5 px ; align - items : center ; }
. c - times { margin - top : 2 px ; }
. c - time { font - family : 'Cascadia Mono' , Consolas , monospace ; font - size : 11 px ; color : var ( -- el - text - color - secondary ) ; line - height : 1.7 ; display : block ; }
. site - chip { border - radius : 5 px ; padding : 1 px 7 px ; font - size : 11.5 px ; margin - right : 4 px ; font - weight : 600 ; display : inline - block ; }
. tag - gap { margin - right : 4 px ; margin - bottom : 2 px ; }
. table - footer { display : flex ; justify - content : space - between ; align - items : center ; padding - top : 14 px ; }
. table - footer span { color : var ( -- el - text - color - secondary ) ; font - size : 12.5 px ; }
. table - footer b { color : var ( -- el - text - color - primary ) ; }
. drawer - head { display : flex ; align - items : center ; gap : 10 px ; margin - bottom : 12 px ; }
. d - brand { color : var ( -- el - text - color - secondary ) ; font - size : 13 px ; }
/* 明细抽屉汇总徽章组(对齐 reference:N家店铺/国家chips/N次上架/最早上架)。 */
. drawer - badges { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 6 px ; margin - bottom : 12 px ; }
. badge . store { background : # e8f1fa ; color : # 2 f5d8b ; }
. badge . count { background : # fdebed ; color : # d63a4a ; }
. badge . time { background : var ( -- el - fill - color - light ) ; color : var ( -- el - text - color - regular ) ; }
. chip { border - radius : 20 px ; padding : 2 px 9 px ; font - size : 11 px ; font - weight : 600 ; color : # fff ; white - space : nowrap ; }
. drawer - time { font - family : 'Cascadia Mono' , Consolas , monospace ; font - size : 11.5 px ; color : var ( -- el - text - color - regular ) ; line - height : 1.7 ; }
/* 矩阵表头吸顶(对齐 reference sticky 表头)。 */
. matrix - wrap { max - height : 480 px ; overflow - y : auto ; }
table . matrix th { position : sticky ; top : 0 ; z - index : 1 ; }
. empty - state { margin - top : 12 px ; }
< / style >