Files
crawler-plugin/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue
T

982 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 店铺数据重复检查 · 撞款控制台 —— 观感与交互像素级复刻 reference/asin-store-consoleindex.template.html),
* 数据源保持自家后端:/duplicate-check-console(指标/店铺/撞款全集/分组统计) + /duplicate-check-ledger(台账分页) + /duplicate-check-export(Excel)。
* 分组归属来自「店铺管理」真实店铺分组,本页只读展示,仅用于分组撞款范围切换。
*/
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useAdminSessionStore } from '@/stores/admin-session'
import type { DuplicateItem, DuplicateOccurrence, DuplicateMetrics, DuplicateShop } from './duplicate-model.ts'
import { fetchDuplicateConsole, fetchDuplicateLedger, requestDuplicateExportBlob, 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, 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: '', dateFrom: '', dateTo: '' })
/** 角色范围:超管可看全部分组店铺并可跨分组筛选;普通管理员数据已由后端裁剪到自己分组,无需分组筛选。 */
const session = useAdminSessionStore()
const isSuper = computed(() => session.isSuperAdmin)
const activeTab = ref<Tab>('dup')
const scope = ref<Scope>('global')
const selectedGroup = ref('')
const shopGroups = ref<ShopGroups>({ members: {}, shopGroupNames: {} })
const LEDGER_PAGE_SIZE = 20
const ledgerItems = ref<LedgerRow[]>([])
const ledgerTotal = ref(0)
const ledgerPage = ref(1)
const ledgerLoading = ref(false)
const jumpInput = ref('')
const sortState = reactive<{ key: LedgerSortKey | ''; dir: 'asc' | 'desc' }>({ key: 'earliest', dir: 'asc' })
import OldPagination from '@/components/OldPagination.vue'
const drawerVisible = ref(false)
const drawerTitle = ref('')
const drawerBrand = ref('')
const drawerDup = ref(false)
const drawerOccurrences = ref<DuplicateOccurrence[]>([])
const drawerAggRows = computed<DrawerAggRow[]>(() => aggregateDrawerRows(drawerOccurrences.value))
const drawerSummary = computed<DrawerSummary>(() => drawerSummaryOf(drawerOccurrences.value))
const groupInfoVisible = ref(false)
let toastTimer: number | undefined
function toast(msg: string) {
const t = document.getElementById('dup-toast')
if (!t) return
t.textContent = msg
t.classList.add('show')
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2400)
}
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]
})
/** 店铺展示顺序(reference:按上架记录量降序),店铺分布/矩阵列/筛选下拉共用。 */
const orderedShops = computed<DuplicateShop[]>(() =>
[...shops.value].sort((a, b) => b.recordCount - a.recordCount || a.shopName.localeCompare(b.shopName)),
)
const kpis = computed(() => {
const s = summary.value
const totalDup = consoleData.value?.totalDup ?? dupItems.value.length
const groupDupTotal = groupStats.value.reduce((sum, group) => sum + group.dupCount, 0)
const groupSub = groupStats.value.map((group) => group.name).join(' / ')
const siteSub = [...countryOptions.value].map((code) => asinCountryLabel(code)).join(' / ')
return [
{ label: '唯一ASIN', value: (s?.asinTotal ?? 0).toLocaleString(), sub: '去重后', tone: '' },
{ label: '上架记录', value: (s?.recordTotal ?? 0).toLocaleString(), sub: '含重复扫描', tone: '' },
{ label: '在售店铺', value: (s?.shopCount ?? orderedShops.value.length).toLocaleString(), sub: '本批数据', tone: 'primary' },
{ label: '分组', value: groupStats.value.length.toLocaleString(), sub: groupSub || '未配置', tone: '' },
{ label: '全店撞款', value: totalDup.toLocaleString(), sub: '≥2店在售', tone: 'danger' },
{ label: '组内撞款', value: groupDupTotal.toLocaleString(), sub: '分组内≥2店', tone: groupDupTotal > 0 ? 'danger' : '' },
{ label: '覆盖站点', value: (s?.siteCount ?? 0).toLocaleString(), sub: siteSub, tone: '' },
]
})
const storeDistribution = computed(() => {
const list = orderedShops.value.map((shop) => ({ name: shop.shopName, group: shop.groupName, records: shop.recordCount, asins: shop.asinCount }))
const max = Math.max(1, ...list.map((item) => item.records))
return { max, list }
})
function selectedGroupMembers(): string[] {
if (!selectedGroup.value) return []
return shopGroups.value.members[selectedGroup.value] || []
}
const groupChips = computed(() => groupStats.value.filter((group) => group.shopCount >= 2))
/** 当前撞款行集:全局=撞款全集;分组=组内成员撞款子集;均按 ASIN 升序(reference 台账顺序)。 */
const dupRows = computed<DuplicateItem[]>(() => {
const rows = scope.value === 'global' ? dupItems.value : dupWithinGroup(dupItems.value, selectedGroupMembers())
return [...rows].sort((a, b) => a.asin.localeCompare(b.asin))
})
/** 矩阵列:分组范围=该组成员店;全店=全部可见店铺(按上架量降序)。 */
const matrixShops = computed<string[]>(() => {
if (scope.value === 'group') return selectedGroupMembers()
return orderedShops.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 ''
}
// ---- 撞款视图分页(分布/矩阵/撞款详情/抽屉明细;10/20/50/100 可选) ----
const distCount = computed(() => storeDistribution.value.list.length)
const distPage = ref(1)
const distPageSize = ref(10)
const pagedDistBars = computed(() =>
storeDistribution.value.list.slice((distPage.value - 1) * distPageSize.value, distPage.value * distPageSize.value),
)
const matrixPage = ref(1)
const matrixPageSize = ref(10)
const matrixRows = computed(() =>
dupRows.value.slice((matrixPage.value - 1) * matrixPageSize.value, matrixPage.value * matrixPageSize.value),
)
const dupCardPage = ref(1)
const dupCardPageSize = ref(6)
const dupCardRows = computed(() =>
dupRows.value.slice((dupCardPage.value - 1) * dupCardPageSize.value, dupCardPage.value * dupCardPageSize.value),
)
const drawerPage = ref(1)
const drawerPageSize = ref(10)
const drawerRows = computed(() =>
drawerAggRows.value.slice((drawerPage.value - 1) * drawerPageSize.value, drawerPage.value * drawerPageSize.value),
)
watch([dupRows, drawerOccurrences], () => {
matrixPage.value = Math.min(matrixPage.value, Math.max(1, Math.ceil(dupRows.value.length / matrixPageSize.value)))
dupCardPage.value = Math.min(dupCardPage.value, Math.max(1, Math.ceil(dupRows.value.length / dupCardPageSize.value)))
drawerPage.value = 1
})
const matrixHint = computed(() =>
scope.value === 'global'
? '行 = 全店撞款ASIN,列 = 全部店铺;有数字表示该店在售此 ASIN,点击行可看上架时间明细'
: selectedGroup.value
? `行 = 分组「${selectedGroup.value}」内的撞款ASIN,列 = 该分组店铺;有数字表示组内该店在售此 ASIN`
: '行 = 分组内撞款ASIN,列 = 该分组店铺;有数字表示组内该店在售此 ASIN',
)
/* ----- 矩阵单元格辅助 ----- */
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}`
}
function activeFilterParams() {
return { asin: filter.asin.trim(), shopName: filter.shop.trim(), group: filter.group.trim(), country: filter.country, site: '', dateFrom: filter.dateFrom, dateTo: filter.dateTo }
}
async function loadConsole() {
loading.value = true
loadError.value = ''
try {
consoleData.value = await fetchDuplicateConsole({ view: '', ...activeFilterParams() })
shopGroups.value = indexShopGroups(orderedShops.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 : '撞款数据加载失败'
toast(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 = await fetchDuplicateLedger({ view: '', ...activeFilterParams() }, page, LEDGER_PAGE_SIZE, sortState.key, sortState.dir)
ledgerItems.value = result.items
ledgerTotal.value = result.total
ledgerPage.value = result.page
} catch (error) {
toast(error instanceof Error ? error.message : '台账加载失败')
} finally {
ledgerLoading.value = false
}
}
function applyQuery() {
if (filter.dateFrom && filter.dateTo && filter.dateFrom > filter.dateTo) {
toast('开始日期不能晚于结束日期')
return
}
loadConsole()
if (activeTab.value === 'ledger') loadLedger(1)
}
function resetQuery() {
filter.asin = ''
filter.shop = ''
filter.group = ''
filter.country = ''
filter.dateFrom = ''
filter.dateTo = ''
scope.value = 'global'
selectedGroup.value = ''
activeTab.value = 'dup'
applyQuery()
toast('筛选已重置')
}
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: '', group: '', country: '', site: '', dateFrom: '', dateTo: '' })
await loadConsole()
toast('重新分析完成,已同步最新扫描结果')
} catch (error) {
toast(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((entry) => entry.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)
}
/* ----- 台账排序/分页(服务器端 sort_key/page_size ----- */
const ledgerCols: { label: string; key: LedgerSortKey | '' }[] = [
{ label: 'ASIN', key: 'asin' },
{ label: '品牌', key: 'brand' },
{ label: '上架店铺', key: 'store_count' },
{ label: '涉及店铺', key: '' },
{ label: '分组', key: '' },
{ label: '站点', key: '' },
{ label: '上架次数', key: 'record_count' },
{ label: '最早上架', key: 'earliest' },
{ label: '最近上架', key: 'latest' },
{ label: '操作', key: '' },
]
function onSort(key: LedgerSortKey) {
if (sortState.key === key) sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc'
else {
sortState.key = key
sortState.dir = 'asc'
}
loadLedger(1)
}
const totalPages = computed(() => Math.max(1, Math.ceil(ledgerTotal.value / LEDGER_PAGE_SIZE)))
function goPage(page: number) {
if (page < 1 || page > totalPages.value) return
loadLedger(page)
}
function jumpPage() {
const value = parseInt(jumpInput.value, 10)
if (!Number.isNaN(value) && value >= 1 && value <= totalPages.value) {
goPage(value)
jumpInput.value = ''
}
}
async function exportXlsx() {
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 requestDuplicateExportBlob({ view, ...params }, view)
saveBlob(blob, `店铺数据_${activeTab.value === 'ledger' ? '台账' : '撞款'}_${new Date().toISOString().slice(0, 10)}.xlsx`)
toast('已导出 Excel')
} catch (error) {
toast(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'
}
onMounted(() => {
loadConsole()
// 台账 Tab 计数打开即显示:挂载时预取第一页拿 total(对齐 reference 渲染即见台账条数)。
void loadLedger(1)
})
</script>
<template>
<div class="dup-ref">
<div class="dup-head">
<div>
<h2>店铺数据撞款监控</h2>
<p> ASIN×店铺×站点分析同一 ASIN 在多家自营店铺/分组内的重复上架分组归属来自店铺管理只读展示</p>
</div>
</div>
<div v-if="loadError" class="load-banner">
<span>{{ loadError }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="loadError = ''">知道了</button>
</div>
<!-- 筛选条件 -->
<section class="card filter">
<div class="filter-title">筛选条件</div>
<div class="filter-row">
<div class="f-group">
<label>ASIN</label>
<input type="text" v-model="filter.asin" placeholder="模糊搜索ASIN" @keyup.enter="applyQuery" />
</div>
<div class="f-group">
<label>店铺</label>
<select v-model="filter.shop">
<option value="">全部店铺</option>
<option v-for="shop in orderedShops" :key="shop.shopName" :value="shop.shopName">{{ shop.shopName }}</option>
</select>
</div>
<div v-if="isSuper" class="f-group">
<label>分组</label>
<select v-model="filter.group">
<option value="">全部分组</option>
<option v-for="group in groupStats" :key="group.name" :value="group.name">{{ group.name }}</option>
</select>
</div>
<div class="f-group">
<label>国家 / 站点</label>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in countryOptions" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="f-group">
<label>上架时间开始</label>
<input type="date" v-model="filter.dateFrom" />
</div>
<div class="f-group">
<label>上架时间结束</label>
<input type="date" v-model="filter.dateTo" />
</div>
<div class="f-group">
<label>&nbsp;</label>
<button class="btn btn-primary" type="button" @click="applyQuery">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
查询
</button>
</div>
<div class="f-group">
<label>&nbsp;</label>
<button class="btn btn-ghost" type="button" @click="resetQuery">重置</button>
</div>
</div>
</section>
<div v-loading="loading" class="dup-body-wrap">
<template v-if="consoleData">
<!-- KPI 统计 -->
<section class="kpis">
<div v-for="kpi in kpis" :key="kpi.label" class="kpi" :class="kpi.tone">
<div class="k-label"><span class="k-dot"></span>{{ kpi.label }}</div>
<div class="k-value">{{ kpi.value }}</div>
<div class="k-sub">{{ kpi.sub }}</div>
</div>
</section>
<!-- 店铺上架分布 -->
<section class="card store-strip">
<div class="strip-head">
<span class="strip-title">店铺上架分布</span>
<span class="strip-note">各店铺在本批站点中的上架记录量含重复扫描</span>
</div>
<div class="strip-bars">
<div v-for="bar in pagedDistBars" :key="bar.name" class="s-bar">
<div class="s-name">
<span>{{ bar.name }}<span v-if="bar.group" class="s-grp"> · {{ 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>
<OldPagination v-if="distCount > distPageSize" :total="distCount" :page="distPage" :page-size="distPageSize" @change="distPage = $event" @size-change="(s: number) => { distPageSize = s; distPage = 1 }" />
</section>
<!-- 主区 Tabs + 操作 -->
<div class="content-head">
<div class="tabs">
<button class="tab" :class="{ active: activeTab === 'dup' }" type="button" @click="switchTab('dup')">撞款监控 <span class="t-count">{{ totalDupCount }}</span></button>
<button class="tab" :class="{ active: activeTab === 'ledger' }" type="button" @click="switchTab('ledger')">全部ASIN台账 <span class="t-count">{{ ledgerTotal }}</span></button>
</div>
<div class="actions">
<span v-if="overview" class="updated">数据更新时间 {{ overview.scannedAt || '—' }}</span>
<button v-if="isSuper" class="btn btn-ghost btn-sm" type="button" @click="groupInfoVisible = true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>
分组说明
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="reanalyzing" @click="reAnalyze">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4" /><path d="M21 3v6h-6" /></svg>
{{ reanalyzing ? '重新分析中…' : '重新分析' }}
</button>
<button class="btn btn-ghost btn-sm" type="button" @click="exportXlsx">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M7 10l5 5 5-5" /><path d="M4 21h16" /></svg>
导出
</button>
</div>
</div>
<!-- ============ 撞款监控 ============ -->
<template v-if="activeTab === 'dup'">
<div v-if="isSuper" class="scope-bar">
<div class="seg">
<button class="seg-btn" :class="{ active: scope === 'global' }" type="button" @click="switchScope('global')">全店撞款</button>
<button class="seg-btn" :class="{ active: scope === 'group' }" type="button" @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" type="button" 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="chip-empty">暂无 2 家店铺的分组无法做分组撞款分析</span>
</div>
</div>
<div v-if="scope === 'group' && activeGroupStat" class="group-summary card">
<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">{{ matrixHint }}</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 matrixRows" :key="item.asin" @click="openDupDrawer(item)">
<td><span class="asin-code">{{ item.asin }}</span></td>
<td><span class="m-brand">{{ itemBrand(item) || '—' }}</span></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>
<div v-else class="empty-state">
<div class="e-title">当前范围下没有撞款ASIN</div>
调整筛选条件或切换到其他分组查看
</div>
</div>
<OldPagination v-if="dupRows.length > matrixPageSize" :total="dupRows.length" :page="matrixPage" :page-size="matrixPageSize" @change="matrixPage = $event" @size-change="(s: number) => { matrixPageSize = s; matrixPage = 1 }" />
<div class="section-title" style="margin-top:24px">撞款详情 <span class="hint">同一 ASIN 在多家店铺的上架时间明细</span></div>
<div v-if="dupRows.length" class="dup-grid">
<div v-for="item in dupCardRows" :key="item.asin" class="dup-card">
<div class="dup-head">
<span class="asin-code">{{ item.asin }}</span>
<span class="badge dup">{{ item.shopCount }}店在售</span>
<span class="d-brand">{{ itemBrand(item) || '未知品牌' }}</span>
<button class="link-btn" type="button" @click="openDupDrawer(item)">查看明细</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>
<template v-for="site in block.sites" :key="site.country">
<div class="c-site"><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>
</template>
</div>
</div>
</div>
</div>
<div v-else class="dup-empty">当前范围下没有撞款ASIN</div>
<div class="dup-total"> <b>{{ dupRows.length }}</b> {{ scope === 'group' && selectedGroup ? `分组「${selectedGroup}」组内撞款ASIN` : '全店撞款ASIN' }}</div>
</template>
<!-- ============ 全部ASIN台账 ============ -->
<template v-else>
<div class="table-wrap" v-loading="ledgerLoading">
<table v-if="ledgerItems.length" class="ledger">
<thead>
<tr>
<th v-for="col in ledgerCols" :key="col.label" :class="{ sortable: !!col.key }" @click="col.key && onSort(col.key)">
{{ col.label }}<span v-if="sortState.key === col.key" class="arr">{{ sortState.dir === 'asc' ? '▲' : '▼' }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in ledgerItems" :key="row.asin">
<td><span class="link" @click="openLedgerDrawer(row)">{{ row.asin }}</span></td>
<td class="brand-cell">{{ row.brand || '—' }}</td>
<td><span class="badge" :class="row.storeCount >= 2 ? 'dup' : 'single'">{{ row.storeCount }} </span></td>
<td class="chips-cell"><span v-for="store in row.stores" :key="store" class="badge store">{{ store }}</span></td>
<td class="chips-cell">
<template v-if="row.groups.length"><span v-for="group in row.groups" :key="group" class="badge country">{{ group }}</span></template>
<span v-else class="dim-cell"></span>
</td>
<td><span v-for="code in row.countries" :key="code" class="site-chip" :style="{ background: siteColor(code) + '1F', color: siteColor(code) }">{{ asinCountryLabel(code) }}</span></td>
<td class="num-cell">{{ row.recordCount }}</td>
<td class="mono dt-cell">{{ row.earliest || '—' }}</td>
<td class="mono dt-cell">{{ row.latest || '—' }}</td>
<td><button class="btn btn-ghost btn-sm" type="button" @click="openLedgerDrawer(row)">明细</button></td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">
<div class="e-title">暂无台账记录</div>
调整筛选条件或触发重新分析后再查看
</div>
</div>
<div v-if="ledgerTotal" class="pager">
<span class="total"> <b>{{ ledgerTotal.toLocaleString() }}</b> · {{ ledgerPage }}/{{ totalPages }} </span>
<button class="pg-btn" type="button" :disabled="ledgerPage <= 1" @click="goPage(ledgerPage - 1)">上一页</button>
<button class="pg-btn" type="button" :disabled="ledgerPage >= totalPages" @click="goPage(ledgerPage + 1)">下一页</button>
<span class="jump">跳至 <input type="number" min="1" :max="totalPages" v-model="jumpInput" @keyup.enter="jumpPage" /> 页 <button class="btn btn-ghost btn-sm" type="button" @click="jumpPage">跳转</button></span>
</div>
</template>
</template>
<div v-else-if="!loadError" class="card empty-state">
<div class="e-title">暂无扫描结果</div>
请点击右上角重新分析触发撞款扫描
</div>
</div>
<!-- 明细抽屉 -->
<div class="mask" :class="{ show: drawerVisible }" v-show="drawerVisible" @click="drawerVisible = false"></div>
<aside class="drawer" :class="{ show: drawerVisible }" v-show="drawerVisible">
<div class="drawer-head">
<span class="d-title">{{ drawerTitle }}</span>
<span v-if="drawerDup" class="badge dup">撞款</span>
<button class="drawer-close" type="button" @click="drawerVisible = false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="drawer-body">
<div class="d-brand">品牌{{ drawerBrand || '未知' }}</div>
<div class="d-summary">
<span class="badge store">{{ drawerSummary.shopCount }} 家店铺</span>
<template v-for="code in drawerSummary.countries" :key="code"><span class="site-chip" :style="{ background: siteColor(code) + '1F', color: siteColor(code) }">{{ asinCountryLabel(code) }}</span></template>
<span class="badge single">{{ drawerSummary.totalRecords }} 次上架</span>
<span class="badge time">最早上架 {{ drawerSummary.earliest || '—' }}</span>
</div>
<table v-if="drawerAggRows.length" class="d-table">
<thead>
<tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间按时间升序</th><th>次数</th></tr>
</thead>
<tbody>
<tr v-for="row in drawerRows" :key="row.shopName + '|' + row.country">
<td class="cell-strong">{{ row.shopName }}</td>
<td><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
<td><span class="site-chip" :style="{ background: siteColor(row.country) + '1F', color: siteColor(row.country) }">{{ asinCountryLabel(row.country || '') }}</span></td>
<td><template v-for="(time, index) in row.times" :key="index"><span class="d-time">{{ time }}</span></template></td>
<td class="num-cell">{{ row.count }}</td>
</tr>
</tbody>
</table>
<div v-else class="dup-empty">暂无上架时间明细</div>
</div>
</aside>
<!-- 分组说明只读来自店铺管理 -->
<div class="mask" :class="{ show: groupInfoVisible }" v-show="groupInfoVisible" @click="groupInfoVisible = false"></div>
<aside class="drawer" :class="{ show: groupInfoVisible }" v-show="groupInfoVisible" style="width:560px">
<div class="drawer-head">
<span class="m-title">分组撞款说明</span>
<button class="drawer-close" type="button" @click="groupInfoVisible = false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="drawer-body">
<p class="note">分组归属来自店铺管理的真实店铺分组仅用于本页分组撞款范围切换本页不做分组编辑如需调整店铺所属分组请到店铺管理维护会影响对应负责人的数据查看范围)。</p>
<table class="d-table">
<thead><tr><th>分组</th><th>店铺数</th><th>成员店铺</th><th>组内撞款</th></tr></thead>
<tbody>
<tr v-for="group in groupStats" :key="group.name">
<td class="cell-strong">{{ group.name }}</td>
<td class="num-cell">{{ group.shopCount }}</td>
<td><template v-for="shop in (shopGroups.members[group.name] || [])" :key="shop"><span class="badge store">{{ shop }}</span></template></td>
<td class="num-cell">{{ group.dupCount }}</td>
</tr>
<tr v-if="!groupStats.length"><td colspan="4" class="empty-cell">暂无分组</td></tr>
</tbody>
</table>
</div>
</aside>
<div id="dup-toast"></div>
</div>
</template>
<style>
/* ============================================================
像素级复刻 reference asin-store-consoleindex.template.html):
页面内独立令牌 + 组件观感。所有选择器前缀 .dup-ref 防外泄。
============================================================ */
.dup-ref {
/* reference 设计令牌 */
--bg: #F2F4F8;
--surface: #FFFFFF;
--border: #E5E9F0;
--border-strong: #D5DBE6;
--text: #1C2230;
--text2: #5A6474;
--text3: #98A1B1;
--primary: #2E5BE6;
--primary-hover: #1E4BD8;
--primary-weak: #EAF0FF;
--danger: #D63A4A;
--danger-weak: #FDEBED;
--green: #1F9D62;
--green-weak: #E7F7EF;
--amber: #D9912B;
--amber-weak: #FCF2E3;
--mono: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
--shadow: 0 1px 2px rgba(20, 30, 60, 0.05), 0 4px 18px rgba(20, 30, 60, 0.06);
--radius: 10px;
max-width: 1560px;
margin: 0 auto;
color: var(--text);
font-size: 13px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.dup-ref .mono { font-family: var(--mono); }
.dup-ref button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
.dup-ref button:disabled { opacity: 0.55; cursor: not-allowed; }
.dup-ref input, .dup-ref select { font-family: inherit; font-size: 13px; color: var(--text); }
.dup-ref a { color: var(--primary); text-decoration: none; }
.dup-ref .dim-cell { color: var(--text3); }
.dup-ref .num-cell { font-variant-numeric: tabular-nums; }
.dup-ref .cell-strong { font-weight: 600; }
.dup-ref .empty-cell { text-align: center; color: var(--text3); padding: 18px 10px !important; }
/* ---------- 页面标题(本页在 admin 壳内的控制台头部) ---------- */
.dup-ref .dup-head { margin: 0 0 18px; }
.dup-ref .dup-head h2 { margin: 0; font-size: 20px; color: var(--text); }
.dup-ref .dup-head p { margin: 7px 0 0; color: var(--text2); font-size: 13px; }
.dup-ref .load-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
background: var(--danger-weak); color: var(--danger);
border: 1px solid #F3C6CC; border-radius: var(--radius);
padding: 10px 14px; margin-bottom: 14px; font-size: 13px;
}
/* ---------- 卡片 ---------- */
.dup-ref .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
/* ---------- 筛选条件 ---------- */
.dup-ref .filter { padding: 16px 18px; margin-bottom: 16px; }
.dup-ref .filter-title { font-size: 12px; color: var(--text3); margin-bottom: 12px; letter-spacing: 0.5px; }
.dup-ref .filter-row { display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap; }
.dup-ref .f-group { display: flex; flex-direction: column; gap: 6px; }
.dup-ref .f-group label { font-size: 12px; color: var(--text2); }
.dup-ref .f-group input, .dup-ref .f-group select {
height: 34px; border: 1px solid var(--border-strong); border-radius: 7px; padding: 0 10px; background: #FBFCFE; outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.dup-ref .f-group input:focus, .dup-ref .f-group select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(46, 91, 230, 0.12); background: #fff; }
.dup-ref .f-group input { width: 170px; }
.dup-ref .f-group select { width: 150px; cursor: pointer; }
.dup-ref .f-group input[type="date"] { width: 150px; }
.dup-ref .btn {
height: 34px; border-radius: 7px; padding: 0 16px; font-size: 13px; font-weight: 600;
display: inline-flex; align-items: center; gap: 6px; transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.dup-ref .btn-primary { background: var(--primary); color: #fff; }
.dup-ref .btn-primary:hover { background: var(--primary-hover); }
.dup-ref .btn-ghost { background: #fff; color: var(--text2); border: 1px solid var(--border-strong); }
.dup-ref .btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
.dup-ref .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; border-radius: 6px; }
/* ---------- KPI 统计 ---------- */
.dup-ref .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 16px; }
.dup-ref .kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; }
.dup-ref .kpi .k-label { font-size: 12px; color: var(--text3); display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.dup-ref .kpi .k-value { font-size: 26px; font-weight: 700; letter-spacing: 0.3px; font-variant-numeric: tabular-nums; }
.dup-ref .kpi .k-sub { font-size: 11px; color: var(--text3); margin-top: 3px; }
.dup-ref .kpi.danger .k-value { color: var(--danger); }
.dup-ref .kpi.primary .k-value { color: var(--primary); }
.dup-ref .k-dot { width: 8px; height: 8px; border-radius: 50%; background: #B8C0CE; }
.dup-ref .kpi.danger .k-dot { background: var(--danger); }
.dup-ref .kpi.primary .k-dot { background: var(--primary); }
/* ---------- 店铺分布条 ---------- */
.dup-ref .store-strip { padding: 14px 18px; margin-bottom: 16px; }
.dup-ref .strip-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.dup-ref .strip-title { font-size: 13px; font-weight: 600; }
.dup-ref .strip-note { font-size: 11px; color: var(--text3); }
.dup-ref .strip-bars { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 16px 20px; }
.dup-ref .s-bar .s-name { font-size: 12px; color: var(--text2); margin-bottom: 6px; display: flex; justify-content: space-between; gap: 8px; }
.dup-ref .s-bar .s-name b { color: var(--text); font-weight: 600; }
.dup-ref .s-grp { color: var(--text3); font-weight: 400; font-size: 11px; }
.dup-ref .s-track { height: 8px; background: #EEF1F6; border-radius: 5px; overflow: hidden; }
.dup-ref .s-fill { height: 100%; border-radius: 5px; background: linear-gradient(90deg, #2E5BE6, #6C8BEF); }
.dup-ref .s-bar .s-meta { font-size: 11px; color: var(--text3); margin-top: 5px; }
/* ---------- 内容区头部(Tabs + 操作) ---------- */
.dup-ref .content-head { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 14px; flex-wrap: wrap; gap: 10px; }
.dup-ref .tabs { display: flex; gap: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: 9px; padding: 4px; }
.dup-ref .tab {
padding: 7px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--text2);
display: inline-flex; align-items: center; gap: 7px; transition: all 0.15s;
}
.dup-ref .tab:hover { color: var(--primary); }
.dup-ref .tab.active { background: var(--primary); color: #fff; }
.dup-ref .tab .t-count { background: rgba(0, 0, 0, 0.12); border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 700; }
.dup-ref .tab.active .t-count { background: rgba(255, 255, 255, 0.25); }
.dup-ref .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.dup-ref .actions .updated { font-size: 11px; color: var(--text3); margin-right: 4px; }
/* ---------- 撞款检测范围(分段控件) ---------- */
.dup-ref .scope-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.dup-ref .seg { display: flex; gap: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: 9px; padding: 4px; box-shadow: var(--shadow); }
.dup-ref .seg-btn { padding: 7px 16px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--text2); transition: all 0.15s; }
.dup-ref .seg-btn:hover { color: var(--primary); }
.dup-ref .seg-btn.active { background: var(--primary); color: #fff; }
.dup-ref .group-chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.dup-ref .g-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 20px; font-size: 12.5px; font-weight: 600;
background: var(--surface); border: 1px solid var(--border-strong); color: var(--text2); transition: all 0.15s; box-shadow: var(--shadow);
}
.dup-ref .g-chip:hover { border-color: var(--primary); color: var(--primary); }
.dup-ref .g-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.dup-ref .g-chip.dup:not(.active) { border-color: #F3C6CC; background: var(--danger-weak); color: var(--danger); }
.dup-ref .g-chip .g-count { font-size: 11px; font-weight: 500; opacity: 0.85; }
.dup-ref .chip-empty { font-size: 12px; color: var(--text3); }
/* ---------- 分组概览 ---------- */
.dup-ref .group-summary { display: grid; grid-template-columns: repeat(4, 1fr); overflow: hidden; margin-bottom: 16px; }
.dup-ref .gs-item { padding: 16px 20px; border-right: 1px solid var(--border); }
.dup-ref .gs-item:last-child { border-right: none; }
.dup-ref .gs-item.warn { background: var(--danger-weak); }
.dup-ref .gs-v { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.dup-ref .gs-item.warn .gs-v { color: var(--danger); }
.dup-ref .gs-l { font-size: 12px; color: var(--text2); margin-top: 4px; }
/* ---------- 撞款覆盖矩阵 ---------- */
.dup-ref .section-title { font-size: 14px; font-weight: 700; display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.dup-ref .section-title .hint { font-size: 11px; font-weight: 400; color: var(--text3); }
.dup-ref .matrix-wrap { overflow-x: auto; overflow-y: auto; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); max-height: 480px; }
.dup-ref table.matrix { border-collapse: separate; border-spacing: 0; width: 100%; background: var(--surface); }
.dup-ref table.matrix th {
background: #F7F9FC; color: var(--text2); font-size: 12px; font-weight: 600; padding: 10px 12px;
border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap;
position: sticky; top: 0; z-index: 1;
}
.dup-ref table.matrix td { padding: 9px 12px; border-bottom: 1px solid #EFF2F7; vertical-align: middle; font-size: 12.5px; white-space: nowrap; }
.dup-ref table.matrix tbody tr { cursor: pointer; }
.dup-ref table.matrix tbody tr:hover td { background: #F7FAFF; }
.dup-ref .m-brand { color: var(--text2); }
.dup-ref .m-cell {
width: 34px; height: 30px; border-radius: 7px; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 12.5px; color: var(--primary); background: var(--primary-weak); position: relative;
}
.dup-ref .m-cell.empty { background: #F1F3F8; color: #C4CBD8; font-weight: 400; }
.dup-ref .m-cell .dots { position: absolute; bottom: 2px; left: 0; right: 0; display: flex; justify-content: center; gap: 2px; }
.dup-ref .m-dot { width: 4px; height: 4px; border-radius: 50%; display: inline-block; }
.dup-ref .asin-code { font-family: var(--mono); font-weight: 700; font-size: 12.5px; letter-spacing: 0.4px; color: var(--primary); }
/* ---------- 撞款详情卡片 ---------- */
.dup-ref .badge { display: inline-flex; align-items: center; gap: 5px; border-radius: 20px; padding: 2px 9px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.dup-ref .badge.dup { background: var(--danger-weak); color: var(--danger); }
.dup-ref .badge.single { background: var(--green-weak); color: var(--green); }
.dup-ref .badge.store { background: var(--primary-weak); color: var(--primary); }
.dup-ref .badge.country { background: #EFF2F8; color: var(--text2); font-weight: 500; }
.dup-ref .badge.time { background: #F1F3F8; color: var(--text2); }
.dup-ref .dup-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(368px, 1fr)); gap: 16px; }
.dup-ref .dup-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; display: flex; flex-direction: column; }
.dup-ref .dup-head {
padding: 13px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: linear-gradient(180deg, #FBFCFE, #F6F8FC);
}
.dup-ref .dup-head .d-brand { font-size: 12px; color: var(--text2); font-weight: 500; margin-left: 2px; }
.dup-ref .dup-head .link-btn { margin-left: auto; font-size: 12px; color: var(--primary); font-weight: 600; }
.dup-ref .dup-head .link-btn:hover { text-decoration: underline; }
.dup-ref .dup-body { padding: 14px 16px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px; flex: 1; align-content: start; }
.dup-ref .store-col .st-name { font-size: 12.5px; font-weight: 700; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.dup-ref .store-col .st-name .st-count { font-size: 11px; color: var(--text3); font-weight: 400; }
.dup-ref .store-col .st-grp { font-size: 10.5px; color: var(--text3); font-weight: 400; margin-bottom: 4px; }
.dup-ref .c-site { font-size: 11px; color: var(--text2); margin-top: 8px; font-weight: 600; display: flex; align-items: center; gap: 5px; }
.dup-ref .c-site .m-dot { width: 6px; height: 6px; }
.dup-ref .c-times { margin-top: 3px; }
.dup-ref .c-time { font-family: var(--mono); font-size: 11.5px; color: var(--text2); line-height: 1.7; display: block; }
.dup-ref .dup-empty { padding: 40px; text-align: center; color: var(--text3); font-size: 13px; }
.dup-ref .dup-total { margin-top: 12px; color: var(--text2); font-size: 12.5px; }
.dup-ref .dup-total b { color: var(--text); }
/* ---------- 全部ASIN台账表 ---------- */
.dup-ref .table-wrap { overflow-x: auto; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow); }
.dup-ref table.ledger { border-collapse: separate; border-spacing: 0; width: 100%; background: var(--surface); }
.dup-ref table.ledger th {
background: #F7F9FC; color: var(--text2); font-size: 12px; font-weight: 600; padding: 11px 12px;
border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; user-select: none; position: sticky; top: 0; z-index: 1;
}
.dup-ref table.ledger th.sortable { cursor: pointer; }
.dup-ref table.ledger th.sortable:hover { color: var(--primary); }
.dup-ref table.ledger th .arr { font-size: 10px; margin-left: 4px; color: var(--text3); }
.dup-ref table.ledger td { padding: 10px 12px; border-bottom: 1px solid #EFF2F7; font-size: 12.5px; white-space: nowrap; }
.dup-ref table.ledger tbody tr:hover td { background: #F7FAFF; }
.dup-ref table.ledger .link { color: var(--primary); font-weight: 600; cursor: pointer; }
.dup-ref table.ledger .link:hover { text-decoration: underline; }
.dup-ref .brand-cell { color: var(--text2); }
.dup-ref .chips-cell { max-width: 230px; }
.dup-ref .chips-cell .badge { margin-right: 4px; margin-bottom: 2px; }
.dup-ref .dt-cell { font-size: 11.5px; color: var(--text2); }
.dup-ref .site-chip { border-radius: 5px; padding: 1px 7px; font-size: 11.5px; margin-right: 4px; margin-bottom: 2px; font-weight: 600; display: inline-block; white-space: nowrap; }
/* ---------- 分页 ---------- */
.dup-ref .pager {
display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 16px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 16px;
box-shadow: var(--shadow);
}
.dup-ref .pager .total { color: var(--text2); font-size: 12.5px; margin-right: auto; }
.dup-ref .pager .total b { color: var(--text); font-size: 13px; }
.dup-ref .pager .pg-btn { height: 28px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 6px; font-size: 12px; color: var(--text2); background: #fff; }
.dup-ref .pager .pg-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.dup-ref .pager .pg-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.dup-ref .pager .jump { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text2); }
.dup-ref .pager .jump input { width: 52px; height: 28px; border: 1px solid var(--border-strong); border-radius: 6px; text-align: center; }
.dup-ref .pager .jump input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(46, 91, 230, 0.12); }
/* ---------- 空态 / 加载 ---------- */
.dup-ref .empty-state { padding: 48px 20px; text-align: center; color: var(--text3); font-size: 13px; }
.dup-ref .empty-state .e-title { font-size: 14px; font-weight: 600; color: var(--text2); margin-bottom: 6px; }
.dup-ref .dup-body-wrap { min-height: 120px; }
/* ---------- 抽屉(明细) ---------- */
.dup-ref .mask { position: fixed; inset: 0; background: rgba(16, 24, 40, 0.42); z-index: 100; }
.dup-ref .drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: 540px; max-width: 92vw; background: var(--surface); z-index: 101;
box-shadow: -8px 0 30px rgba(20, 30, 60, 0.18); display: flex; flex-direction: column;
}
.dup-ref .drawer-head { display: flex; align-items: center; gap: 10px; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.dup-ref .drawer-head .d-title { font-size: 15px; font-weight: 700; font-family: var(--mono); color: var(--primary); }
.dup-ref .drawer-head .m-title { font-size: 15px; font-weight: 700; }
.dup-ref .drawer-close { margin-left: auto; width: 30px; height: 30px; border-radius: 7px; display: flex; align-items: center; justify-content: center; color: var(--text3); }
.dup-ref .drawer-close:hover { background: #F1F3F8; color: var(--text); }
.dup-ref .drawer-body { padding: 18px 20px; overflow-y: auto; flex: 1; }
.dup-ref .d-brand { font-size: 12px; color: var(--text2); margin-bottom: 4px; }
.dup-ref .d-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 16px; align-items: center; }
.dup-ref .d-table { width: 100%; border-collapse: collapse; }
.dup-ref .d-table th { background: #F7F9FC; font-size: 12px; color: var(--text2); text-align: left; padding: 8px 10px; border: 1px solid var(--border); }
.dup-ref .d-table td { padding: 8px 10px; border: 1px solid var(--border); font-size: 12.5px; vertical-align: top; }
.dup-ref .d-table .d-time { font-family: var(--mono); font-size: 11.5px; display: block; line-height: 1.8; color: var(--text2); }
.dup-ref .d-stgrp { font-size: 11px; color: var(--text3); }
.dup-ref .note { font-size: 12px; color: var(--text2); line-height: 1.7; margin: 0 0 14px; }
/* ---------- Toast ---------- */
.dup-ref #dup-toast {
position: fixed; left: 50%; bottom: 34px; transform: translateX(-50%) translateY(20px); z-index: 200;
background: #1C2230; color: #fff; padding: 10px 18px; border-radius: 9px; font-size: 13px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25); opacity: 0; pointer-events: none; transition: all 0.25s;
max-width: 70vw; text-align: center;
}
.dup-ref #dup-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>