task-280(验收反馈): 重复检测对齐reference P0(分组筛选生效/抽屉聚合+汇总徽章/台账计数预载/total_dup计数/矩阵表头吸顶;后端含工作区在途console化代码)

This commit is contained in:
2026-09-06 02:41:44 +08:00
parent 3dd39bba44
commit ab787ceff7
8 changed files with 1350 additions and 0 deletions
@@ -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>
@@ -0,0 +1,65 @@
/** 撞款控制台/台账适配:/api/admin/shop-data-crawl/duplicate-check-console、-ledger、-export。 */
import { http } from '@/api/http'
import { toDuplicateItemsQuery, type DuplicateFilter } from './duplicate-filter.ts'
import {
parseDuplicateConsole,
parseLedgerPage,
type DuplicateConsole,
type LedgerPage,
} from './duplicate-console-model.ts'
export const DUPLICATE_CHECK_ENDPOINT = '/api/admin/shop-data-crawl'
/** console 端点吃 asin/shop_name/group/country/site/date_from/date_to;多余分页参数后端忽略。 */
function toConsoleQuery(filter: DuplicateFilter): Record<string, string> {
const query: Record<string, string> = {}
const asin = (filter.asin || '').trim()
if (asin) query.asin = asin
const shopName = (filter.shopName || '').trim()
if (shopName) query.shop_name = shopName
const group = (filter.group || '').trim()
if (group) query.group = group
const country = (filter.country || '').trim()
if (country) query.country = country
const site = (filter.site || '').trim()
if (site) query.site = site
const dateFrom = (filter.dateFrom || '').trim()
if (dateFrom) query.date_from = dateFrom
const dateTo = (filter.dateTo || '').trim()
if (dateTo) query.date_to = dateTo
return query
}
export type LedgerSortKey = 'asin' | 'brand' | 'store_count' | 'record_count' | 'earliest' | 'latest'
/** 加载撞款控制台快照:GET /duplicate-check-console。 */
export async function fetchDuplicateConsole(filter: DuplicateFilter): Promise<DuplicateConsole> {
const { data } = await http.get<unknown>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-console`, {
params: toConsoleQuery(filter),
})
return parseDuplicateConsole(data)
}
/** 加载全部 ASIN 台账分页(含未撞款):GET /duplicate-check-ledger。 */
export async function fetchDuplicateLedger(
filter: DuplicateFilter,
page: number,
pageSize: number,
sortKey: LedgerSortKey | '',
sortDir: 'asc' | 'desc',
): Promise<LedgerPage> {
const params: Record<string, string | number> = { ...toConsoleQuery(filter), page, page_size: pageSize }
if (sortKey) params.sort_key = sortKey
params.sort_dir = sortDir
const { data } = await http.get<unknown>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-ledger`, { params })
return parseLedgerPage(data)
}
/** 导出 CSV(撞款 view=monitor / 台账 view=all)GET /duplicate-check-export → Blob。 */
export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
responseType: 'blob',
})
return data
}
@@ -0,0 +1,144 @@
/** 撞款控制台视图派生(分组索引 / 组内撞款子集 / 店铺列并集):纯逻辑。 */
import type { DuplicateItem, DuplicateOccurrence, DuplicateShop } from './duplicate-model.ts'
export interface ShopGroups {
/** 分组名 → 成员店铺(展示名,保持首次出现顺序)。 */
members: Record<string, string[]>
/** 店铺展示名 → 所属分组名(含多分组拆分的每一组)。 */
shopGroupNames: Record<string, string[]>
}
const GROUP_SEPARATOR = /[、,/;]/
function shopKey(name: string): string {
return (name || '').trim().toLowerCase()
}
function splitTokens(value: string): string[] {
const out: string[] = []
for (const token of (value || '').split(GROUP_SEPARATOR)) {
const group = token.trim()
if (group && !out.includes(group)) out.push(group)
}
return out
}
/** 由店铺分布构建 分组→成员 与 店铺→分组 双向索引。 */
export function indexShopGroups(shops: DuplicateShop[]): ShopGroups {
const members: Record<string, string[]> = {}
const shopGroupNames: Record<string, string[]> = {}
for (const shop of shops || []) {
const display = (shop.shopName || '').trim()
if (!display) continue
const groups = splitTokens(shop.groupName || '')
for (const group of groups) {
const list = members[group] || (members[group] = [])
if (!list.includes(display)) list.push(display)
const owned = shopGroupNames[display] || (shopGroupNames[display] = [])
if (!owned.includes(group)) owned.push(group)
}
}
return { members, shopGroupNames }
}
/** 仅在指定成员店铺(展示名)内保留 occurrences;重算 shop_count,返回 ≥2 店的撞款子集。 */
export function dupWithinGroup(items: DuplicateItem[], memberShops: string[]): DuplicateItem[] {
const memberKeys = new Set<string>()
for (const shop of memberShops || []) memberKeys.add(shopKey(shop))
const result: DuplicateItem[] = []
for (const item of items || []) {
const kept: DuplicateOccurrence[] = []
const shops = new Set<string>()
for (const occ of item.occurrences || []) {
if (memberKeys.has(shopKey(occ.shopName))) {
kept.push(occ)
shops.add(occ.shopName || '')
}
}
if (kept.length > 0 && shops.size >= 2) {
result.push({ asin: item.asin, shopCount: shops.size, recordCount: kept.length, occurrences: kept })
}
}
return result
}
/** 撞款行涉及店铺的并集列(出现顺序去重);memberShops 非空时仅取交集成员的子集顺序。 */
export function storeColumnsOf(items: DuplicateItem[], memberShops: string[] = []): string[] {
const restricted = new Set(memberShops.map((shop) => shopKey(shop)))
const useRestricted = restricted.size > 0
const order: string[] = []
const seen = new Set<string>()
const push = (name: string): void => {
const key = shopKey(name)
if (!name || seen.has(key) || (useRestricted && !restricted.has(key))) return
seen.add(key)
order.push(name)
}
for (const item of items || []) {
for (const occ of item.occurrences || []) push(occ.shopName || '')
}
if (order.length === 0 && memberShops.length > 0) {
// 无撞款行但该组有成员店铺:矩阵列仍需展示组员店。
for (const shop of memberShops) {
const key = shopKey(shop)
if (!seen.has(key)) {
seen.add(key)
order.push(shop)
}
}
}
return order
}
/** 明细抽屉聚合行:店铺×站点聚合(对齐 reference 抽屉的 店铺/分组/站点/上架时间(升序堆叠)/次数)。 */
export interface DrawerAggRow {
shopName: string
groupName: string
country: string
times: string[]
count: number
}
export function aggregateDrawerRows(occurrences: DuplicateOccurrence[]): DrawerAggRow[] {
const rows = new Map<string, DrawerAggRow>()
for (const occ of occurrences || []) {
const key = `${shopKey(occ.shopName)}|${(occ.country || '').trim().toUpperCase()}`
let row = rows.get(key)
if (!row) {
row = { shopName: occ.shopName || '', groupName: occ.groupName || '', country: (occ.country || '').trim(), times: [], count: 0 }
rows.set(key, row)
}
row.times.push(occ.date || '')
row.count += 1
}
const list = [...rows.values()]
for (const row of list) row.times.sort()
return list
}
/** 明细抽屉汇总徽章:N店在售/N家店铺/国家chips/N次上架/最早上架。 */
export interface DrawerSummary {
shopCount: number
countries: string[]
totalRecords: number
earliest: string
}
export function drawerSummaryOf(occurrences: DuplicateOccurrence[]): DrawerSummary {
const shops = new Set<string>()
const countries: string[] = []
const seenCountry = new Set<string>()
let earliest = ''
let totalRecords = 0
for (const occ of occurrences || []) {
if (occ.shopName) shops.add(shopKey(occ.shopName))
const country = (occ.country || '').trim()
if (country && !seenCountry.has(country)) {
seenCountry.add(country)
countries.push(country)
}
if (occ.date && (!earliest || occ.date < earliest)) earliest = occ.date
totalRecords += 1
}
return { shopCount: shops.size, countries, totalRecords, earliest }
}
@@ -9,6 +9,8 @@ export interface DuplicateFilter {
view: string
asin: string
shopName: string
/** 分组筛选(撞款分组名,后端按组员店铺裁剪可见集)。 */
group?: string
country: string
site: string
dateFrom: string
@@ -0,0 +1,58 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { aggregateDrawerRows, drawerSummaryOf } from '../src/pages/tasks/duplicate-console-logic.ts'
// 验收反馈:店铺重复检测对齐 reference —— 本轮补齐 P0 缺口:
// 分组筛选生效、抽屉聚合+汇总徽章、台账计数打开即显示、撞款计数走后端 total_dup、矩阵表头吸顶。
test('align_dup_console_group_filter_wired', () => {
const api = readSource('src/pages/tasks/duplicate-console-api.ts')
assert.match(api, /query\.group = group/, 'console 查询携带 group 参数')
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /group: filter\.group\.trim\(\)/, '页面筛选把 group 传给查询')
})
test('align_dup_console_group_filter_backend', () => {
const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java')
assert.match(ctrl, /@RequestParam\(name = "group"/, '后端 console/ledger 接收 group 参数')
assert.match(ctrl, /withGroupFilter\(visibleKeys, scan, group\)/, '端点按分组裁剪可见店铺')
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java')
assert.match(service, /withGroupFilter/, 'service 提供分组裁剪方法')
assert.match(service, /分组筛选/, '分组筛选留中文排查日志')
})
test('align_dup_console_drawer_aggregation', () => {
const rows = aggregateDrawerRows([
{ asin: 'A', date: '2026-09-02 10:00', price: '1', brand: 'B', shopName: '店1', groupName: '组1', countryCodes: [], country: 'DE' },
{ asin: 'A', date: '2026-09-01 10:00', price: '1', brand: 'B', shopName: '店1', groupName: '组1', countryCodes: [], country: 'DE' },
{ asin: 'A', date: '2026-09-03 10:00', price: '1', brand: 'B', shopName: '店2', groupName: '组1', countryCodes: [], country: 'UK' },
])
assert.equal(rows.length, 2, '按店铺×站点聚合')
assert.equal(rows[0].count, 2, '同店同站次数累加')
assert.deepEqual(rows[0].times, ['2026-09-01 10:00', '2026-09-02 10:00'], '时间升序')
const summary = drawerSummaryOf(rows.flatMap((row) => row.times.map((time) => ({ asin: 'A', date: time, price: '1', brand: 'B', shopName: row.shopName, groupName: '组1', countryCodes: [], country: row.country }))))
assert.equal(summary.shopCount, 2)
assert.equal(summary.totalRecords, 3)
assert.equal(summary.earliest, '2026-09-01 10:00')
})
test('align_dup_console_drawer_template', () => {
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
assert.match(page, /次上架/, '汇总含 N 次上架')
assert.match(page, /label="次数"/, '聚合表含次数列')
})
test('align_dup_console_ledger_total_preload', () => {
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /onMounted\(\(\) => \{\s*loadConsole\(\).*loadLedger\(1\)/s, '')
assert.match(page, /totalDup/, '撞款计数走后端 total_dup 字段')
})
test('align_dup_console_matrix_sticky', () => {
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶')
assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动')
})
@@ -128,6 +128,58 @@ public class AdminShopDataDuplicateCheckController {
return ApiResponse.success(queryService.detailData(scan, visibleKeys, safePage, safeSize, filters));
}
@GetMapping("/duplicate-check-console")
@Operation(summary = "撞款控制台快照:指标/店铺/命中筛选的撞款全集/分组统计")
public ApiResponse<Object> console(
HttpServletRequest request,
@RequestParam(name = "asin", required = false) String asin,
@RequestParam(name = "shop_name", required = false) String shopName,
@RequestParam(name = "group", required = false) String group,
@RequestParam(name = "country", required = false) String country,
@RequestParam(name = "site", required = false) String site,
@RequestParam(name = "date_from", required = false) String dateFrom,
@RequestParam(name = "date_to", required = false) String dateTo) {
RequestOperator operator = requireDuplicateCheckAccess(request);
ShopDataDuplicateCheckQueryService.Filters filters =
ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo);
DuplicateScanView scan = scanService.loadLatest();
if (scan == null) {
return ApiResponse.success(queryService.pendingConsole());
}
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
Set<String> effectiveKeys = queryService.withGroupFilter(visibleKeys, scan, group);
return ApiResponse.success(queryService.consoleData(scan, effectiveKeys, filters));
}
@GetMapping("/duplicate-check-ledger")
@Operation(summary = "全部 ASIN 台账(含未撞款):筛选 + 列排序 + 分页")
public ApiResponse<Object> ledger(
HttpServletRequest request,
@RequestParam(name = "page", required = false) String page,
@RequestParam(name = "page_size", required = false) String pageSize,
@RequestParam(name = "sort_key", required = false) String sortKey,
@RequestParam(name = "sort_dir", required = false) String sortDir,
@RequestParam(name = "asin", required = false) String asin,
@RequestParam(name = "shop_name", required = false) String shopName,
@RequestParam(name = "group", required = false) String group,
@RequestParam(name = "country", required = false) String country,
@RequestParam(name = "site", required = false) String site,
@RequestParam(name = "date_from", required = false) String dateFrom,
@RequestParam(name = "date_to", required = false) String dateTo) {
RequestOperator operator = requireDuplicateCheckAccess(request);
int safePage = parsePage(page);
int safeSize = clamp(parseSize(pageSize, 100), 1, 200);
ShopDataDuplicateCheckQueryService.Filters filters =
ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo);
DuplicateScanView scan = scanService.loadLatest();
if (scan == null) {
return ApiResponse.success(queryService.pendingLedger(safePage, safeSize));
}
Set<String> visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin());
Set<String> effectiveKeys = queryService.withGroupFilter(visibleKeys, scan, group);
return ApiResponse.success(queryService.ledgerData(scan, effectiveKeys, safePage, safeSize, sortKey, sortDir, filters));
}
@GetMapping("/duplicate-check-export")
@Operation(summary = "撞款导出 CSV(UTF-8 BOM,逐行=一条上架记录)")
public void export(
@@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateChe
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckCsvWriter;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckTimeNormalizer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
@@ -37,6 +38,7 @@ import java.util.Set;
* 内存模型/排序语义与 Python 后台逐字对齐;返回结构为可直接序列化的 Map(键 snake_case
* 空态与 Python 响应完全一致:pending 时 summary 为 {} 空对象)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ShopDataDuplicateCheckQueryService {
@@ -86,6 +88,29 @@ public class ShopDataDuplicateCheckQueryService {
return keys;
}
/** 分组筛选:group 非空时取该分组全部成员店铺键;与 visibleKeys 求交集(null=全量时直接取组键)。 */
public Set<String> withGroupFilter(Set<String> visibleKeys, DuplicateScanView view, String groupName) {
String group = trim(groupName);
if (group.isEmpty()) {
return visibleKeys;
}
Set<String> memberKeys = new LinkedHashSet<>();
for (DuplicateShop shop : view.payload().shops()) {
for (String g : splitGroups(safe(shop.groupName()))) {
if (g.equals(group)) {
memberKeys.add(shopKey(shop.shopName()));
}
}
}
if (visibleKeys == null) {
log.info("[duplicate-check] 分组筛选 group={} 命中成员店铺 {} 家", group, memberKeys.size());
return memberKeys;
}
memberKeys.retainAll(visibleKeys);
log.info("[duplicate-check] 分组筛选 group={} 与数据范围交集后成员店铺 {} 家", group, memberKeys.size());
return memberKeys;
}
/** 超管/null → 全量;否则保留可见店铺(shops 保持缓存序)。 */
public List<DuplicateShop> cropShops(List<DuplicateShop> shops, Set<String> visibleKeys) {
if (visibleKeys == null || shops == null) {
@@ -177,6 +202,197 @@ public class ShopDataDuplicateCheckQueryService {
"page", page, "page_size", pageSize, "scanned_at", "");
}
/* ================= 控制台 & 台账(reference console 对齐的只读聚合) ================= */
private static final Set<String> LEDGER_SORT_KEYS =
Set.of("asin", "brand", "store_count", "record_count", "earliest", "latest");
/** 店铺 group_name 可能以分隔符连接多分组,统一按分隔符拆分。 */
private static final String GROUP_SPLIT = "[、,/;]";
/** 台账单行聚合:{asin, brand, stores[], groups[], countries[], record_count, earliest, latest, occurrences}。 */
private record LedgerRow(String asin, String brand, List<String> stores, List<String> groups,
List<String> countries, int recordCount, String earliest, String latest,
List<DuplicateOccurrence> occurrences) {
}
/**
* 全部 ASIN 台账(含单店未撞款):筛选 + 可选列排序 + 分页。
* sortKey 白名单外回退 earliestsortDir 非 desc 视为 asc。
*/
public Map<String, Object> ledgerData(DuplicateScanView view, Set<String> visibleKeys,
int page, int pageSize, String sortKey, String sortDir,
Filters filters) {
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
List<DuplicateItem> matched = filterItems(cropped, false, filters);
String key = LEDGER_SORT_KEYS.contains(safe(sortKey).toLowerCase(Locale.ROOT))
? safe(sortKey).toLowerCase(Locale.ROOT) : "earliest";
boolean asc = !"desc".equals(safe(sortDir).trim().toLowerCase(Locale.ROOT));
List<LedgerRow> rows = new ArrayList<>(matched.size());
for (DuplicateItem item : matched) {
rows.add(ledgerRow(item));
}
rows.sort((a, b) -> ledgerCompare(a, b, key, asc));
int total = rows.size();
int from = Math.min((page - 1) * pageSize, total);
int to = Math.min(from + pageSize, total);
List<Map<String, Object>> slice = new ArrayList<>();
for (int i = from; i < to; i++) {
slice.add(ledgerMap(rows.get(i)));
}
return mapOf("pending", false, "items", slice, "total", total,
"page", page, "page_size", pageSize, "scanned_at", safe(view.scannedAt()));
}
public Map<String, Object> pendingLedger(int page, int pageSize) {
return mapOf("pending", true, "items", Collections.emptyList(), "total", 0,
"page", page, "page_size", pageSize, "scanned_at", "");
}
/** 撞款控制台一次快照:summary/shops(全局可见口径) + 命中筛选的撞款全集 + 分组统计。 */
public Map<String, Object> consoleData(DuplicateScanView view, Set<String> visibleKeys, Filters filters) {
List<DuplicateShop> shops = cropShops(view.payload().shops(), visibleKeys);
List<DuplicateItem> cropped = cropItems(view.payload().items(), visibleKeys);
String source = view.summary() == null ? "" : safe(view.summary().source());
DuplicateScanSummary summary = recomputeSummary(cropped, shops, source);
List<DuplicateItem> dup = filterItems(cropped, true, filters);
List<Map<String, Object>> groups = groupStats(shops, filterItems(cropped, false, filters));
return mapOf("pending", false, "scanned_at", safe(view.scannedAt()), "summary", summary,
"shops", shops, "dup", dup, "groups", groups, "total_dup", dup.size());
}
public Map<String, Object> pendingConsole() {
return mapOf("pending", true, "scanned_at", "", "summary", Collections.emptyMap(),
"shops", Collections.emptyList(), "dup", Collections.emptyList(),
"groups", Collections.emptyList(), "total_dup", 0);
}
/** 单条台账行:品牌取首个非空;店铺/分组/站点保持出现顺序去重;最早/最近上架取归一化时间。 */
private LedgerRow ledgerRow(DuplicateItem item) {
LinkedHashSet<String> stores = new LinkedHashSet<>();
LinkedHashSet<String> groups = new LinkedHashSet<>();
LinkedHashSet<String> countries = new LinkedHashSet<>();
String brand = "";
String earliest = "";
String latest = "";
for (DuplicateOccurrence occ : item.occurrences()) {
if (!safe(occ.shopName()).isEmpty()) {
stores.add(occ.shopName());
}
for (String group : splitGroups(safe(occ.groupName()))) {
groups.add(group);
}
String country = safe(occ.country()).trim().toUpperCase(Locale.ROOT);
if (!country.isEmpty()) {
countries.add(country);
}
if (brand.isEmpty()) {
brand = safe(occ.brand());
}
String normalized = DuplicateCheckTimeNormalizer.normalizeSortTime(safe(occ.date()));
if (!normalized.isEmpty()) {
if (earliest.isEmpty() || normalized.compareTo(earliest) < 0) {
earliest = normalized;
}
if (latest.isEmpty() || normalized.compareTo(latest) > 0) {
latest = normalized;
}
}
}
return new LedgerRow(item.asin(), brand, List.copyOf(stores), List.copyOf(groups),
List.copyOf(countries), item.recordCount(), earliest, latest,
new ArrayList<>(item.occurrences()));
}
private Map<String, Object> ledgerMap(LedgerRow row) {
return mapOf("asin", row.asin(), "brand", row.brand(), "store_count", row.stores().size(),
"stores", row.stores(), "groups", row.groups(), "countries", row.countries(),
"record_count", row.recordCount(), "earliest", row.earliest(), "latest", row.latest(),
"occurrences", row.occurrences());
}
private int ledgerCompare(LedgerRow a, LedgerRow b, String key, boolean asc) {
int cmp;
switch (key) {
case "asin" -> cmp = safe(a.asin()).compareTo(safe(b.asin()));
case "brand" -> cmp = safe(a.brand()).compareTo(safe(b.brand()));
case "store_count" -> cmp = Integer.compare(a.stores().size(), b.stores().size());
case "record_count" -> cmp = Integer.compare(a.recordCount(), b.recordCount());
case "latest" -> cmp = safe(a.latest()).compareTo(safe(b.latest()));
default -> cmp = safe(a.earliest()).compareTo(safe(b.earliest()));
}
return asc ? cmp : -cmp;
}
/** 可见店铺按 group_name 拆分组;再在命中行上统计组内 唯一ASIN/上架记录/撞款ASIN。 */
private List<Map<String, Object>> groupStats(List<DuplicateShop> shops, List<DuplicateItem> matched) {
LinkedHashMap<String, Set<String>> members = new LinkedHashMap<>();
for (DuplicateShop shop : shops) {
String display = shop.shopName() == null ? "" : shop.shopName().trim();
if (display.isEmpty()) {
continue;
}
for (String group : splitGroups(safe(shop.groupName()))) {
members.computeIfAbsent(group, k -> new LinkedHashSet<>()).add(shopKey(display));
}
}
if (members.isEmpty()) {
return List.of();
}
List<String> groupNames = new ArrayList<>(members.keySet());
int[] asinUnique = new int[groupNames.size()];
int[] recordCount = new int[groupNames.size()];
int[] dupCount = new int[groupNames.size()];
for (DuplicateItem item : matched) {
Set<String> shopsOnItem = new LinkedHashSet<>();
for (DuplicateOccurrence occ : item.occurrences()) {
String key = shopKey(occ.shopName());
if (!key.isEmpty()) {
shopsOnItem.add(key);
}
}
for (int gi = 0; gi < groupNames.size(); gi++) {
Set<String> memberKeys = members.get(groupNames.get(gi));
int inGroup = 0;
for (String key : shopsOnItem) {
if (memberKeys.contains(key)) {
inGroup++;
}
}
if (inGroup > 0) {
asinUnique[gi]++;
for (DuplicateOccurrence occ : item.occurrences()) {
if (memberKeys.contains(shopKey(occ.shopName()))) {
recordCount[gi]++;
}
}
if (inGroup >= 2) {
dupCount[gi]++;
}
}
}
}
List<Map<String, Object>> result = new ArrayList<>(groupNames.size());
for (int gi = 0; gi < groupNames.size(); gi++) {
result.add(mapOf("name", groupNames.get(gi), "shop_count", members.get(groupNames.get(gi)).size(),
"asin_unique", asinUnique[gi], "record_count", recordCount[gi], "dup_count", dupCount[gi]));
}
return result;
}
private static List<String> splitGroups(String value) {
if (value == null || value.isBlank()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (String token : value.split(GROUP_SPLIT)) {
String group = token.trim();
if (!group.isEmpty() && !out.contains(group)) {
out.add(group);
}
}
return out;
}
public void writeExport(DuplicateScanView view, Set<String> visibleKeys,
String viewMode, Filters filters, OutputStream out) throws IOException {
boolean monitor = "monitor".equals(viewMode);
@@ -137,6 +137,113 @@ class ShopDataDuplicateCheckQueryServiceTest {
assertThat(list.get("scanned_at")).isEqualTo("");
}
@Test
void consoleSuperAdminReturnsDupSetGroupsAndSummary() {
Map<String, Object> data = service.consoleData(view, null, noFilter());
assertThat(data.get("pending")).isEqualTo(false);
assertThat(data.get("total_dup")).isEqualTo(3);
@SuppressWarnings("unchecked")
List<DuplicateItem> dup = (List<DuplicateItem>) data.get("dup");
assertThat(dup).extracting(DuplicateItem::asin)
.containsExactlyInAnyOrder("A0000001", "E0000001", "F0000001");
assertThat(dup).allSatisfy(item -> assertThat(item.shopCount()).isGreaterThanOrEqualTo(2));
DuplicateScanSummary summary = (DuplicateScanSummary) data.get("summary");
assertThat(summary.asinTotal()).isEqualTo(6);
assertThat(summary.recordTotal()).isEqualTo(10);
assertThat(summary.duplicateAsinTotal()).isEqualTo(3);
assertThat(summary.siteCount()).isEqualTo(3);
@SuppressWarnings("unchecked")
List<Map<String, Object>> groups = (List<Map<String, Object>>) data.get("groups");
assertThat(groups).extracting(map -> map.get("name"))
.containsExactlyInAnyOrder("GroupA", "GroupC", "GroupD");
Map<String, Object> groupA = groups.stream()
.filter(map -> "GroupA".equals(map.get("name"))).findFirst().orElseThrow();
assertThat(groupA.get("shop_count")).isEqualTo(2);
assertThat(groupA.get("asin_unique")).isEqualTo(4);
assertThat(groupA.get("record_count")).isEqualTo(6);
assertThat(groupA.get("dup_count")).isEqualTo(2);
}
@Test
void consoleLeaderCropRemovesCrossGroupDupAndKeepsOwnGroups() {
Set<String> visible = Set.of("shopa", "shopb");
Map<String, Object> data = service.consoleData(view, visible, noFilter());
assertThat(data.get("total_dup")).isEqualTo(2);
@SuppressWarnings("unchecked")
List<DuplicateItem> dup = (List<DuplicateItem>) data.get("dup");
assertThat(dup).extracting(DuplicateItem::asin)
.containsExactlyInAnyOrder("A0000001", "E0000001");
@SuppressWarnings("unchecked")
List<Map<String, Object>> groups = (List<Map<String, Object>>) data.get("groups");
assertThat(groups).hasSize(1);
assertThat(groups.get(0).get("name")).isEqualTo("GroupA");
@SuppressWarnings("unchecked")
List<DuplicateShop> shops = (List<DuplicateShop>) data.get("shops");
assertThat(shops).extracting(DuplicateShop::shopName).containsExactly("ShopA", "ShopB");
}
@Test
void consolePendingShapes() {
Map<String, Object> data = service.pendingConsole();
assertThat(data.get("pending")).isEqualTo(true);
assertThat(data.get("dup")).isEqualTo(List.of());
assertThat(data.get("groups")).isEqualTo(List.of());
assertThat(data.get("summary")).isEqualTo(Map.of());
}
@Test
void ledgerSingleAsinRowDerivesStoresGroupsCountriesAndDates() {
ShopDataDuplicateCheckQueryService.Filters filters =
ShopDataDuplicateCheckQueryService.Filters.clean("E0000001", "", "", "", "", "");
Map<String, Object> data = service.ledgerData(view, null, 1, 200, "asin", "asc", filters);
assertThat(data.get("total")).isEqualTo(1);
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
Map<String, Object> e = items.get(0);
assertThat(e.get("asin")).isEqualTo("E0000001");
assertThat(e.get("brand")).isEqualTo("BrandE");
assertThat(e.get("store_count")).isEqualTo(3);
assertThat(e.get("stores")).isEqualTo(List.of("ShopA", "ShopB", "ShopC"));
assertThat(e.get("groups")).isEqualTo(List.of("GroupA", "GroupC"));
assertThat(e.get("countries")).isEqualTo(List.of("UK", "FR"));
assertThat(e.get("record_count")).isEqualTo(3);
assertThat(String.valueOf(e.get("earliest"))).startsWith("2026-08-29");
assertThat(String.valueOf(e.get("latest"))).startsWith("2026-08-31");
}
@Test
void ledgerStoreCountDescPutsThreeShopAsinFirst() {
Map<String, Object> data = service.ledgerData(view, null, 1, 200, "store_count", "desc", noFilter());
assertThat(data.get("total")).isEqualTo(6);
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
assertThat(items.get(0).get("asin")).isEqualTo("E0000001");
assertThat(items.get(0).get("store_count")).isEqualTo(3);
}
@Test
void ledgerLeaderCropCountsOnlyVisibleStores() {
Set<String> visible = Set.of("shopa", "shopb");
ShopDataDuplicateCheckQueryService.Filters filters =
ShopDataDuplicateCheckQueryService.Filters.clean("E0000001", "", "", "", "", "");
Map<String, Object> data = service.ledgerData(view, visible, 1, 200, "", "", filters);
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
assertThat(items).hasSize(1);
assertThat(items.get(0).get("store_count")).isEqualTo(2);
assertThat(items.get(0).get("stores")).isEqualTo(List.of("ShopA", "ShopB"));
assertThat(items.get(0).get("record_count")).isEqualTo(2);
}
@Test
void ledgerPendingShapes() {
Map<String, Object> data = service.pendingLedger(2, 100);
assertThat(data.get("pending")).isEqualTo(true);
assertThat(data.get("total")).isEqualTo(0);
assertThat(data.get("page")).isEqualTo(2);
assertThat(data.get("page_size")).isEqualTo(100);
}
@Test
void exportCsvBomHeaderAndMonitorRows() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -157,4 +264,16 @@ class ShopDataDuplicateCheckQueryServiceTest {
assertThat(text).contains(",法国,");
assertThat(lines[1]).doesNotContain(",UK,");
}
@Test
void groupFilterNarrowsToGroupMembers() {
// 分组筛选:GroupA 只保留 ShopA/ShopB 键。
Set<String> keys = service.withGroupFilter(null, view, "GroupA");
assertThat(keys).containsExactlyInAnyOrder("shopa", "shopb");
// 与数据范围交集:可见集只剩 ShopA 时,交集只剩 ShopA。
Set<String> cropped = service.withGroupFilter(Set.of("shopa"), view, "GroupA");
assertThat(cropped).containsExactly("shopa");
// 空分组名不裁剪。
assertThat(service.withGroupFilter(null, view, " ")).isNull();
}
}