task-280(验收反馈): 重复检测对齐reference P0(分组筛选生效/抽屉聚合+汇总徽章/台账计数预载/total_dup计数/矩阵表头吸顶;后端含工作区在途console化代码)
This commit is contained in:
@@ -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="600px">
|
||||
<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="520px">
|
||||
<el-alert type="info" :closable="false" show-icon title="分组归属来自「店铺管理」的真实店铺分组,仅用于本页分组撞款范围切换;本页不做分组编辑。如需调整店铺所属分组,请到店铺管理维护(会影响对应负责人的数据查看范围)。" />
|
||||
<el-table :data="groupStats" border style="margin-top: 14px">
|
||||
<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: 1560px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.page-heading .actions { display: flex; align-items: center; gap: 10px; }
|
||||
.updated { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.wide { width: 320px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 14px; }
|
||||
.kpi-card { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 10px; padding: 12px 16px; box-shadow: var(--el-box-shadow-lighter); }
|
||||
.k-label { color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 6px; }
|
||||
.k-value { font-size: 26px; 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: 11px; margin-top: 3px; }
|
||||
.card-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; display: flex; gap: 8px; align-items: baseline; }
|
||||
.hint { font-size: 11px; font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.store-bars { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; }
|
||||
.s-name { display: flex; justify-content: space-between; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 5px; }
|
||||
.s-name b { color: var(--el-text-color-primary); font-weight: 600; }
|
||||
.s-group { color: var(--el-text-color-secondary); }
|
||||
.s-track { height: 8px; background: var(--el-fill-color-light); border-radius: 5px; overflow: hidden; }
|
||||
.s-fill { height: 100%; border-radius: 5px; background: linear-gradient(90deg, #6366f1, #8b9cf6); }
|
||||
.s-meta { font-size: 11px; color: var(--el-text-color-secondary); margin-top: 5px; }
|
||||
.tab-bar { display: flex; justify-content: space-between; align-items: center; margin: 4px 0 14px; }
|
||||
.tabs { display: flex; gap: 4px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 9px; padding: 4px; }
|
||||
.tab { padding: 7px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--el-text-color-regular); cursor: pointer; background: transparent; border: none; display: inline-flex; gap: 7px; 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: 10px; padding: 0 7px; font-size: 11px; }
|
||||
.tab.active .t-count { background: rgba(255, 255, 255, 0.25); }
|
||||
.scope-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.seg { display: flex; gap: 4px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 9px; padding: 4px; }
|
||||
.seg-btn { padding: 7px 16px; border-radius: 7px; font-size: 13px; 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: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.g-chip { padding: 7px 14px; border-radius: 20px; font-size: 12.5px; font-weight: 600; background: var(--el-bg-color); border: 1px solid var(--el-border-color); color: var(--el-text-color-regular); cursor: pointer; transition: all 0.15s; }
|
||||
.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: 11px; opacity: 0.85; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.group-summary { display: grid; grid-template-columns: repeat(4, 1fr); background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 10px; margin-bottom: 16px; overflow: hidden; }
|
||||
.gs-item { padding: 14px 20px; border-right: 1px solid var(--el-border-color-lighter); }
|
||||
.gs-item:last-child { border-right: none; }
|
||||
.gs-v { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.gs-item.warn .gs-v { color: var(--el-color-danger); }
|
||||
.gs-l { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
|
||||
.section-title { font-size: 14px; font-weight: 700; display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
|
||||
.matrix-wrap { overflow-x: auto; border-radius: 10px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); }
|
||||
table.matrix { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 12.5px; }
|
||||
table.matrix th { background: var(--el-fill-color-lighter); color: var(--el-text-color-regular); font-size: 12px; font-weight: 600; padding: 10px 12px; border-bottom: 1px solid var(--el-border-color-lighter); text-align: left; white-space: nowrap; }
|
||||
table.matrix td { padding: 8px 12px; border-bottom: 1px 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: 42px; height: 30px; border-radius: 7px; 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: 2px; left: 0; right: 0; display: flex; justify-content: center; gap: 2px; }
|
||||
.m-dot { width: 5px; height: 5px; border-radius: 50%; display: inline-block; }
|
||||
.dup-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
|
||||
.dup-card { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 10px; overflow: hidden; }
|
||||
.dup-head { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px 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: 13px; }
|
||||
.badge { display: inline-flex; align-items: center; border-radius: 20px; padding: 2px 9px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||||
.badge.dup { background: #fdebed; color: #d63a4a; }
|
||||
.dup-brand { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.dup-body { padding: 12px 14px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
|
||||
.store-col .st-name { font-size: 12.5px; font-weight: 700; margin-bottom: 6px; display: flex; gap: 6px; align-items: center; }
|
||||
.st-count { font-size: 11px; color: var(--el-text-color-secondary); font-weight: 400; }
|
||||
.st-grp { font-size: 10.5px; color: var(--el-text-color-secondary); margin-bottom: 4px; }
|
||||
.c-site { font-size: 11px; color: var(--el-text-color-regular); margin-top: 6px; }
|
||||
.c-site-head { font-weight: 600; display: flex; gap: 5px; align-items: center; }
|
||||
.c-times { margin-top: 2px; }
|
||||
.c-time { font-family: 'Cascadia Mono', Consolas, monospace; font-size: 11px; color: var(--el-text-color-secondary); line-height: 1.7; display: block; }
|
||||
.site-chip { border-radius: 5px; padding: 1px 7px; font-size: 11.5px; margin-right: 4px; font-weight: 600; display: inline-block; }
|
||||
.tag-gap { margin-right: 4px; margin-bottom: 2px; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.drawer-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.d-brand { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
/* 明细抽屉汇总徽章组(对齐 reference:N家店铺/国家chips/N次上架/最早上架)。 */
|
||||
.drawer-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.badge.store { background: #e8f1fa; color: #2f5d8b; }
|
||||
.badge.count { background: #fdebed; color: #d63a4a; }
|
||||
.badge.time { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
|
||||
.chip { border-radius: 20px; padding: 2px 9px; font-size: 11px; font-weight: 600; color: #fff; white-space: nowrap; }
|
||||
.drawer-time { font-family: 'Cascadia Mono', Consolas, monospace; font-size: 11.5px; color: var(--el-text-color-regular); line-height: 1.7; }
|
||||
/* 矩阵表头吸顶(对齐 reference sticky 表头)。 */
|
||||
.matrix-wrap { max-height: 480px; overflow-y: auto; }
|
||||
table.matrix th { position: sticky; top: 0; z-index: 1; }
|
||||
.empty-state { margin-top: 12px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user