982 lines
53 KiB
Vue
982 lines
53 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 店铺数据重复检查 · 撞款控制台 —— 观感与交互像素级复刻 reference/asin-store-console(index.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> </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> </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-console(index.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>
|